02 / Tokens

The contract

Every name is a Scale, a Role or a Mix, and a component may reference any of them. There is no raw layer underneath: a second name for the same value buys nothing. A skin reassigns the values. The names never move.

Every token, by category

This is every token in the contract, ordered by what a name is, then what it paints, then the set it belongs to. Every example is drawn with the tokens themselves, so the page moves when the contract does. The sections after it do not repeat any of this — each one argues why its part of the contract is the way it is, and what to check before adding to it.

Scale

65 tokens

A series of values for one property, with the step in the name: --se-space-1 through --se-space-8, --se-text-xs through --se-text-4xl. Nothing lands between two steps. To add one, say why the two either side do not cover the case.

Type · 25

--se-text-*
xsAa
--se-text-xs
calc(0.75rem * var(--se-font-scale))
smAa
--se-text-sm
calc(0.8125rem * var(--se-font-scale))
mdAa
--se-text-md
calc(clamp(1rem, 0.97rem + 0.13cqi, 1.0625rem) * var(--se-font-scale))
the type root — control and choice heights are multiples of this step
lgAa
--se-text-lg
calc(clamp(1.125rem, 1.05rem + 0.36cqi, 1.25rem) * var(--se-font-scale))
xlAa
--se-text-xl
calc(clamp(1.25rem, 1.09rem + 0.71cqi, 1.5rem) * var(--se-font-scale))
2xlAa
--se-text-2xl
calc(clamp(1.5rem, 1.11rem + 1.79cqi, 2rem) * var(--se-font-scale))
3xlAa
--se-text-3xl
calc(clamp(2.125rem, 1.27rem + 3.93cqi, 3rem) * var(--se-font-scale))
4xlAa
--se-text-4xl
calc(clamp(2.5rem, 0.93rem + 7.14cqi, 5rem) * var(--se-font-scale))
--se-weight-*
regularPortfolio
--se-weight-regular
400
body copy and long-form — anything a reader spends more than a sentence on
mediumPortfolio
--se-weight-medium
500
UI labels, table keys, buttons, navigation — short strings that need to hold their own
semiboldPortfolio
--se-weight-semibold
600
headings at every level
boldPortfolio
--se-weight-bold
700
reserved — the system does not reach above semibold
--se-leading-*
displayOne line, a second line, a third line.
--se-leading-display
0.98
headingOne line, a second line, a third line.
--se-leading-heading
1.12
leadOne line, a second line, a third line.
--se-leading-lead
1.45
bodyOne line, a second line, a third line.
--se-leading-body
1.6
uiOne line, a second line, a third line.
--se-leading-ui
1.2
codeOne line, a second line, a third line.
--se-leading-code
1.8
--se-tracking-*
displayPortfolio
--se-tracking-display
-0.035em
headingPortfolio
--se-tracking-heading
-0.02em
bodyPortfolio
--se-tracking-body
0
labelPortfolio
--se-tracking-label
0.1em
--se-measure-*
tight
--se-measure-tight
46ch
captions, asides, helper text — a column a glance can take in
base
--se-measure-base
68ch
body prose — the default reading column
wide
--se-measure-wide
84ch
the ceiling — tables of text, editorial spreads; nothing reads wider

Space · 8

--se-space-*
1
--se-space-1
calc(0.25rem * var(--se-density))
2
--se-space-2
calc(0.5rem * var(--se-density))
3
--se-space-3
calc(0.75rem * var(--se-density))
4
--se-space-4
calc(1rem * var(--se-density))
5
--se-space-5
calc(1.5rem * var(--se-density))
6
--se-space-6
calc(2rem * var(--se-density))
7
--se-space-7
calc(3rem * var(--se-density))
8
--se-space-8
calc(4.5rem * var(--se-density))

Form and focus · 15

--se-radius-*
none
--se-radius-none
0
tables, full-bleed media, brutalist skins
sm
--se-radius-sm
0.3125rem
chips, swatches, small cells
md
--se-radius-md
0.4375rem
buttons, inputs, list rows
lg
--se-radius-lg
0.75rem
cards, panels, dialogs
pill
--se-radius-pill
999px
tags, avatars, toggles
--se-shadow-*
none
--se-shadow-none
none
raised
--se-shadow-raised
0 1px 2px rgba(38, 41, 52, 0.06), 0 1px 1px rgba(38, 41, 52, 0.04)
cards, project tiles, list rows — not dismissible
floating
--se-shadow-floating
0 4px 12px -2px rgba(38, 41, 52, 0.10), 0 2px 4px -1px rgba(38, 41, 52, 0.06)
popovers, menus, dropdowns, tooltips — dismissed by clicking outside
overlay
--se-shadow-overlay
0 16px 40px -8px rgba(38, 41, 52, 0.18), 0 4px 12px -4px rgba(38, 41, 52, 0.10)
dialogs, sheets, publish confirmation — Esc, and traps focus
dragging
--se-shadow-dragging
0 24px 48px -12px rgba(38, 41, 52, 0.24), 0 8px 16px -8px rgba(38, 41, 52, 0.12)
a section being reordered in the builder — dismissed by releasing it
--se-layer-*
raised10
--se-layer-raised
10
sticky header, in-control adornment
floating20
--se-layer-floating
20
menu, listbox popup, tooltip — anchored to a trigger
backdrop30
--se-layer-backdrop
30
the scrim behind a modal
overlay40
--se-layer-overlay
40
dialog, sheet — above its own backdrop
dragging50
--se-layer-dragging
50
a thing under the pointer, above everything

