02 / Tokens

The contract

Two tiers. Primitives are raw values with no opinion. Semantic tokens are the only thing components are allowed to reference, and they resolve to primitives. Swapping a skin means reassigning the semantic tier.

Colour — grouped by what it paints

Seventeen values, and the grouping is the specification: a token belongs to exactly one job. A surface never doubles as a border, a text tone never fills a shape. Where a softer tone is needed it is derived with color-mix rather than added here.

Surfaces — what sits behind everything

--se-bg

Page canvas

--se-surface

Cards, panels, popovers

--se-surface-sunk

Wells, inputs, disabled fills

--se-surface-raised

Elevated panels

same as surface here

Borders — every edge in the system

--se-border

Hairlines, dividers, cards

--se-border-strong

Control edges — inputs, selects

--se-border-hover

Control edge under the pointer

--se-accent-line

Accent hairline, selection

Text on a light surface — two tiers, and that is the ceiling

Aa

--se-text

Primary copy, headings

13.9:1

Aa

--se-text-muted

Secondary copy, labels, captions

5.3:1

Aa

--se-text-placeholder

Empty field hints

alias of muted

Aa

--se-text-disabled

Inactive controls

2.1:1 · exempt

Text on a dark or coloured surface

Aa

--se-text-inverse

Body on a dark panel

9.8:1

Aa

--se-text-muted-inverse

Secondary on a dark panel

5.0:1

Aa

--se-text-on-intent

Label on a solid fill

a pale skin flips this

Colour — accent and status

Five colours that carry meaning rather than structure. Each is a single value used as text or fill — which is why none of them is named --se-text-* any more. Badges, soft buttons and alert backgrounds derive from these with color-mix; there are no separate surface tokens for status.

Aa

--se-accent

Action, focus, selection

5.6:1

--se-accent-soft

Selected rows, soft buttons

Aa

--se-positive

Published, saved, available

6.4:1

Aa

--se-negative

Errors, failed publish, destructive

5.5:1

Aa

--se-warning

Unsaved changes, expiring domain

5.9:1

In use

Published·maya.studio — live since 12 March

Failed·DNS record could not be verified

Unsaved·3 sections edited since last publish

Add a short line about this project

Connect a domain — available on Pro

Every status tone clears 4.5:1 on the canvas, so status is read as text rather than inferred from a coloured dot. Colour never carries the meaning alone — each pairs with a word, because roughly one man in twelve cannot separate the positive and negative hues.

Derived tones — mixed, never authored

Hover, press and the soft tints are not colours in the contract. A component declares one intent colour and every other state is mixed from it in oklch, so the darkening is perceptually even across a blue, a green and a red — and a skin overriding an intent gets correct states for free. The swatches below are live color-mix, not pasted values.

--se-mix-shade

What hover and press mix toward

#262934 · Sky Captain

--se-mix-hover

Solid control under the pointer

88% intent

--se-mix-down

Solid control pressed

78% intent

--se-mix-line

The outline border of a bordered control

60% over --se-bg

--se-mix-soft

Outline and label hover fill

8% over --se-bg

--se-mix-press

Outline and label pressed fill

18% over --se-bg

[data-se="button"][data-intent="negative"] { --se-intent: var(--se-negative); }

[data-se="button"][data-emphasis="solid"]:hover {
  background: color-mix(in oklch, var(--se-intent) var(--se-mix-hover), var(--se-mix-shade));
}
[data-se="button"][data-emphasis="outline"]:hover {
  background: color-mix(in oklch, var(--se-intent) var(--se-mix-soft), var(--se-bg));
}

Two text tiers, not three

There was briefly a third grey between --se-text-muted and the AA floor, and a fourth for placeholders. Measured, they sat at 5.3:1, 5.0:1 and 4.8:1 — three tokens spanning less than half a step of contrast, which no reader could tell apart. They are now one.

The constraint is arithmetic, not taste. Muted sits at 5.3:1 and the floor is 4.5:1, so there is roughly one usable step beneath the primary tone and no more. On a light canvas, colour carries two levels of de-emphasis; everything below that comes from size, tracking and caps. --se-text-placeholder survives as a name because it says where it belongs, but it resolves to muted rather than to a colour of its own.

Space — a 0.25rem root, non-linear

