Spacing Tokens & Shared Primitives
Canonical reference for the spacing role-token layer and shared dialog/row/tree primitives introduced by the Spacing Rearchitecture epic (#3957). Distills the ratio doctrine, the new --spacing-* component tokens, the shared class constants, and the DialogCloseButton / treeIndentStyle primitives every consumer builds on.
Note
This page distills the doctrine locked in _(Sub #3961, Spacing Rearchitecture epic #3957) into a durable public reference. The token/primitive layer described here landed in wave 4 (#3962); site-by-site adoption across the six wave-5 rollout areas and the final _permanent.json lint sweep (wave 6, #3970) are tracked separately. See . for the quick-lookup token tables this page expands on.
Base scale — unchanged
The named spacing scale in packages/ is not touched by this epic:
0, 1px, 2xs=2, xs=4, sm=6, md=8, lg=12, xl=16, 2xl=20, 3xl=24, 4xl=32, 5xl=48Renaming or removing a step would force edits across ~3,000 existing usages. The governing rule for everything below: spend complexity only where the pixels are visibly broken — where a diagnosis found no visible defect, unify to one value and stop.
Display-scale contract
Every visual dimension must scale with --display-scale, with three fixed exceptions:
--spacing-1px/--spacing-tree-connector— hairlines, always 1px.--overlay-offset-top—vh, viewport-relative by design.The floor portion of
--spacing-touch-min(44px never shrinks below the floor, even at 0.75×).
Tokens.css declares every scaled token twice: the unscaled base lives in the @theme block (so Tailwind can generate the utility class), and the calc(Npx * var(--display-scale)) override lives in a later :root block (which wins the cascade at runtime). The split is load-bearing — @theme cannot carry var(--display-scale) — so both blocks must be updated together. A unit test (packages/) asserts the @theme and :root --spacing-* key sets match, modulo the fixed-token allowlist, as a durable guard against the "declared twice, forget one block" footgun.
Ratio doctrine — two interleaved ×2 ladders
The base scale contains two clean doubling ladders plus two off-ladder fine-tuning steps:
Ladder A (×2): xs 4 → md 8 → xl 16 → 4xl 32
Ladder B (×2): sm 6 → lg 12 → 3xl 24 → 5xl 48
Off-ladder: 2xs 2 (below the ladders), 2xl 20 (between xl 16 and 3xl 24)The rule, applied by review + the squint test (lint cannot see ratio): at any grouping boundary, pick the within-group gap and the between-group gap from the same ladder, with the between-group gap at least one rung higher (exactly 2×) than the within-group gap. Adjacent-step pairings (xs↔sm, sm↔md, md↔lg, lg↔xl, xl↔2xl, 2xl↔3xl, 3xl↔4xl) read as one group and are the exact failure the initial diagnosis measured on the timeline view. Off-ladder steps (2xs, 2xl) are for absolute fitting — never a within/between grouping partner against a neighbour.
North-stars validated by the diagnosis: kanban card 6↔26 (4.3×), settings body 10↔28 (2.8×). New groupings should land in the 2×–3× band.
Semantic role-token layer
Two delivery mechanisms, chosen per role:
New
--spacing-*component tokens — for roles whose value is not on the base scale (28, 22, 44, 3, a 16px gap) or that need independent scaling behavior (a touch floor, a rounded/floored bar).Shared class constants — for roles that are a pairing of existing base-scale tokens (a header is px+py+border). A single CSS var can't express a padding shape, but a named class can.
New --spacing-* tokens (both @theme and :root, unless FIXED)
| Token | Base | :root scaled value | Replaces |
|---|---|---|---|
--spacing-close-btn | 28px | calc(28px * var(--display-scale)) | ~19–35 copy-pasted w-[28px] h-[28px] close-X sites |
--spacing-hint-indent | 28px | calc(28px * var(--display-scale)) | ~25 ml-[28px] settings hint indents |
--spacing-accent-bar | 3px | max(2px, round(3px * var(--display-scale), 1px)) | 3px priority stripe / drag pill / insertion indicator |
--spacing-icon-btn-mini | 22px | calc(22px * var(--display-scale)) | table-helper 22px mini buttons; shortcut remove-button 24→22 |
--spacing-card-gap | 16px | calc(16px * var(--display-scale)) | gap between bordered board cards (timeline/pile) |
--spacing-section-gap | 28px | calc(28px * var(--display-scale)) | between-group boundary — names the settings 10↔28 (2.8×) north-star |
--spacing-tree-indent-base | 12px | calc(12px * var(--display-scale)) | tree depth-0 content indent |
--spacing-tree-indent-step | 16px | calc(16px * var(--display-scale)) | tree per-depth increment |
--spacing-tree-connector | 1px | (FIXED — declared once, no override) | tree guide-line width |
--spacing-touch-min | 44px | max(44px, calc(44px * var(--display-scale))) | raw 44/56 hit areas (iOS HIG floor) |
--overlay-offset-top | (:root only, not a spacing utility) | 10vh (FIXED, vh) | modal overlay vertical anchor |
Notes on the trickier ones:
--spacing-accent-barfloors at 2px so the bar never goes sub-visible at 0.75× (round(2.25) = 2before the floor even engages) and stays a crisp integer width at every other scale. JS-computed bars (drag pill, spreadsheet insertion indicators) mirror the same floor:Math.max(2, Math.round(3 * scale)); centering offsets derive from the token (calc(var(--spacing-accent-bar) / -2)/-), never a hardcodedMath. round(bar / 2) ±1.5.--spacing-touch-minis a physical-ergonomics constant (44px, iOS HIG), somax()makes it an absolute floor that only grows: 0.75×→44, 1.0×→44, 1.5×→66, 2.0×→88.--spacing-icon-btn-mini(22) is distinct from the existing--spacing-frame-ctrl-btn(also 22, frame chrome) — different roles, do not rename or merge.--spacing-hint-indentkeeps the shipped 28px (checkbox 16 + gap-md 8 + 4pxoptical fudge) rather than "correcting" it to 24 — the 4px fudge is intentional and already the established rendered position.--spacing-section-gapnames the existing settings rhythm; adopting it must not shift the rendered value off 28.--overlay-offset-topunifies command-palette/spotlight/item-detail from 5/10/20vh onto the middle value, 10vh. It has no@themeentry — it's not a Tailwind spacing utility, so it's declared:root-only.
Shared class constants (@takazudo/ui-components)
| Constant | Classes | Role |
|---|---|---|
dialogHeaderCls | flex items-center justify-between px-3xl py-xl border-b border-edge | Dialog header chrome |
dialogBodyCls | px-3xl py-3xl | Dialog body chrome |
dialogFooterCls | flex items-center justify-end gap-md px-3xl py-xl border-t border-edge | Dialog footer chrome |
menuItemCls | px-md py-xs max-mobile:min-h-touch-min | Tier A floating-menu row |
pickerRowCls | px-lg py-sm min-h-touch-min | Tier B search-overlay row |
toolbarBarCls | flex items-center gap-sm px-lg py-sm | Secondary board/control bar |
chipPadCls | px-sm py-2xs | Chip/pill/badge padding |
bannerRowCls | px-lg py-sm mb-md bg-bg-alt | Sub-header banner row |
hintIndentCls | ml-hint-indent | Settings hint indentation |
Dialog inset is single, not size-tiered. All three dialog-chrome constants share px-3xl (24) so every dialog's left/right chrome computes equal at every scale. This is the dialog content inset — a different role from the existing --dialog-inset (20, the big-dialog viewport-edge margin via --spacing-2xl), which is unchanged.
Row soup collapses to 2 tiers, not 3. menuItemCls (context-menu, dropdown-listbox, kebab, font-picker, board/spreadsheet menus) and pickerRowCls (spotlight, command-palette) are the only two floating-row densities app-wide. A third "standard listbox" tier was considered and rejected — it would reintroduce the soft menu-vs-listbox ambiguity the consolidation is meant to remove.
Card grouping (--spacing-card-gap) is a figure/ground fix, not a gap-ratio fix. The timeline/pile "cards fusing into one list" bug comes from a transparent background + a single 1px border between cards, not from an insufficient content-line gap. The fix gives each card a filled bg-surface + a full border border-edge rounded-md (all sides) plus --spacing-card-gap (16) between cards — the same recipe the kanban card already uses. Card padding stays density-tiered (kanban p-md=8 vs. timeline/pile p-lg=12) — it is not unified.
Shared primitives
<DialogCloseButton>
The close (X) button for a dialog header. Renders a w-close-btn h-close-btn box (backed by --spacing-close-btn) with the glyph inside, and follows the Close-X top-right rule (root CLAUDE.md § Adding a New Dialog — every dialog must render a close button so users can always dismiss it).
import { DialogCloseButton, dialogHeaderCls } from "@takazudo/ui-components";
<div className={dialogHeaderCls}>
<span>Dialog title</span>
<DialogCloseButton onClick={onClose} />
</div>The glyph's text-[20px] size intentionally stays an unscaled arbitrary value, matching every existing close-X site — font-scale unification for this glyph is out of this epic's scope.
treeIndentStyle(depth, scale)
The one tree-indent/connector formula every tree surface should converge on (file-tree, workspace-dir-tree, settings-sidebar-tree, directory-tree-picker, mindmap outline-view — wave 6, #3969). Backed by --spacing-tree-indent-base (12) and --spacing-tree-indent-step (16):
import { treeIndentStyle } from "@takazudo/ui-components";
const { paddingLeft, connectorLeft } = treeIndentStyle(depth, displayScale);paddingLeft = Math.round((12 + 16 * depth) * scale)connectorLeft = Math.round((12 + 16 * depth − 8) * scale)
Rounding happens here, in JS, per depth — not via CSS calc() — because fractional display scales (0.9, 1.1, 1.25, 1.75) leave the raw product on a sub-pixel value at some depths (e.g. 44 * 0.9 = 39.6), which would drift rows out of pixel alignment across a tall tree. This and the accent-bar JS floor above are the only two places rounding is applied in code; every other token relies on the browser's own calc().
Where this doctrine came from
The full ranked-problem diagnosis (timeline grouping, menu-row soup, command-palette over-roominess, header-pins divider ambiguity, sub-header banner weak bind, overlay vertical anchor drift), the per-wave-5-area migration mapping, and the wave-7 computed-style verification predicates all live in _ (epic #3957). That file is the working spec for the epic's remaining waves; this page is the durable summary for anyone landing here after the epic closes.