Icons · 3

--se-icon-*
sm
--se-icon-sm
1rem
md
--se-icon-md
1.25rem
lg
--se-icon-lg
1.5rem

Motion · 7

--se-duration-*
instant80ms
--se-duration-instant
80ms
hover, press, checkbox, focus ring
fast160ms
--se-duration-fast
160ms
popover, tooltip, menu, tab switch
medium240ms
--se-duration-medium
240ms
dialog, sheet, panel, publish confirm
slow400ms
--se-duration-slow
400ms
route change, builder entering preview
--se-ease-*
standardcubic-bezier(0.2, 0, 0, 1)
--se-ease-standard
cubic-bezier(0.2, 0, 0, 1)
the default — anything moving within the page
deceleratecubic-bezier(0, 0, 0, 1)
--se-ease-decelerate
cubic-bezier(0, 0, 0, 1)
entering: arrives fast, settles gently
acceleratecubic-bezier(0.3, 0, 1, 1)
--se-ease-accelerate
cubic-bezier(0.3, 0, 1, 1)
leaving: starts slow, exits off-screen

Layout · 2

--se-bp-*
md
--se-bp-md
40rem
lg
--se-bp-lg
64rem

Dials · 5

--se-density-*
compact0.75
--se-density-compact
0.75
dense lists, asset tables, the inspector
spacious1.25
--se-density-spacious
1.25
published portfolios, touch, marketing pages
--se-control-*
h-sm
--se-control-h-sm
calc(var(--se-text-md) * 2)
h
--se-control-h
calc(var(--se-text-md) * 2.5)
h-lg
--se-control-h-lg
calc(var(--se-text-md) * 3)

Role

54 tokens

One name for one job. --se-text-muted says what the color is for, not how much of it there is. To add one, name the job and check that nothing already names it.

Color · 23

Surfaces — what sits behind everything

--se-bg--se-surface-*
--se-bg
#FBFAF8
page canvas
--se-surface
#FFFFFF
cards, panels, popovers
--se-surface-sunk
#F2EFE9
wells, inputs, disabled fills
--se-surface-raised
#FFFFFF
same as surface here; a dark skin lifts it
--se-surface-highlight
#EAEFFD
where the keyboard is — a composite's focus indicator
--se-surface-selected
#D5DEFA
what the reader chose — persists when the cursor moves on
--se-surface-readonly
var(--se-surface)
not editable, still readable — the edge carries the signal, not the fill

Borders — every edge in the system

--se-border-*--se-accent-line
--se-border
#E8E4DE
hairlines, dividers, cards
--se-border-strong
#D6D1C9
control edges
--se-border-hover
#A9A29A
control edge under the pointer
--se-accent-line
#DBE3FC
accent hairline, selection

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

--se-text-*
--se-text
#262934
Sky Captain — 13.9:1
--se-text-muted
#6E6862
5.3:1
var(--se-text-muted)
--se-text-placeholder
var(--se-text-muted)
empty field hints — an alias of text-muted
--se-text-disabled
#B5AFA6
2.1:1 — the WCAG exemption for inactive controls

Text on a dark or colored surface

--se-text-*
--se-text-on-intent
#FFFFFF
label on a solid fill; a pale-fill skin flips this
--se-text-inverse
#D9D4CC
body on a dark surface
--se-text-muted-inverse
#9C978E
secondary on a dark panel — 5.0:1

Accent and status

--se-accent-*--se-positive--se-negative--se-warning
--se-accent
#2F5BE8
action, focus, selection — 5.6:1
--se-accent-soft
#EEF2FE
selected rows, soft buttons
--se-positive
#1F6B4A
published, saved, available — 6.4:1
--se-negative
#B8402A
errors, failed publish, destructive — 5.5:1
--se-warning
#8A5A0F
unsaved changes, expiring domain — 5.9:1

Type · 5