--se-space-1
0.25rem
--se-space-2
0.5rem
--se-space-3
0.75rem
--se-space-4
1rem
--se-space-5
1.5rem
--se-space-6
2rem
--se-space-7
3rem
--se-space-8
4.5rem

In rem, never px. If type scales with the reader's browser setting and spacing does not, the layout gets tighter the more someone needs it not to be. Steps 1–4 are linear for component internals; 5–8 open up for layout, where 4px is invisible anyway.

/* layout rhythm is the only space that scales */
--se-section-y:   clamp(3.5rem, 8vi, 5.5rem);
--se-page-gutter: clamp(1.25rem, 4vi, 2rem);
--se-stack-gap:   clamp(1rem, 2.5vi, 1.5rem);

Steps 1–4 are never clamped. A 4px gap that becomes 3px saves nothing, breaks alignment with the px borders and radii, and leaves you unable to tell whether two things sit on the same step. It would also fight the density dial for the same number.

Type — fluid, one family

Aa--se-text-4xl
clamp(2.5rem, 0.93rem + 7.14vi, 5rem)
Aa--se-text-3xl
clamp(2.125rem, 1.27rem + 3.93vi, 3rem)
Aa--se-text-2xl
clamp(1.5rem, 1.11rem + 1.79vi, 2rem)
Aa--se-text-xl
clamp(1.25rem, 1.09rem + 0.71vi, 1.5rem)
Aa--se-text-lg
clamp(1.125rem, 1.05rem + 0.36vi, 1.25rem)
Aa--se-text-md · body
clamp(1rem, 0.97rem + 0.13vi, 1.0625rem)
Aa--se-text-sm · meta
0.8125rem — fixed
Aa--se-text-xs · label
0.75rem — fixed

Archivo at 400, 500 and 600, with --se-font-code for anything whose characters must align and --se-font-meta for labels. Small sizes are fixed and large sizes are fluid: body text that reads 14px on a phone and 22px on a desktop is not better typography. Every fluid step carries a rem term alongside the vi term — a preferred value of pure vi ignores browser zoom and fails WCAG 1.4.4.

/* minSize at minWidth  →  maxSize at maxWidth, all rem */
slope     = (maxSize - minSize) / (maxWidth - minWidth)
intercept = minSize - slope * minWidth
preferred = intercept rem + (slope * 100) vi

Swap vi for cqi inside components and a project title sizes itself to the card it sits in rather than to the window. Page chrome uses vi; components use cqi.

Weight — roles, not numbers

Portfolio

--se-weight-regular

400

Body copy and long-form. Anything a reader spends more than a sentence on.

Portfolio

--se-weight-medium

500

UI labels, table keys, buttons, navigation. Short strings that need to hold their own.

Portfolio

--se-weight-semibold

600

Headings at every level. The only weight above medium the system uses.

Three is deliberate. Two forces you to fake emphasis with colour or size; four invites 600 and 700 to sit next to each other, which nobody can distinguish at 14px and which makes a system look undisciplined.

Weights are tokens here for one reason only: the family is skin-replaceable, so anything that depends on the family has to be replaceable too. A skin loading a serif with no medium maps the role to the nearest real weight rather than letting the browser synthesise one.

:root { font-synthesis: none; }   /* fail visibly, never fake a weight */

[data-se-skin="studio-edit"] {
  --se-weight-regular:  400;
  --se-weight-medium:   500;
  --se-weight-semibold: 600;
}

[data-se-skin="editorial"] {      /* family ships no medium */
  --se-weight-regular:  400;
  --se-weight-medium:   400;
  --se-weight-semibold: 700;
}

Tracking — a token for the same reason

--se-tracking-display-0.035emHero and 4xl. Large type needs the air taken out of it
--se-tracking-heading-0.02em3xl down to xl
--se-tracking-body0Leave it alone. Tracking body copy is almost always a mistake
--se-tracking-label0.1emUppercase --se-font-meta labels, where letters need room to be read as letters

Negative tracking that looks correct on a grotesk looks broken on a serif, so a skin that changes --se-font-sans must be able to change these with it. Values stay in em so they scale with whatever size the clamp resolves to.

What is not tokenised: font-style, text-transform, text-decoration. A skin never needs to decide whether a label is uppercase — that belongs to the component.

