Status: superseded by
Replacing Lattice's visual identity with a Figma-generated one
(2026-08-03), never implemented. This document argued for a second, standalone
--ts-* system with a blue-violet primary. The Figma-generated identity now occupies the same slot
inside Lattice itself, so the reason to maintain two systems disappears with it.
Kept for its palette reasoning — particularly why severity must separate by
lightness as well as hue, which the new system carries forward.
Audience: engineers building tabstop, and PMs specifying it.
Tabstop is zero-setup accessibility monitoring: paste a URL, get an axe-core audit, track the score over time, get emailed on regressions. This document specifies the design system it is built from.
This is a standalone system, independent of @chameleon-labs/lattice, which
tabstop consumes today. That was a deliberate call, made with the cost
understood: two systems now exist, they will drift, and a fix applied to one
does not reach the other. The alternative — documenting and extending Lattice —
was considered and rejected.
What this system deliberately keeps from Lattice's approach, because the approach is sound regardless of which system it serves:
- Colours are generated from a curve, not picked. A lightness curve, a chroma envelope and a hue per scale produce every step.
- Contrast is a contract checked against the generated values, not a review step. Ninety pairs are verified; every one passes before a value ships.
- Three tiers — primitive, semantic, component — because a system without the middle tier cannot gain a second theme without rewriting every component.
The values in this document are the output of that generation, not hand-chosen approximations. They were produced by solving each contracted step against its contract and gamut-mapping the result into sRGB.
Tabstop's subject is contrast and severity. That constrains the palette more than a typical product would be constrained:
- The brand colour cannot live near a severity hue. A primary button that reads as a finding is a defect in a product that reports findings. Primary is blue-violet at 265°, far from red 27°, orange 40°, amber 75° and green 145°.
- There is no
secondarycolour scale. "Secondary" is a button variant drawing fromneutral. A second brand hue would compete with severity for meaning, and severity has the stronger claim. - Severity separates by lightness as well as hue.
serious(orange 40°) andmoderate(amber 75°) are 35° apart, which is not enough under protanopia or deuteranopia. Their solid fills are therefore pinned to distinct lightnesses — critical 0.550, serious 0.620, minor 0.650, moderate 0.720 — so the ramp survives as a lightness ordering when hue fails. - Severity is never conveyed by colour alone. Every severity indicator carries an icon and a text label. This is a hard rule, not a recommendation, and it is what makes point 3 a safety net rather than the only defence.
primitive --ts-primary-9 generated; never referenced by a component
semantic --ts-solid what components consume
component --ts-button-bg-rest permitted, but each needs written justification
A component reads the semantic tier. Reaching past it into a primitive is the error the tier structure exists to make visible.
Lightness. Steps 1–7 come from a shared curve. Steps 8, 11 and 12 are solved per scale against their contrast contracts rather than read from the curve, because a fixed curve cannot know how much luminance a given hue and chroma contribute. Step 9 is pinned per scale. Step 10 is derived from step 9.
| Step | Light L | Dark L | Job |
|---|---|---|---|
| 1 | 0.994 | 0.175 | page background |
| 2 | 0.983 | 0.213 | subtle background |
| 3 | 0.960 | 0.255 | component fill / soft fill |
| 4 | 0.940 | 0.285 | component fill, hover |
| 5 | 0.920 | 0.315 | component fill, active |
| 6 | 0.895 | 0.350 | subtle border, separators |
| 7 | 0.860 | 0.405 | border |
| 8 | solved → 3:1 vs step 1 | solved | interactive border |
| 9 | pinned per scale | same | solid fill |
| 10 | step 9 ± 0.045 | same | solid fill, hover |
| 11 | solved → 4.5:1 vs step 3 | solved | low-contrast text |
| 12 | solved → 7:1 vs step 1 | solved | body text |
Chroma is a fraction of each scale's peak, by step:
0.06, 0.12, 0.24, 0.36, 0.44, 0.52, 0.62, 0.78, 1.00, 0.95, 0.85, 0.35.
Values are gamut-mapped into sRGB by reducing chroma while holding lightness
and hue, so no step silently shifts hue at the gamut boundary.
Hue and peak chroma per scale:
| Scale | Hue | Peak C | Step 9 L | Purpose |
|---|---|---|---|---|
primary |
265° | 0.190 | 0.575 | brand, interactive |
neutral |
265° | 0.012 | 0.620 | surfaces, borders, text |
critical |
27° | 0.190 | 0.550 | axe critical |
serious |
40° | 0.170 | 0.620 | axe serious |
moderate |
75° | 0.160 | 0.720 | axe moderate |
minor |
230° | 0.140 | 0.650 | axe minor |
success |
145° | 0.160 | 0.600 | passing checks |
neutral shares the primary hue at a chroma low enough to read as grey. A
faintly brand-tinted grey is what makes a page feel like one system rather than
a brand colour dropped onto stock grey.
Step 9 is mode-invariant. A solid fill is the same colour in both themes.
Only the surfaces around it move. This is why a critical badge is recognisably
the same badge in either theme.
Hover moves away from the label colour, not simply darker. A white-text button deepens on hover; a black-text button brightens. Deepening a black-text amber fill is exactly what breaks its 4.5:1. Encoding hover as "darker" would have shipped a hover state that fails contrast on four of seven scales.
Primary is pinned to its contrast boundary. oklch(0.575 0.19 265) is the
exact lightness at which white label text reaches 4.5:1 against the fill. The
value is not aesthetic; a lighter primary would force black button labels, which
for a brand action is wrong however correct the arithmetic.
| Scale | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
primary |
#fcfdff |
#f7f9ff |
#ecf2ff |
#e2ebff |
#d8e5ff |
#ccddff |
#bbd1ff |
#6790ef |
#406fe7 |
#3762d2 |
#4169cd |
#46577e |
neutral |
#fdfdfe |
#f9f9fa |
#f1f2f4 |
#eaebee |
#e3e4e8 |
#dadce1 |
#ced1d6 |
#909399 |
#83868e |
#90949b |
#6b6e75 |
#56585a |
critical |
#fffcfc |
#fff7f6 |
#ffedeb |
#ffe4e0 |
#ffdbd6 |
#ffcfc9 |
#ffbfb6 |
#e57065 |
#c9302d |
#b52524 |
#c0433c |
#7b4b45 |
serious |
#fffcfc |
#fff8f5 |
#ffeee8 |
#ffe5dc |
#ffdcd0 |
#ffd0c0 |
#ffc0aa |
#db7855 |
#d75928 |
#e36b3f |
#b64e27 |
#764e40 |
moderate |
#fffdfa |
#fff8f0 |
#ffefdb |
#ffe7c8 |
#ffdfb4 |
#fdd59f |
#f7c886 |
#c1882c |
#db9400 |
#eaa325 |
#966400 |
#6b5433 |
minor |
#fbfeff |
#f3fbff |
#e2f5ff |
#d4f0ff |
#c5ebff |
#b1e5ff |
#94dcff |
#3a9dc7 |
#009cce |
#1cabdf |
#00779e |
#395c6d |
success |
#f9fff9 |
#f2fdf2 |
#e3f9e2 |
#d4f6d4 |
#c8f2c8 |
#bbecbb |
#a8e3a9 |
#5aa35e |
#31983d |
#47a54f |
#2c7f34 |
#425f43 |
| Scale | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
primary |
#0e1016 |
#141924 |
#182239 |
#19284c |
#1c2f5c |
#21376d |
#294488 |
#385cb5 |
#406fe7 |
#3762d2 |
#5a85ec |
#8a9ec8 |
neutral |
#101011 |
#19191a |
#222324 |
#292a2c |
#303234 |
#393a3e |
#47494d |
#5e6066 |
#83868e |
#90949b |
#868990 |
#9c9ea0 |
critical |
#150f0e |
#231513 |
#361a17 |
#461a17 |
#541d19 |
#64211c |
#7c2823 |
#a93933 |
#c9302d |
#b52524 |
#df6056 |
#c6908a |
serious |
#150f0d |
#211612 |
#331c13 |
#431e11 |
#502211 |
#5f2712 |
#773116 |
#a04420 |
#d75928 |
#e36b3f |
#d56a44 |
#c19384 |
moderate |
#13100c |
#1e180f |
#2e200c |
#3a2502 |
#452c00 |
#513400 |
#644100 |
#845700 |
#db9400 |
#eaa325 |
#ba7d00 |
#b39a78 |
minor |
#0d1113 |
#111b1f |
#102630 |
#082e3e |
#02374b |
#004158 |
#00506c |
#00688a |
#009cce |
#1cabdf |
#1a94c2 |
#7ea3b6 |
success |
#0e120e |
#131b14 |
#172817 |
#163117 |
#173b1a |
#19451d |
#1f5624 |
#236e2b |
#31983d |
#47a54f |
#4a9b4f |
#87a687 |
Hex is the sRGB fallback. OKLCH is the authored form; a build emits both.
The label colour for each solid fill, identical in both themes:
| Scale | On-solid | Ratio |
|---|---|---|
primary |
white | 4.54:1 |
critical |
white | 5.34:1 |
neutral |
black | 5.77:1 |
serious |
black | 5.37:1 |
moderate |
black | 8.27:1 |
minor |
black | 6.68:1 |
success |
black | 5.68:1 |
Chosen per scale. A single global on-solid is how white-on-amber ships.
What components actually consume. Each resolves to a different primitive per theme; the alias itself never changes.
| Token | Light | Dark |
|---|---|---|
--ts-bg |
neutral-1 |
neutral-1 |
--ts-bg-subtle |
neutral-2 |
neutral-2 |
--ts-surface |
neutral-3 |
neutral-3 |
--ts-surface-hover |
neutral-4 |
neutral-4 |
--ts-surface-active |
neutral-5 |
neutral-5 |
--ts-border-subtle |
neutral-6 |
neutral-6 |
--ts-border |
neutral-7 |
neutral-7 |
--ts-border-interactive |
neutral-8 |
neutral-8 |
--ts-solid |
primary-9 |
primary-9 |
--ts-solid-hover |
primary-10 |
primary-10 |
--ts-on-solid |
white | white |
--ts-text |
neutral-12 |
neutral-12 |
--ts-text-subtle |
neutral-11 |
neutral-11 |
--ts-focus-ring |
primary-9 |
primary-9 |
--ts-scrim |
rgb(0 0 0 / 0.5) |
rgb(0 0 0 / 0.6) |
Per-scale step aliases exist for the cases the role layer does not cover:
--ts-critical-solid, --ts-critical-soft (step 3), --ts-critical-text
(step 11), and so on for every scale.
Aliases are emitted into every theme scope, not only :root. A custom
property whose value contains var() resolves on the element carrying the
declaration, and the resolved value is what inherits. An alias declared only at
the root keeps the root theme's colour inside a nested opposite-theme section.
Re-declaring every alias per scope is what makes [data-ts-theme] work on any
element rather than only the document root.
Families. Three stacks. The mono stack is load-bearing rather than decorative: tabstop renders CSS selectors and DOM snippets in every finding, and proportional text makes a selector harder to read and harder to copy correctly.
--ts-font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif
--ts-font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
"Liberation Mono", monospace
Inter is optional. An application that wants it redefines --ts-font-sans to
"Inter", <the default stack> and self-hosts the files. There is no separate
brand-font token, because a second family token invites two typefaces on one
page. This system does not bundle or load web fonts — a design system that ships
a font ships a network request its consumers did not choose.
Scale. Sizes in rem so they respond to user font-size preferences.
Tracking tightens as size grows, which is what keeps display text from looking
loose at the same tracking that suits body text.
| Role | Size | Weight | Line height | Tracking |
|---|---|---|---|---|
display |
3.000rem / 48px | 700 | 1.05 | −0.03em |
h1 |
2.250rem / 36px | 700 | 1.15 | −0.02em |
h2 |
1.875rem / 30px | 700 | 1.20 | −0.015em |
h3 |
1.500rem / 24px | 600 | 1.25 | −0.01em |
h4 |
1.250rem / 20px | 600 | 1.35 | −0.005em |
h5 |
1.125rem / 18px | 600 | 1.40 | 0 |
h6 |
1.000rem / 16px | 600 | 1.45 | 0 |
body-lg |
1.125rem / 18px | 400 | 1.60 | 0 |
body |
1.000rem / 16px | 400 | 1.60 | 0 |
body-sm |
0.875rem / 14px | 400 | 1.55 | +0.005em |
caption |
0.750rem / 12px | 500 | 1.45 | +0.01em |
code |
0.875rem / 14px | 400 | 1.50 | 0 |
code uses --ts-font-mono; every other role uses --ts-font-sans.
caption at 12px is the floor. Nothing smaller ships, and caption is never
used for text a user must read to complete a task — severity labels, error
messages and finding descriptions all use body-sm or larger.
4px base grid, emitted in rem. The token name is the multiplier.
| Token | rem | px |
|---|---|---|
--ts-space-0 |
0 | 0 |
--ts-space-1 |
0.25rem | 4 |
--ts-space-2 |
0.5rem | 8 |
--ts-space-3 |
0.75rem | 12 |
--ts-space-4 |
1rem | 16 |
--ts-space-5 |
1.25rem | 20 |
--ts-space-6 |
1.5rem | 24 |
--ts-space-8 |
2rem | 32 |
--ts-space-10 |
2.5rem | 40 |
--ts-space-12 |
3rem | 48 |
--ts-space-16 |
4rem | 64 |
--ts-space-20 |
5rem | 80 |
--ts-space-24 |
6rem | 96 |
The scale is deliberately gappy above 6. Offering every multiple invites decisions that carry no meaning; the gaps are the opinion.
Radii.
| Token | Value |
|---|---|
--ts-radius-none |
0 |
--ts-radius-sm |
0.25rem / 4px |
--ts-radius-md |
0.5rem / 8px |
--ts-radius-lg |
0.75rem / 12px |
--ts-radius-xl |
1rem / 16px |
--ts-radius-full |
9999px |
Nested radius. Where an element is inset from its container by a small,
equal gap on all sides — a concentric pairing — the inner radius is
max(0, outer − gap), or the two curves visibly disagree. Documented pairing:
outer lg (12px) with a --ts-space-2 (8px) gap takes inner sm (4px).
The rule applies only to concentric pairings. Once the gap exceeds the outer radius the formula yields zero and stops being useful, so an element inset by generous padding simply picks its own radius from the scale.
Breakpoints (rem, min-width): sm 30, md 48, lg 64, xl 80.
Containers (max-width, rem): prose 42, content 64, wide 80.
Five levels. Light and dark use different mechanisms, because a shadow tuned for a white page is nearly invisible on a dark one — dimming the same shadow produces depth that reads in one theme and vanishes in the other.
- Light: layered shadows at increasing blur and spread. Two layers per level — a tight, darker contact shadow and a wider, softer ambient one.
- Dark: a progressively lighter surface step plus a
border-subtlehairline, with a much softer shadow present only as a secondary cue.
| Token | Role | Light | Dark |
|---|---|---|---|
--ts-elevation-0 |
page content | none | neutral-1, no border |
--ts-elevation-1 |
cards, table rows | 0 1px 2px rgb(0 0 0 / 0.06), 0 1px 3px rgb(0 0 0 / 0.10) |
neutral-2 + neutral-6 hairline |
--ts-elevation-2 |
dropdowns, popovers | 0 2px 4px rgb(0 0 0 / 0.06), 0 4px 8px rgb(0 0 0 / 0.10) |
neutral-3 + neutral-6 hairline, 0 2px 6px rgb(0 0 0 / 0.35) |
--ts-elevation-3 |
nav bars, sticky headers | 0 1px 2px rgb(0 0 0 / 0.08), 0 4px 12px rgb(0 0 0 / 0.08) |
neutral-2 + neutral-7 hairline |
--ts-elevation-4 |
dialogs | 0 8px 16px rgb(0 0 0 / 0.10), 0 16px 32px rgb(0 0 0 / 0.14) |
neutral-4 + neutral-7 hairline, 0 8px 24px rgb(0 0 0 / 0.45) |
One further shadow token, used by pressed solid controls:
| Token | Value |
|---|---|
--ts-shadow-pressed |
inset 0 2px 4px rgb(0 0 0 / 0.18) |
This section is the one place raw colour literals are permitted, because a shadow is where they are defined. Rule 2 governs components, which reference the token.
Elevation never carries meaning on its own. A modal is not "important because it is elevated"; it is important because of what it says.
| Token | Value | Use |
|---|---|---|
--ts-duration-fast |
120ms | state changes: hover, focus |
--ts-duration-base |
200ms | disclosure, dropdown |
--ts-duration-slow |
320ms | modal, drawer |
--ts-ease-standard |
cubic-bezier(0.2, 0, 0, 1) |
most transitions |
--ts-ease-exit |
cubic-bezier(0.4, 0, 1, 1) |
dismissals |
Reduced motion is a hard contract. Under prefers-reduced-motion: reduce,
all durations collapse to 1ms and transforms are removed; opacity changes may
remain. No component in this system uses continuous or looping motion — a
spinner that cannot be stopped is excluded until a pausability escape is
designed.
These apply to every interactive component and are not restated per component.
States. default → hover → active → focus-visible → disabled. Each state is
a defined token. A state is never produced by applying opacity or a filter to
the base — that changes the text colour along with the background and silently
breaks contrast.
Focus. 2px solid --ts-focus-ring at 2px offset, applied via
:focus-visible only, so pointer users do not see a ring on click but keyboard
users always do. The ring is never removed and never replaced by a box-shadow:
outline with offset survives forced-colors mode, and box-shadow does not.
Focus ring contrast is verified at ≥3:1 against both --ts-bg and
--ts-surface, because a ring that only clears against the page fails on a card.
Disabled. --ts-surface fill, neutral-8 text. This deliberately fails
4.5:1, and that is correct — WCAG 1.4.3 exempts disabled controls, and a
disabled control that meets text contrast does not look disabled. disabled
and aria-disabled are never used to make something look inert while it still
responds.
Target size. ≥24×24 CSS px, per WCAG 2.2 AA (2.5.8). 44×44 on primary flows and on anything in a table row.
Motion. All state transitions use --ts-duration-fast and
--ts-ease-standard unless stated otherwise.
Purpose. Triggers an action. Never navigates — a control that changes the
URL is a link, and it must be an <a> so it keeps middle-click, right-click and
"open in new tab".
Variants.
| Variant | Rest | Hover | Active | Use |
|---|---|---|---|---|
primary |
--ts-solid fill, --ts-on-solid text |
--ts-solid-hover |
--ts-solid-hover + inset |
one per view; the main action |
secondary |
--ts-surface fill, --ts-text, --ts-border-interactive |
--ts-surface-hover |
--ts-surface-active |
supporting actions |
ghost |
transparent, --ts-text |
--ts-surface-hover |
--ts-surface-active |
toolbars, low-emphasis rows |
destructive |
critical-9 fill, white text |
critical-10 |
critical-10 + inset |
irreversible actions only |
The solid variants share one fill across hover and active, and distinguish
active with --ts-shadow-pressed rather than a further colour step. A third step would need its own verified on-solid pairing,
and the ramp has no room for one below step 10 that still carries white text.
The neutral variants have that room, so they use distinct fills.
Sizes. sm 32px tall / --ts-space-3 inline padding / body-sm;
md 40px / --ts-space-4 / body; lg 48px / --ts-space-5 / body-lg.
All use --ts-radius-md.
Loading. Retains its rest width so the layout does not shift, sets
aria-busy="true", and keeps its accessible name. The label is not replaced by
a spinner alone.
A11y.
- A real
<button>with a realtype. Never a<div>with a click handler. - Icon-only buttons require
aria-label. This is required in the type signature, not documented as a convention. - Label text meets 4.5:1 via the verified on-solid pairing.
destructiveis never distinguished fromprimaryby colour alone; its label states the consequence ("Delete site", not "Confirm").
Purpose. Single-line text entry. In tabstop the highest-traffic instance is the URL field on the scan form.
Variants. default, invalid, readonly. Sizes match Button.
States. Rest: --ts-bg fill, --ts-border-interactive (neutral-8)
border, --ts-radius-md. Hover: border moves to neutral-9 — a step that gains
contrast against the page in both themes, since step 9 is darker than step 8 in
light mode and lighter in dark. Focus: the universal ring, and the border
switches to --ts-focus-ring. Invalid: critical-8 border plus an icon in the
field. Disabled: per the universal contract.
A11y.
- A visible
<label>is required. Placeholder-as-label is prohibited: the placeholder disappears on input, fails contrast at typical placeholder greys, and is not reliably announced. - Placeholder, when present, shows format only —
https://example.com— never the field's name. - Errors:
aria-invalid="true"plusaria-describedbypointing at the message. The message carries an icon so the error is not signalled by red alone, and it states what to do, not merely what is wrong. - Error text is
body-sm,critical-11, verified at 4.5:1 on both--ts-bgand--ts-surface. - Autocomplete and
inputmodeset appropriately; the URL field usestype="url"andinputmode="url".
Purpose. Groups related content into one surface. In tabstop: a monitored site, a score summary, a finding group.
Variants. flat (--ts-surface, no shadow, --ts-border-subtle);
raised (elevation 1); interactive (elevation 1, gains elevation 2 and
--ts-surface-hover on hover).
Padding --ts-space-6; radius --ts-radius-lg. That padding is not a
concentric gap, so nested elements pick their own radius — --ts-radius-md for
a nested surface, --ts-radius-sm for a code block.
A11y.
- An
interactivecard contains one focusable element — typically the heading wrapped in a link — and the whole card is a click target for pointer users via that element's expanded hit area. A click handler on the card<div>is prohibited: it is unreachable by keyboard and invisible to assistive technology. - Card headings participate in the page's heading order. A card does not reset
heading levels to
h3because it looks right. - Nested interactive elements inside an
interactivecard are avoided; where unavoidable, the card is not itself interactive.
Purpose. A short status label. Tabstop's densest use is severity on a findings list.
Tones. neutral, primary, success, and the four severity levels
critical, serious, moderate, minor.
Appearance. Soft by default: <tone>-3 fill, <tone>-11 text,
<tone>-7 border, --ts-radius-full, --ts-space-1/--ts-space-2 padding,
body-sm. The <tone>-11 on <tone>-3 pairing is verified at ≥4.5:1 in both
themes — that pairing is why step 11 is solved against step 3 rather than
against the page.
A solid badge variant exists for the single-value case (an overall score
grade) and uses <tone>-9 with its verified on-solid colour.
A11y.
- Text is required. A badge with no text content is invalid; the type makes
childrenmandatory. This is the enforcement point for severity-never-by-colour-alone. - Severity badges additionally carry a distinct icon per level, so the four levels are distinguishable in greyscale and under any colour vision deficiency.
- A badge is not a control. If it filters or navigates, it is a Button or a link that happens to look like a badge.
- Where a badge conveys a count that changes, the change is announced through a live region, not by the badge alone.
Purpose. Interrupts to demand a decision. Used sparingly — in tabstop, for destructive confirmations and the add-site flow.
Sizes. sm 24rem, md 32rem, lg 48rem; each max-width: calc(100vw - var(--ts-space-8)).
Elevation 4, radius --ts-radius-lg, padding --ts-space-6, scrim
--ts-scrim.
A11y. This component's requirements are non-negotiable because a broken modal traps a keyboard user with no escape:
role="dialog",aria-modal="true",aria-labelledbypointing at the title.- Focus moves into the dialog on open — to the first interactive element, or to the dialog container when there is none.
- Focus is trapped while open, and returns to the invoking element on close. Losing the return point strands the user at the top of the document.
Esccloses, unless closing would discard unsaved input, in which caseEscraises the confirmation rather than being disabled silently.- Background content is
inertand page scroll is locked. - The close control has an accessible name ("Close dialog"), not just an ×.
- Destructive confirmations name the object and the consequence, and the
confirming button is
destructive.
Purpose. Primary wayfinding. Two arrangements: top for the marketing and
auth surfaces, sidebar for the authenticated dashboard.
Structure. Elevation 3. top: 64px tall, --ts-space-6 inline padding,
brand at the start, primary links centre or start-aligned, account menu at the
end. sidebar: 16rem wide, --ts-space-4 padding, collapsing to an overlay
drawer below the lg breakpoint.
States per item. Rest --ts-text-subtle; hover --ts-text on
--ts-surface-hover; current --ts-text on primary-3 with a 2px
--ts-solid indicator (left edge in sidebar, bottom edge in top).
A11y.
<nav>with anaria-label— and distinct labels when both arrangements are present on one page, since two unlabellednavlandmarks are indistinguishable.- The current item carries
aria-current="page". The colour and indicator are reinforcement, never the sole signal. - A skip-to-content link is the first focusable element in the document. It is visually hidden until focused, then fully visible. For a product that audits this exact pattern, shipping without it would be indefensible.
- The mobile disclosure button carries
aria-expandedandaria-controls; the drawer traps focus while open and restores it on close, as with Modal. - Navigation order in the DOM matches visual order.
- The active item is distinguishable in forced-colors mode, so the indicator is a real border rather than a background image.
These are the rules that keep the system a system. They are stated as prohibitions because that is how they are checkable.
-
Components consume semantic tokens. A component referencing a primitive (
--ts-primary-9) instead of a semantic token (--ts-solid) is a defect, because it is the thing that makes a second theme expensive. -
No raw hex,
rgb(), orhsl()in any component. Colour enters a component only through a token. A literal colour is invisible to theming and invisible to the contrast contracts. -
No raw pixel values for spacing, sizing, or radius. Use
--ts-space-*and--ts-radius-*. The two exceptions, both permitted and both requiring no justification because they cannot be expressed as tokens: hairline borders (1px,2px) and9999pxfor pill radius. -
Type is set through a role, not through properties. A component applies
body-sm; it does not setfont-sizeandline-heightindependently. Font sizes arerem, neverpx, so user font-size preferences work. -
Never hand-edit a primitive. Primitives are generated output. Change the curve, the envelope, or the scale's hue and regenerate. A value that is neither declared nor computed does not ship.
-
The contrast contracts run in CI and fail the build. All ninety pairs are checked on every change to the palette configuration. A palette change that breaks a contract does not merge. This is the difference between having contrast and claiming it.
-
Component-tier tokens require written justification.
--ts-button-bg-restis permitted, but each one is a small commitment to maintain, and a component tier that grows unchecked recreates the problem the semantic tier solved. -
Colour is never the sole carrier of meaning. Every status, severity, and error state pairs colour with text and an icon. In this product specifically, a violation here is a violation of the thing being sold.
- Wide-gamut (P3) output. sRGB only. The generation already gamut-maps into sRGB; emitting P3 as well is separable work.
- Forced-colors mode beyond the per-component notes. The components specify
what must survive; a systematic
forced-colorspass is its own piece of work. - Chart tokens. Tabstop plots scores over time, and a categorical and sequential palette validated for colour vision deficiency is a genuine requirement — but a separable one, with its own contracts.
- Toast and Skeleton. Both want continuous motion, which the reduced-motion contract forbids until a pausability escape is designed.
Each is real work that tabstop will need. Each gets its own design.