--se-font-*
sansPortfolio
--se-font-sans
Archivo, system-ui, sans-serif
codePortfolio
--se-font-code
"Fragment Mono", ui-monospace, monospace
metaPortfolio
--se-font-meta
"Archivo Narrow", system-ui, sans-serif
iconPortfolio
--se-font-icon
"Phosphor", sans-serif
the face that renders an icon sitting in the type stream
icon-strongPortfolio
--se-font-icon-strong
"Phosphor-Bold", sans-serif
the strong face, for a glyph beside a medium-weight label

Space · 3

--se-section-y
--se-section-y
clamp(3.5rem, 8cqi, 5.5rem)
--se-page-gutter
--se-page-gutter
clamp(1.25rem, 4cqi, 2rem)
--se-stack-gap
--se-stack-gap
clamp(1rem, 2.5cqi, 1.5rem)

Form and focus · 9

--se-radius-nested
--se-radius-nested
calc(var(--se-radius-lg) - var(--se-space-3))
--se-border-width
--se-border-width
1px
--se-choice-*
box
--se-choice-box
calc(var(--se-text-md) * 1.25)
the box of a checkbox, radio or switch — and the switch track height
row
--se-choice-row
calc(var(--se-text-md) * 1.75)
minimum height of a choice row — the box plus room for its label
--se-switch-track
--se-switch-track
calc(var(--se-text-md) * 2.25)
width of a switch track; its height is --se-choice-box
--se-thumb-size
Aa
--se-thumb-size
var(--se-choice-box)
slider and switch thumb — matches the checkbox box so controls in a row agree
--se-focus-*
width
--se-focus-width
2px
offset
--se-focus-offset
2px
colorvar(--se-accent)
--se-focus-color
var(--se-accent)

Icons · 2

--se-icon-*
weightregular
--se-icon-weight
regular
which SVG weight an illustration is placed at — see icon-weight-strong
weight-strongbold
--se-icon-weight-strong
bold

Layout · 8

--se-container-max
--se-container-max
72.5rem
--se-grid-*
columns4
--se-grid-columns
4
guttervar(--se-space-4)
--se-grid-gutter
var(--se-space-4)
marginvar(--se-page-gutter)
--se-grid-margin
var(--se-page-gutter)
--se-col-min
--se-col-min
14rem
auto-fit floor for tile grids
--se-sidebar-min
--se-sidebar-min
14rem
panel floor before main gives way
--se-popup-max-h
--se-popup-max-h
16rem
scroll ceiling for a floating list — roughly eight rows
--se-switch-at
--se-switch-at
38rem
Switcher stacks below this

Dials · 4

--se-density
1
--se-density
1
the space dial — every --se-space-* step is multiplied by it
--se-font-scale
1
--se-font-scale
1
the type dial — every --se-text-* step is multiplied by it
--se-target-min
--se-target-min
2.75rem
44px touch floor — fixed on purpose: an accessibility floor, not a proportion
--se-banner-h
--se-banner-h
0px
declared height of a sticky banner — 0px means nothing sticks

Mix

6 tokens

A percentage or a color that exists only to derive another value. --se-mix-hover is 88%, and a component passes it to color-mix(). None of these is used on its own.

Dials · 6

--se-mix-*
soft8%
--se-mix-soft
8%
intent over surface: hover on outline and label
press18%
--se-mix-press
18%
intent over surface: pressed outline and label
hover88%
--se-mix-hover
88%
intent over black: hover on solid
down78%
--se-mix-down
78%
intent over black: pressed solid
line60%
--se-mix-line
60%
intent over surface: the outline border
shade
--se-mix-shade
#262934
Sky Captain — what hover and press mix toward

Color — grouped by what it paints

The grouping above 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.

Color — accent and status

Five colors 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.

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 colored dot. Color 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 colors in the contract. A component declares one intent color 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.