Line height — unitless, always

The one token where rem is not just unnecessary but wrong. A line height carrying a unit computes to a fixed length and inherits that length — so a container set to 1.5rem passes 24px of leading down to a 32px heading and the lines overlap. Unitless inherits the ratio instead, and every descendant recomputes its own leading from its own size.

--se-leading-display0.98The hero and 4xl. Large type needs the lines almost touching
--se-leading-heading1.123xl down to xl
--se-leading-lead1.45Standfirsts and intro paragraphs at lg
--se-leading-body1.6Body copy. The one that matters most and gets set too tight most often
--se-leading-ui1.2Single-line controls, labels, table cells
--se-leading-code1.8Code blocks, where every line is scanned independently

--se-leading-body · 1.6

A portfolio is read at arm's length on a phone, usually once, usually quickly. Generous leading is what lets someone skim it without losing their place between lines.

1.25 · too tight for body

A portfolio is read at arm's length on a phone, usually once, usually quickly. Generous leading is what lets someone skim it without losing their place between lines.

/* wrong — the child inherits 24px of leading for 32px type */
.card  { line-height: 1.5rem; }
.card h2 { font-size: 2rem; }

/* right — the child inherits the ratio and recomputes */
.card  { line-height: var(--se-leading-body); }
.card h2 { line-height: var(--se-leading-heading); }

Leading tightens as size grows, which is why there are six values rather than one. It also has to move with --se-font-sans: a family with a taller x-height needs more leading at the same ratio, so a skin changing the face should expect to adjust these. There is one legitimate use for a length here — snapping a component to a fixed baseline grid — and it is set per element, never inherited.

Corners — five values, no more

--se-radius-none

0

Tables, full-bleed media, brutalist skins

--se-radius-sm

0.3125rem

Chips, swatches, small cells

--se-radius-md

0.4375rem

Buttons, inputs, list rows

--se-radius-lg

0.75rem

Cards, panels, dialogs

--se-radius-pill

999px

Tags, avatars, toggles

Corners are in rem, not px. Border width has to stay in px because fractional pixels render as a grey smear, but a radius antialiases cleanly — so the argument that pins borders does not transfer to corners. If type and spacing both grow with the reader's root font size and corners do not, a reader at a 24px base gets a UI where everything scaled but the corners, which then read tight and hard.

Controls that ship in several sizes are the exception and use em instead, so a button at 13px and the same button at 20px keep the same shape rather than the same absolute corner. Cards and panels have no size variants, so rem is right for those. pill stays a constant because it is not a measurement — it just has to exceed half the height.

/* nested corners: outer minus the padding between them,
   or the two curves sit at visibly different centres */
--se-radius-nested: calc(var(--se-radius-lg) - var(--se-space-3));

[data-se="button"] { border-radius: 0.45em; }   /* has size variants */
[data-se="card"]   { border-radius: var(--se-radius-lg); }

Five values, and the scale stays short deliberately. Radius is the most brand-expressive token in the contract — a skin setting everything to none reads brutalist, one setting everything to pill reads consumer-friendly. It is the token most likely to be overridden wholesale, so there should be as little of it as possible to override.

Elevation — distance means dismissibility

Four levels, named for what they are rather than how deep they sit. The scale is not decorative: how far something floats above the page tells the reader how to get rid of it, so each level is bound to a dismissal behaviour and the two must never disagree.

--se-shadow-raised

Cards, project tiles, list rows

Not dismissible

--se-shadow-floating

Popovers, menus, dropdowns, tooltips

Click outside

--se-shadow-overlay

Dialogs, sheets, publish confirmation

Esc · traps focus

--se-shadow-dragging

Section being reordered in the builder

Release

--se-shadow-none:     none;
--se-shadow-raised:   0 1px 2px rgba(38, 41, 52, 0.06), 0 1px 1px rgba(38, 41, 52, 0.04);
--se-shadow-floating: 0 4px 12px -2px rgba(38, 41, 52, 0.10), 0 2px 4px -1px rgba(38, 41, 52, 0.06);
--se-shadow-overlay:  0 16px 40px -8px rgba(38, 41, 52, 0.18), 0 4px 12px -4px rgba(38, 41, 52, 0.10);
--se-shadow-dragging: 0 24px 48px -12px rgba(38, 41, 52, 0.24), 0 8px 16px -8px rgba(38, 41, 52, 0.12);

Every level is two shadows: a tight contact shadow for the edge and a wide diffuse one for the ambient cast. A single-layer shadow reads as a sticker rather than as height. The tint is drawn from --se-text rather than pure black, because black over a warm canvas goes flat grey.

Shadow never carries a boundary on its own. A skin is free to set all four to none — a flat or brutalist identity will — so components always draw a border and add elevation on top of it. If removing the shadow makes a dialog ambiguous, the dialog was relying on the wrong thing.

Dark skins get one exception. Shadow barely reads on a dark surface, so elevation there comes from surface lightness instead: --se-surface lifts to --se-surface-raised as things rise, and the shadow tokens drop to a plain ring. The component reads both and does not need to know which strategy the skin chose.

Focus — the one token a skin may not remove

A creative's skin can flatten every shadow, square every corner and replace the whole palette. It cannot delete the focus ring, because for a keyboard or switch user that is the cursor. The ring is therefore owned by the anatomy layer and exposed to skins only as tokens — a skin restyles it, and has no selector with which to remove it.

Tab through these

Back to tokens
--se-focus-width:  2px;
--se-focus-offset: 2px;
--se-focus-color:  var(--se-accent);

/* owned by the anatomy, not the skin */
[data-se] :focus-visible {
  outline: var(--se-focus-width) solid var(--se-focus-color);
  outline-offset: var(--se-focus-offset);
}

:focus-visible, never :focus

A ring on every mouse click is what makes designers ask for it to be removed. :focus-visible shows it to keyboard users and hides it from pointer users, which removes the reason anyone wants it gone.

outline, not box-shadow

A box-shadow ring is clipped by any ancestor with overflow: hidden — which every card and panel in this system has. Outline is never clipped, and modern browsers follow border-radius, so the old reason to reach for box-shadow is gone.

3:1 against the adjacent colour, not the page

The accent clears 4.9:1 on the canvas, but a blue ring drawn on the blue Publish button has nothing to separate it. The 2px offset is what fixes that: the gap exposes the surface behind, giving the ring an edge on both sides regardless of what it sits on.

The offset needs room to exist

A control flush against a panel edge has its ring cut off. Interactive elements carry at least the offset plus the ring width as inner padding from any container edge — 4px in this scale.

Nothing may sit on top of it

A sticky header that covers a focused field fails WCAG 2.2 on its own, even with a perfect ring. Every scroll target carries scroll-margin-block-start equal to the sticky chrome's height.

One case the offset does not solve: a skin whose accent is close in lightness to its own surface. There the ring needs two tones — an inner band in --se-surface and an outer band in the accent — so one of the two always contrasts. Worth building into the anatomy from the start rather than discovering it when a creative picks a pale yellow.

Icons — bring your own set

The system does not own the icon set. If a component imports a specific glyph then that library is part of the contract and a skin can never swap it — the same failure as a hardcoded hex. Components define the box an icon sits in; the app fills it. Studio Edit fills it with Phosphor.

Phosphor earns the pick on its weight axis. Six weights of the same drawing let a skin move the whole icon set from thin to bold and stay coherent with its type — the icon equivalent of --se-weight-*.

thin

100

light

300

regular

400

bold

700

fill

duotone

In context — regular, inheriting currentColor

Portfolio Work Domain Share Preview Publish
--se-icon-sm · 1rem --se-icon-md · 1.25rem --se-icon-lg · 1.5rem
--se-icon-sm:     1rem;      /* beside --se-text-sm */
--se-icon-md:     1.25rem;   /* beside body */
--se-icon-lg:     1.5rem;
--se-icon-weight: regular;   /* thin | light | regular | bold | fill | duotone */

[data-se] [data-part="icon"] {
  inline-size: var(--se-icon-md);
  block-size:  var(--se-icon-md);
  color: currentColor;
  flex: none;
}

Weight is a set, not a CSS property

Phosphor bakes weight into the path geometry and fills with currentColor, so there is no stroke-width to override. --se-icon-weight names which asset set to load and resolves at build time — unlike a stroke-based library such as Lucide, where the same token would be a live CSS value.