[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, color 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 color of its own.

Space — a 0.25rem root, non-linear

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.

/* every step carries the space dial */
--se-space-4:     calc(1rem * var(--se-density));

/* layout rhythm is the only space that is fluid */
--se-section-y:   clamp(3.5rem, 8cqi, 5.5rem);
--se-page-gutter: clamp(1.25rem, 4cqi, 2rem);
--se-stack-gap:   clamp(1rem, 2.5cqi, 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

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 cqi term — a preferred value of pure cqi 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) cqi

The ramp measures cqi, so a project title sizes itself to the card it sits in rather than to the window. A region opts in with data-se-container; outside any container cqi falls back to the viewport, so a page that never opts in reads as it always did.

Weight — roles, not numbers

Three is deliberate. Two forces you to fake emphasis with color 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="portfolio"] {
  --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

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-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

/* 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

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 behavior and the two must never disagree.

The binding holds for surfaces, not for controls. A slider thumb takes --se-shadow-raised at rest and --se-shadow-floating on hover — literal lift under a pointer, saying nothing about dismissal. Read a level as an obligation only where something opens over the page.

--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 color, 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.

Two deliveries, and one test tells you which. Does the mark sit in the type stream? A glyph beside a label takes its size and color from the text next to it, so a font renders it and --se-icon-* becomes its font-size. An illustration has its own dimensions and its own colors, so it stays an SVG. The test is not decorative — every icon here is aria-hidden, so that word is spent. It is whether the mark takes anything from the text beside it. A two-color status mark at 16px is an SVG: a font cannot hold two colors.

The box is ours; the glyph is never ours. The anatomy owns the box, the alignment and the color: --se-icon-* as font-size for a glyph, as drawn size for an SVG, and currentColor for both. What fills it is yours — an inline <svg>, or an element carrying whatever classes your icon font wants. A vendor class in your markup is your business. One in ours would be the contract taking a dependency.

The box is one step above the text it sits beside. A glyph’s drawn mass sits inside its box with air around it. An icon matching its text size exactly reads light next to the letters; one step up makes them look the same size. --se-icon-sm sits beside --se-text-sm and below, --se-icon-md beside --se-text-md, --se-icon-lg beside --se-text-lg.

A face token is a slot, not a switch. --se-font-icon and --se-font-icon-strong name the two faces for a font whose glyphs are addressable as text. They cannot override a font that ships its own stylesheet — checked against Phosphor 2.1.2, whose ligatures are disabled, whose glyph rules are scoped per weight class, and whose base class sets font-family with !important. With that font the app selects the weight in markup and the contract sizes the result.

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 */

--se-icon-weight-strong: bold;      /* beside a medium-weight label */

--se-font-icon:        "Phosphor", sans-serif;
--se-font-icon-strong: "Phosphor-Bold", sans-serif;

/* One box, whatever fills it. A glyph reads font-size; a drawing reads the box.
   --se-icon-sm is the default because it pairs with --se-text-sm, which is every
   text role but one. `color` needs no declaration: it inherits, which is how an
   icon follows the Button's text color or the Alert's intent. `fill` does need
   one, and it belongs on the part — fill inherits too, so a bare  carrying
   the part and a wrapper's descendants are painted by the same declaration. */
[data-se] [data-part="icon"] {
  display: inline-flex; align-items: center; justify-content: center;
  inline-size: var(--se-icon-sm);
  block-size:  var(--se-icon-sm);
  font-size:   var(--se-icon-sm);
  line-height: 1;
  flex: none;
  fill: currentColor;
}
[data-se] [data-part="icon"] > svg { inline-size: 100%; block-size: 100%; }

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 color 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 color 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;
--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 creative’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.

/* 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 behavior 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 layout roles read

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

Density — three steps

Compact

data-se-density="compact"

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

data-se-density="spacious"

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 */

/* consumed at the token layer — every reader of a token inherits the dial */
--se-space-4:   calc(1rem * var(--se-density));
--se-text-md:   calc(clamp(1rem, 0.97rem + 0.13cqi, 1.0625rem) * var(--se-font-scale));
--se-control-h: calc(var(--se-text-md) * 2.5);   /* the box tracks its text */

/* a consumer turns the dial at the root, or in scope with the attribute */
:root { --se-density: 0.9; }
<aside data-se-density="compact">…</aside>

Wired at the token layer. The dials were declared before anything read them, and a token that looks live but is inert gets set and trusted. Now the space steps carry --se-density and the type steps carry --se-font-scale inside the generated CSS, so a component consumes both by reading the tokens it already reads. At the root the dial is set as a property; in scope it is the data-se-density or data-se-font-scale attribute, because a custom property resolves where it is declared and the attribute is what re-declares the riding tokens at the boundary. DataTable’s compact mode is still its own data-density attribute — a per-table shape, not the app dial.

Choice controls — a box, not a control height

A checkbox is a small box inside a taller row, so it does not take --se-control-h. Applying that token here would make every checkbox 40px. The row meets the touch floor under (pointer: coarse); the box inside it is a different measurement.

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. Each step is a multiple of --se-text-md, the type root, so the box tracks the text it wraps — through --se-font-scale too, which is what stops a smaller type scale leaving a full-height control around tiny text.

The floor holds whatever the dials say: --se-target-min is a fixed 44px — an accessibility floor, not a proportion — and the (pointer: coarse) rules read it directly, 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
cqiThe fluid tokens — type steps and layout rhythm — and fluid paddingMeasures the nearest container, falling back to the viewport outside one — data-se-container is the opt-in
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 chrome that must track the window even inside a containerInline axis, so it survives RTL and excludes the scrollbar — unlike vw. The token ramp itself now measures cqi
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.