currentColor, never a fill token

An icon inherits the colour of the text it accompanies, so it is correct in every context automatically — inside a button, on a dark panel, in the disabled state. An icon that names its own colour token will be wrong in one of those.

Sizes come off the type scale

Three sizes, each pinned to a text step rather than an independent ladder, so an icon beside a label is always right for it. Use em inside controls that have size variants, matching the corners rule.

Decorative icons are hidden from assistive tech

An icon beside a visible label carries aria-hidden="true" — the label already names the action and announcing both is noise. An icon-only control needs a real accessible name instead, and that is the anatomy's job, not the skin's.

Phosphor is a dependency of Studio Edit, not of the design system. Install it in the app and let tree-shaking take only what is used — do not vendor the SVGs into the system, or you inherit the maintenance and lose upstream fixes. The one exception is a build-time sprite, generated from the package rather than copied by hand. The specimens above are illustration for this document; the contract only specifies the box.

Motion — duration follows distance

Four durations, bound to the same ladder as elevation. How far something travels and how long it takes are one decision: a hover state that takes 240ms feels broken, and a dialog that arrives in 80ms was never anywhere. Hover any tile below to see the step.

--se-duration-instant

80ms

Hover, press, checkbox, focus ring

No travel

--se-duration-fast

160ms

Popover, tooltip, menu, tab switch

Short travel

--se-duration-medium

240ms

Dialog, sheet, panel, publish confirm

Enters the page

--se-duration-slow

400ms

Route change, builder entering preview

Whole view

--se-ease-standard

cubic-bezier(0.2, 0, 0, 1)

The default. Anything moving within the page.

--se-ease-decelerate

cubic-bezier(0, 0, 0, 1)

Entering. Arrives fast, settles gently.

--se-ease-accelerate

cubic-bezier(0.3, 0, 1, 1)

Leaving. Starts slow, exits off-screen.

--se-duration-instant: 80ms;
--se-duration-fast:    160ms;
--se-duration-medium:  240ms;
--se-duration-slow:    400ms;

--se-ease-standard:   cubic-bezier(0.2, 0, 0, 1);
--se-ease-decelerate: cubic-bezier(0, 0, 0, 1);
--se-ease-accelerate: cubic-bezier(0.3, 0, 1, 1);

/* exit is quicker than entry — nobody waits to dismiss something */
[data-se="dialog"][data-state="open"] [data-part="content"] {
  transition: opacity var(--se-duration-medium) var(--se-ease-decelerate),
              transform var(--se-duration-medium) var(--se-ease-decelerate);
}
[data-se="dialog"][data-state="closed"] [data-part="content"] {
  transition-duration: var(--se-duration-fast);
  transition-timing-function: var(--se-ease-accelerate);
}

Curves are tokens. Springs are not.

A spring cannot be expressed as a custom property, so a skin can never override one and nothing using it degrades to a plain CSS transition. Motion’s springs are worth having in exactly one place — drag and reorder in the builder, where physics reads better than a curve because the user’s own hand set the velocity. Everywhere else, duration and easing.

Only transform, opacity and filter

These are the properties a compositor can animate without recalculating layout. Animating height, width, top or margin drops frames on the cheapest device your users own, which for a portfolio platform is most of them. To animate a panel open, transform its scale or translate it — do not animate its height.

Enter decelerates, exit accelerates, exit is faster

Something arriving should decelerate into place, and something leaving should accelerate away. Exit runs at roughly 70% of entry, because a user who has decided to dismiss something is no longer interested in watching it.

Reduced motion is a floor, not a ceiling

The reset in this document collapses every duration to near-zero when a reader asks for reduced motion, which satisfies the requirement. Components should do better: keep the opacity fade and drop only the movement, so a dialog still reads as arriving rather than teleporting. Vestibular disorders are triggered by large movement, not by a cross-fade.

Motion explains, it does not decorate

Every transition in the system answers where something came from or where it went. A popover that scales up from its trigger tells you what it belongs to. Motion that answers no question is weight for the browser and noise for the reader.

Motion (motion.dev) is the implementation, not the contract. Its core is framework-agnostic, so the same call works in React, Vue and plain JavaScript, and it runs on the Web Animations API rather than a render loop. It reads these tokens; it does not replace them. A component that configures its own timing is the motion equivalent of a hardcoded hex.

Breakpoints — there are two

This is the complete list, and it exists for one reason: column count is a discrete decision. There is no 7.4 columns, so nothing can interpolate between four and eight. Every other responsive decision in the system is continuous and needs no breakpoint at all.

--se-bp-md40rem · 640pxFour columns become eight
--se-bp-lg64rem · 1024pxEight columns become twelve
/* a media query cannot read a custom property, so the source
   of truth is a build-time definition and the CSS variable
   exists only for JavaScript to read */
@custom-media --se-bp-md (min-width: 40rem);
@custom-media --se-bp-lg (min-width: 64rem);

:root { --se-bp-md: 40rem; --se-bp-lg: 64rem; }  /* for JS only */

@media (--se-bp-md) { :root { --se-grid-columns: 8;  } }
@media (--se-bp-lg) { :root { --se-grid-columns: 12; } }

A media query cannot use a token

@media (min-width: var(--se-bp-md)) does not work and never has — custom properties are not available at the point the browser evaluates a media condition. So breakpoints are the one part of the contract that needs a build step: PostCSS custom-media, or Sass variables. Publishing them as CSS variables as well is still worth doing, because JavaScript can read those and stay in sync with the stylesheet.

rem, not px — and rem means something different here

Inside a media query, rem is always relative to the browser’s default font size, never to whatever your root rule sets. That is precisely why it is the right unit: a reader who raises their default gets the layout switching earlier, in proportion to the type they actually see. A px breakpoint ignores them.

Named for size, never for a device

md and lg, not tablet and desktop. Device names are a promise the web cannot keep — a landscape phone is wider than a portrait tablet, a desktop window is any width its owner drags it to, and the names stop being true within a year.

A third breakpoint needs an argument

Adding one means claiming a layout change is discrete rather than continuous. Most are not: if the answer is that things should be a bit bigger or a bit further apart, that is clamp, and it wants no breakpoint. Component-level changes belong in a container query, which is scoped to the component and never enters this list.

One case sits outside the two: the builder collapsing its side panels into tabs. That is a genuine change of shape rather than a reflow, so it earns a media query — but it belongs to that component, not to the system, and it is declared where the builder is declared.

What changes at each threshold — and nothing else

basemd · 40remlg · 64rem
columns4812
navigationDrawerInlineInline
builderPanels as tabsCanvas + inspectorCanvas + sections + inspector
portfolio grid1 up2 up3 up
résuméSingle columnSingle columnTwo column

This table is the contract; the rem values are only how it is implemented. A breakpoint with no stated effect becomes an open invitation — one team reaches for it to move the nav, another to change a card, and the layout stops being predictable. If a change is not in this table it does not belong at a breakpoint, which in practice means it belongs in a container query or a clamp.

Not width at all

(pointer: coarse) — the one that matters most

For a builder with drag and reorder, input precision affects more real behaviour than either width breakpoint. A coarse pointer raises every target to the 44px floor, widens drag handles, and replaces hover-revealed controls with permanently visible ones. A narrow window on a desktop is not a touch device, and width cannot tell the difference.

(hover: hover) — hover states only where hover exists

A touch device fires hover on tap and never releases it, so a hover style becomes a stuck style. Wrap every hover rule in this query — the token index above is wrapped, which is why its cells do not hold their tint after a tap. It is also why the motion demos above use explicit enter and leave handlers rather than relying on hover alone.

(prefers-contrast: more) — the skin already exists

The high-contrast skin in section 05 is not only a manual choice. This query applies it automatically for readers who have asked their OS for more contrast, which is a larger group than most teams assume.

(prefers-color-scheme: dark) — a default, not a lock

It selects the dark skin as the initial state. A creative who has chosen a light identity for their published portfolio overrides it, because their brand decision outranks the reader’s system default on their own domain — while the Studio Edit editor chrome follows the reader.

--se-container-max: 72.5rem;   /* 1160px — a cap, not a breakpoint */

@media (pointer: coarse) { :root { --se-density: 1; } }
@media (hover: hover)    { /* every hover rule in the system lives here */ }
@media (prefers-contrast: more)      { /* high-contrast skin */ }
@media (prefers-color-scheme: dark)  { /* dark skin as initial state */ }

The content cap is worth naming even though it is not a breakpoint. Layout stops growing at --se-container-max and the gutters absorb the rest, which is a real decision about measure — text past roughly 75 characters a line gets harder to track back from. Left unnamed it gets re-invented as a literal in every new view.

Layout floors — what the primitives read

The layout primitives each read one floor. Naming them is what stops the same 14rem appearing as a literal in four different grids and then drifting apart.

--se-grid-gutter--se-space-4Column gap in the page grid
--se-grid-margin--se-page-gutterOuter margin of the page grid
--se-col-min14remauto-fit floor for tile grids
--se-sidebar-min14remPanel floor before main gives way
--se-switch-at38remWidth below which the Switcher stacks

Density — three steps

Compact

--se-density: 0.75

Northlight
Résumé 2024
Motion reel

Dense lists, asset tables, the inspector.

Default

--se-density: 1

Northlight
Résumé 2024
Motion reel

Everything else. The value a skin inherits.

Spacious

--se-density: 1.25

Northlight
Résumé 2024
Motion reel

Published portfolios, touch, marketing pages.

The label is 14px in all three. Density moves space, never type — someone switching a project list to compact wants more rows at the same readable size, and a compact mode that shrinks the text is a compact mode people turn off. That is why there are two dials rather than one.

--se-density:    1;   /* space only */
--se-font-scale: 1;   /* text only, rarely touched */

[data-se="button"] {
  font-size: calc(var(--se-text-sm) * var(--se-font-scale));
  padding: calc(0.7em * var(--se-density)) calc(1.25em * var(--se-density));
  gap: calc(0.5em * var(--se-density));
  border: var(--se-border-width) solid var(--se-border);
  min-block-size: max(var(--se-target-min), calc(2.5em * var(--se-density)));
}

Control height — one source, three steps

Set as block-size rather than a minimum, so no state can change it: a focus border is absorbed inward by border-box and an outline never enters layout. Buttons and fields read the same token, which is what lets them sit level in a row.

--se-control-h-sm2rem · 32pxCompact toolbars and inline actions
--se-control-h2.5rem · 40pxThe default for every single-line control
--se-control-h-lg3rem · 48pxPrimary actions and touch-first views

That last max() is the floor. Padding may shrink freely; a touch target may not go below 44px, so compact tightens the visual density without producing an interface nobody can tap.

Which unit, where

remSpacing scale, the one font-size anchor per componentRespects the reader's browser font setting, which px does not
emPadding, gap, icon size inside a componentTracks the component's own font-size, so the shape holds at every size
chLine lengthCaps measure at 66ch whichever family a skin loads
cqiFluid type and padding inside componentsResponds to where the component sits, not how wide the window is
cqbVertical fluid space inside a componentBlock axis of the container. Needs container-type: size, not just inline-size
%Widths, and nothing elsePercentage padding resolves against inline size even vertically — a common surprise
viPage-level display type and section paddingInline axis, so it survives RTL and excludes the scrollbar — unlike vw
dvhFull-height shellsAccounts for the collapsing mobile toolbar; plain vh is the cut-off-footer bug
pxBorder widths, small radii, shadow offsetsA hairline in em resolves to fractional pixels and renders as a grey smear
frGrid tracks — nothing elseA share of leftover space. Invalid on font-size, gap, padding and width
unitlessLine heightInherits as a ratio; a value with units passes down the computed length instead

And the functions that make them safe

clamp()Bounding any fluid valueA fluid unit without a floor and ceiling is a bug waiting for a 320px phone or a 2560px monitor
min()The overflow guard in grid tracksmin(100%, 14rem) is what stops an auto-fit column from being wider than its container
max()Floors that must not be crossedmax(2.75rem, …) is how density tightens spacing without dropping a touch target below 44px
calc()Deriving one value from anotherWhat lets a single density variable drive every internal step in the system

The functions matter as much as the units. Almost every rule in section 06 is really a unit paired with the function that bounds it.

One rule keeps em safe: it compounds, so a button inside a card inside a compact panel can end up three multiplications deep. Every component resets its own font-size from a rem token at its root. em is then free to work inside a component and never crosses a component boundary.