diff --git a/.claude/agents/design-reviewer.md b/.claude/agents/design-reviewer.md index bfb2f24c6a..b65076122a 100644 --- a/.claude/agents/design-reviewer.md +++ b/.claude/agents/design-reviewer.md @@ -16,22 +16,27 @@ Cite the DESIGN.md section for every finding. If a rule is not in DESIGN.md and ## Checks (report findings, most severe first) -1. **Token adherence** (Tokens, Derivation rules): semantic tokens only. The Copy Register gate (`tools/check-copy.mjs`) scans tracked web and mobile source for raw brand-accent literals and rejects findings outside the committed `tools/copy-baseline.json` ratchet; the accent-AA test covers the contrast floors. You catch the rest: raw `--slate-*` refs (Derivation rule 7), a new font family / radius / colour outside the spec, `transition-all`, `h-screen`, borders where the kit uses inset rings. **Accent rationing** (Identity & anchor) is yours and is the one most often broken: the accent belongs to the active tab, progress and ring indicators, done dots, the primary CTA, the FAB, and active nav; *that is the whole list*. It is never decorative on a card, row, border, heading, or a non-state icon. Flag `--primary` used as small text on the canvas (that is `--primary-soft`). +1. **Token adherence** (Tokens, Derivation rules): semantic tokens only. The Copy Register gate (`tools/check-copy.mjs`) scans tracked web and mobile source for raw brand-accent literals and rejects findings outside the committed `tools/copy-baseline.json` ratchet; the accent-AA test covers the contrast floors. You catch the rest: raw `--slate-*` refs (Derivation rule 7), a new font family / radius / colour outside the spec, `transition-all`, `h-screen`, borders where the kit uses inset rings. **Accent rationing** (Tokens) is yours and is the one most often broken. The accent takes exactly four ROLES: **the next action** (primary CTA, FAB, an empty state's invitation arc), **current position** (active tab, active nav, a selected card or option, a focused field's ring), **progress toward something unfinished**, and **one element inside the logo mark**. **It never marks completion**, so a 100% progress ring, a done row and a streak total are all neutral. It is never decorative on a card, row, border, heading, static badge or chip, or a non-state icon. Flag `--primary` used as small text on the canvas (that is `--primary-soft`). 2. **The three shipping tests** (Working model) — run all three, not just the last. - - **AI-slop test:** any decorative gradient (`--gradient-header` and `GradientTop` are deleted, so there is no sanctioned gradient left to be outside of) including gradient borders and gradient text, any glow, decorative background orbit arcs, a coloured side-stripe border, cards-in-cards, connector or tree lines, gray text on coloured backgrounds, rounded-square icon tiles above headings, oversized centered H1 outside hero, and the hero-metric template used as decoration. + - **AI-slop test:** any decorative gradient (`--gradient-header` and `GradientTop` are deleted, so there is no sanctioned gradient left to be outside of) including gradient borders and gradient text, any glow, decorative background orbit arcs, a coloured side-stripe border, cards-in-cards, connector or tree lines, gray text on coloured backgrounds, rounded-square icon tiles above headings, oversized centered H1 outside hero, and the hero-metric template used as decoration. **Five newer tells carry the same weight:** a **glass or Liquid Glass material** or a frosted panel used as a look; a **sparkle icon** used as an AI or default marker (identity is the Astra glyph); a **pill used as a CTA outside `PillButton`**, or any pill in a hero, or a static element wearing the pill radius (radius 999 means pressable, a badge is radius 8); a **default component-library theme or a default white accent** on any surface, marketing included; and a **descriptive subtitle** beneath a heading, label, card, or settings row that restates it. That last one is the most common copy-slop shape in agent-written UI and it walks past every gate in the repo. - **Squint test:** blur the surface — one focal element should survive per view. - **Scene-sentence test:** describe the rendered surface in one sentence. If it reads like generic SaaS ("a clean modern dashboard with cards"), say so and name what would make it read as Orbit. **There is no glow and no gradient wash** (Bans) — identity is carried by the logo mark, the Astra glyph, ring indicators, and a rationed accent on a near-black canvas. 3. **Restraint has a floor** (Identity & anchor; vault ADR) — the failure mode opposite to slop. A quieting pass must not flatten every element to one size and weight, go grayscale, strip all personality, or trade an affordance for calm. Reduce drama, keep the point of view. Flag a surface that has become generic in the name of restraint. -4. **Layout & composition** (Layout & spacing, Surface rules, Type roles) — the 65ch measure (Measure and wrapping); spacing rhythm (tight within a group, air between groups); **concentric radii** (outer = inner + padding — a parent and its inset child never share a radius); **optical alignment** (chevrons, play triangles, leading plus glyphs, and a glyph in a circular well need a 1-2px nudge off geometric center); "a card is not a layout primitive"; one focal element per view; the ≤4 options ceiling; blur and glass restraint. -5. **Motion** (Motion) — the **frequency gate** and the **closed purpose list** (Whether); the **remediation order** (How); the **delight budget**. `local/no-overshoot-easing` and `local/will-change-discipline` cover the mechanical half only — the judgement half (does this motion earn its place at this frequency?) is yours. `local/animate-presence-exit` covers only the statically decidable half; an `exit` whose `AnimatePresence` ancestor lives at another call site is invisible to it, so verify the pairing by reading. **`local/no-arbitrary-zindex` is not implemented** (the semantic stacking scale does not exist yet, #539) — until it does, flag arbitrary `z-*` values by eye. -6. **States & copy** (States, Copy): the loading/empty/error **triad** and the **full state set**; copy naming and **"say it once"**; error content and placement; **confirmation-dialog warrant** (does this action deserve a dialog?); **eyebrow rationing**. The Copy Register gate (`tools/check-copy.mjs`) scans i18n string values, deliberately not keys, for shouted strings and the cliché register and rejects findings outside the committed `tools/copy-baseline.json` ratchet; do not re-flag those. Do flag copy that is merely *wordy*, redundant with its heading, or inconsistent in terminology across the two locales (`.claude/rules/product-and-content.md`, rule 5). +4. **Layout & composition** (Layout & spacing, Surface rules, Type roles) — the 65ch measure (Measure and wrapping); **the 2x gap rule** (the gap between two groups is at least twice the gap within a group, or the grouping reads as noise); **concentric radii** (outer = inner + padding, and treat layers as independent once the padding exceeds 24px); **optical alignment** (chevrons, play triangles, leading plus glyphs, and a glyph in a circular well need a 1-2px nudge off geometric center; a trailing-icon pill takes 2px less padding on the icon side); "a card is not a layout primitive"; one focal element per view; the ≤4 options ceiling; blur and glass restraint; **D34, exactly one separation device per boundary** (a separator is never paired with a large gap, because the gap already did the job). +4b. **Icons** (Shape, shadow, motion, icons) — sizes are the set's native grid, **16 / 20 / 24 only**, default 24; an off-grid size such as 22 renders soft. Stroke matches the adjacent text weight: 1.5 beside 400, 2 beside 500 and 600, 2.5 beside 700. One icon set and one stroke strategy per surface, `currentColor` for states, outline as default and fill for active. +5. **Motion** (Motion) — the **frequency gate** and the **closed purpose list** (Whether); the **remediation order** (How); the **delight budget**. `local/no-overshoot-easing` and `local/will-change-discipline` cover the mechanical half only — the judgement half (does this motion earn its place at this frequency?) is yours. `local/animate-presence-exit` covers only the statically decidable half; an `exit` whose `AnimatePresence` ancestor lives at another call site is invisible to it, so verify the pairing by reading. `local/no-arbitrary-zindex` ships and the semantic stacking scale exists (DESIGN.md `## Stacking`), so do not re-flag what it catches; a tier used at the wrong level is still yours. +6. **States & copy** (States, Voice): the loading/empty/error **triad** and the **full nine-state set** (default, hover, focus, active, disabled, loading, error, empty, **at capacity**); a boundary is not an error, so an at-capacity state uses neutral tokens and carries no upgrade call to action. The **listing thresholds** (Listing) are checkable: 0 / 1 / few / many / too many, and a collection that can exceed 20 declares its behaviour before it ships, never as a wrapping pill row. Copy naming and **"say it once"**; the **supporting-copy ban** and its form-field carve-out; error content and placement; **confirmation-dialog warrant** (does this action deserve a dialog?); **eyebrow rationing**. The Copy Register gate (`tools/check-copy.mjs`) scans i18n string values, deliberately not keys, for shouted strings and the cliché register and rejects findings outside the committed `tools/copy-baseline.json` ratchet; do not re-flag those. Do flag copy that is merely *wordy*, redundant with its heading, or inconsistent in terminology across the two locales (`.claude/rules/product-and-content.md`, rule 5). 7. **Frozen & special surfaces** (Habit list, Special surfaces) — the **habit-list treatment** and the **paywall shape** are frozen; flag any drift. **Flow-shape parity with neighbours**: a new flow that does not match the IA of the flows beside it is a conceptual misalignment, not a one-off — name the root cause (`.claude/rules/product-and-content.md`, rule 3). -8. **Buttons — width, brevity, icons** (Buttons) — full-bleed pills are AI slop: a pill hugs its content unless it is in the allowlist (the single primary action of a mobile bottom-sheet/dialog, a mobile form submit in auth/onboarding, a full-screen empty-state CTA, or a `ConfirmDialog` paired action row). `local/no-fullbleed-button` catches web `fullWidth`/`w-full`/`flex-1`, but **cannot see mobile StyleSheet width** (`alignSelf: 'stretch'` / `width: '100%'`) — flag stretched mobile pills by eye. Also flag over-wordy labels (a verb the dialog title already frames: "Log all"→"Log", "Registrar todos"→"Registrar") and commit CTAs missing their canonical leading glyph (create→plus, confirm→check, destructive→trash). Taxonomy is `primary`/`secondary`/`ghost`/`destructive` × `sm`/`md`/`lg` — flag a raw ad-hoc pill that should be `PillButton`. +8. **Buttons — width, brevity, icons** (Buttons) — full-bleed pills are AI slop: a pill hugs its content unless it is in the allowlist (the single primary action of a mobile bottom-sheet/dialog, a mobile form submit in auth/onboarding, a full-screen empty-state CTA, or a `ConfirmDialog` paired action row). `local/no-fullbleed-button` catches web `fullWidth`/`w-full`/`flex-1`, but **cannot see mobile StyleSheet width** (`alignSelf: 'stretch'` / `width: '100%'`) — flag stretched mobile pills by eye. Also flag over-wordy labels (a verb the dialog title already frames: "Log all"→"Log", "Registrar todos"→"Registrar") and commit CTAs missing their canonical leading glyph (create→plus, confirm→check, destructive→trash). Taxonomy is `primary`/`secondary`/`ghost`/`destructive`/`caution` × `xs`/`sm`/`md`/`lg` — flag a raw ad-hoc pill that should be `PillButton`. A submit stays **enabled** until the request starts, then disables with a spinner **keeping its original label**, because the label is what tells assistive tech which button is busy. + +8b. **Overlay** (Overlay) — an overlay is **content-height by default**; a fixed detent is allowed only where the content is genuinely long, never as the default. **Exactly one scroll container, owned by the primitive**: a caller that nests its own scroll view inside a scrollable sheet is a Blocker, and so is an overlay that opens scrolled away from its own first line. The panel is opaque, because a translucent panel over arbitrary content cannot have its contrast checked. Web wraps `@base-ui/react` `Dialog`; mobile wraps TrueSheet. Flag any hand-rolled focus trap, scroll lock, or portal added beside them. 9. **A11y — the judgement half** (Accessibility) — **colour as the only signal** (Perception); the **3:1 non-text contrast floor** for icons, borders, and state indicators; **focus management in overlays** (Keyboard and focus) — where focus lands on open, that it is trapped, and where it returns on close; **reduced-transparency and reduced-contrast handling**. Icon-only controls need a localized accessible label in BOTH locales; hit targets ≥44 (reach the minimum by padding, not by growing the glyph). The `jsx-a11y` rules and `local/require-focus-replacement` cover the static half — semantics and announcement *quality* are yours. Note that 200% zoom, keyboard traps, and screen-reader semantics need the live DOM and belong to the proposed a11y CI gate, not to you (DESIGN.md "Not enforceable here") — say so rather than guessing. 10. **Cross-platform parity** — a UI change in `apps/web` or `apps/mobile` must have its mirror. Defer the deep check to `parity-checker`, but flag an obvious one-sided change. 11. **Responsive + orientation** (Desktop density & orientation, Sub-screen navigation) — desktop composes horizontally (no single stretched mobile column); pills are intrinsic-width at desktop; every sub-screen has a visible back affordance. -**Staged gates.** `local/no-decorative-glow` and `local/no-raw-gradient` ship at `warn` pending bundle 5's cleanup, so the existing violations are known debt — do not report them as new findings. A **newly introduced** glow or raw gradient in this diff is still a Blocker: the token is deleted and the ban is settled. +**Staged gates.** `local/no-decorative-glow` and `local/no-raw-gradient` ship at `warn` until the `eslint-rules/` re-derivation lands (the PR after human grant 1), so the existing violations are known debt — do not report them as new findings. A **newly introduced** glow or raw gradient in this diff is still a Blocker: the token is deleted and the ban is settled. The same applies to the spacing scale: its step values are **provisional until grant 1**, so do not flag an off-scale value against a scale that has not been chosen yet. + +**The colour system changed on 2026-08-15.** One scheme, not six. A deep space-blue accent, not violet. `--fg-on-primary` is the canvas ink and **not white**, because white on the accent measures 2.41:1. `--status-frozen` is a neutral plus the snowflake glyph and no longer a hue. Flag any surviving reference to six schemes, a violet accent, `Rubik`/`Inter`/`Roboto`, or a cyan frozen dot. ## Screenshot critique diff --git a/DESIGN.md b/DESIGN.md index 619de57774..3748901299 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,381 +1,587 @@ -# Orbit Design System - > **At a glance** - the authoritative spec for every Orbit UI surface; it overrides generic and user-global design defaults. -> - Anchor (locked, 2026-07-17 freeze): de-decorated navy-violet orbital. Neutral canvas, rationed violet accent, hierarchy from surface steps + hairlines. **No decorative glow, no gradient wash, anywhere.** +> - Anchor (D66, 2026-08-14): spacious, near-black, maximum contrast, warmth in ONE mark. Canvas `#09090B`, ONE colour scheme, ONE accent (the hue is decided at human grant 1). **No decorative glow, no gradient wash, no Liquid Glass, anywhere.** > - Identity is carried by the orbital logo mark, the Astra orbital glyph, and ring-shaped indicators. Never by background decoration. > - Semantic tokens only (`--bg`, `--bg-card`, `--bg-elev`, `--fg-1..4`, `--primary`, `--primary-soft`, `--primary-rgb`, `--hairline`, `--scrim`, ...); no raw hex in UI. -> - Scales: type, **spacing (enumerated: `0 4 8 12 16 20 24 28 32 40 48 56 64`, three named exemptions, gated by `local/spacing-scale`)**, radius, motion. Ships light AND dark, all 6 color schemes; mobile-first 412px shell. +> - Scales: type, spacing (enumerated, gated by `local/spacing-scale`), radius, motion. Ships light AND dark, **two variants, not twelve**; mobile-first 412px shell. > - Tokens live in `apps/web/app/globals.css` + `apps/mobile/lib/theme.ts` + `packages/shared/src/theme/`. -> - Sections (exact `##` names, so this line is greppable): Identity & anchor · Tokens · Type roles · Layout & spacing · Primitives kit · Buttons · Surface rules · Habit list · States · Copy · Desktop density & orientation · Sub-screen navigation · Motion · Accessibility · Special surfaces (paywall, landing hero) · Bans · Working model · Enforcement. +> - Sections (exact `##` names, so this line is greppable): Identity & anchor · Tokens · Type roles · Layout & spacing · Primitives kit · Overlay · Buttons · Surface rules · Habit list · Listing · States · Voice · Desktop density & orientation · Sub-screen navigation · Motion · Accessibility · Special surfaces · External component sources · Bans · Working model · Enforcement. > - Read the whole doc before shaping, reviewing, or theming any surface. `## Enforcement` says which rules are gate-backed and which are reviewer judgment. -**Authority note:** this DESIGN.md is authoritative over any generic or user-global design default, including the global anti-Inter and anti-violet rules. Orbit adopts Inter for display type and a violet accent deliberately; that deviation is documented here once and applies repo-wide. Deliberate emoji use (habit emoji wells, stat tiles, streak flame, celebration heroes) is part of the language and overrides the global anti-emoji rule on those surfaces only. +# Orbit Design System + +**Authority note:** this DESIGN.md is authoritative over any generic or user-global design default. Deliberate emoji use (habit emoji wells, celebration heroes) is part of the language and overrides the global anti-emoji rule on those surfaces only. It is authoritative for **both platforms** (`apps/web`, `apps/mobile`) and for the `orbit-landing-page` mirror. A rule is cross-platform unless it names a platform. -**Provenance.** The visual language is frozen by the owner-approved Today mockups (desktop `KQMPM`, mobile `N8aEDF`, 2026-07-17). The frozen decisions win over every other input. The craft rules below are the 2026-07-17 harvest of 193 external design skills, deduplicated and routed in the vault note `Orbit skill harvest - canonical rule set (#539)`; where a harvested rule contradicted the freeze it was dropped upstream and is not here. +**Provenance.** The direction is the 2026-08-05 direction ADR, amended by D66 (2026-08-14). The mechanical rules come from the 2026-07-17 harvest of 193 external design skills, plus 45 skill files and one component library read live on 2026-08-15. The implement-or-reject verdict for every one of those inputs is recorded on ticket `#36`, not here: this document is the guidance, and which external source it came from does not change how a surface gets built. Where this document and a rendered `design/reference.html` disagree, **the page wins and this prose is the defect** (D42). + +**Every sentence below changes an implementation choice.** Nothing here is advice. ## Identity & anchor (locked) -Orbit is a **de-decorated navy-violet orbital habit tracker**. Near-black neutral canvas, one rationed violet accent, opaque-reading surface steps built from a white-alpha ladder, hairline rings and dividers, rounded geometry (8/12/16/18/20/999), Rubik/Inter/Roboto type, deliberate emoji. +Orbit is a **spacious, near-black habit tracker with exactly one accent**. Space is the primary hierarchy device. Where a surface step or a hairline would separate two things, use space first. Identity comes from three things and nothing else: 1. the **orbital logo mark**, -2. the **Astra orbital glyph** (which replaces the sparkle icon; the "AI"/"IA" badge is dropped, since the word is the liability, not the glyph), +2. the **Astra orbital glyph** (which replaces the sparkle icon), 3. **ring-shaped status and progress indicators**. -It does **not** come from a background gradient, a glow, decorative background orbit arcs, or any texture. Those were tried and rejected. Hierarchy is bought with surface steps, hairlines, size, weight, and whitespace. +**The mark is an orbit in perspective with one body on the path**: a tilted ellipse at roughly 0.62 aspect, a single stroke, no track and no partial sweep. That is deliberately not the ProgressRing shape, which is a true circle carrying a coloured sweep over a track, so a logo can never be read as a completion percentage. **The mark carries the accent on exactly one element, its body, and that is the only non-state use of the accent in the whole system.** Everything else in the mark is `--fg-1`. + +**Astra is told apart from the mark by its core.** Astra is a circle with a filled centre; the mark is a tilted ellipse that is empty in the middle. The rule is that short: **anything with a core is Astra, anything empty in the middle is Orbit.** + +**Asset sizes are enumerated: 16, 48, 128, 512.** The mark is neither type nor an icon, so it answers to neither the type scale nor the 24 icon grid. **At 16 the mark is redrawn natively rather than scaled**, because a stroke scaled down from the 24 grid renders soft, and at 16 it is monochrome `--fg-1` with no accent. + +**The wordmark** is Space Grotesk 600 in natural case, `-0.02em`, `--fg-1`, carrying `translate="no"`. The horizontal lockup is a 28px mark, a 12px gap and a 22px wordmark. + +It does **not** come from a background gradient, a glow, decorative background orbit arcs, a texture, or a glass material. Hierarchy is bought with space first, then size, weight, and contrast. A surface step or a hairline is the last resort. + +**Warmth has exactly one source: the mark.** There is no warm palette, no texture, and no illustration set. If a surface feels cold, the answer is space and type, never a second warm element. + +**Quiet decoration is still decoration.** A softened glow, a 0.03-opacity texture, a "subtle" mesh gradient, and a barely-there blur are the same violation as the loud version. -The anchor is locked. Do not re-pick, do not hybridise, do not add a "variance" or "intensity" dial. The output should read as **calm, spacious, quietly cosmic**: a personal orbit, not a SaaS dashboard. +**"Design" means UX and accessibility, not appearance.** Do not read this document as a skin. -**Quiet decoration is still decoration.** A softened glow, a 0.03-opacity texture, a "subtle" mesh gradient, a barely-there blur are all the same violation as the loud version. The freeze removes the layer; it does not dim it. +**Finish it or delete it. Nothing stays at 60%.** ## Tokens Canonical CSS lives in `apps/web/app/globals.css`; the mobile equivalent is `createTokensV2` in `apps/mobile/lib/theme.ts`; shared ramp data in `packages/shared/src/theme/`. +**OKLCH is the derivation space. Hex and `rgba()` are the authored notation.** Derive every value in OKLCH, then write the resolved hex or `rgba()` into the token source. Never author an `oklch()` literal in a shared token or a mobile style: `@react-native/normalize-colors` parses `hex`, `rgb`, `rgba`, `hsl`, `hsla` and `hwb`, and has **no `oklch` branch** (read from the installed package, 2026-08-15), so an `oklch()` token is a runtime failure on Android and breaks the parity contract. + +**A token carrying alpha cannot be contrast-checked on its own.** Its rendered value depends on what sits behind it. Every alpha token below therefore records the hex it resolves to over the canvas, and any surface that can sit over arbitrary content is authored **opaque**. + ### Type -- Families: `--font-sans` **Rubik** (UI), `--font-display` **Inter** (hero, big numerals), `--font-mono` **Roboto** (meta + tabular numerals, the "mono" role; intentionally NOT a true monospace; use `font-variant-numeric: tabular-nums` / RN `fontVariant: ['tabular-nums']`). -- Weights loaded: Rubik 400/500/600/700 · Inter 500/600/700 · Roboto 400/500/700. Weight scale is deliberately squashed: regular=400, medium/semibold=500, bold=600. -- Scale: `--fs-xs 12 / sm 14 / base 16 / md 18 / lg 22 / xl 28 / 2xl 34 / 3xl 44 / 4xl 60`. Line-heights: tight 1.15 / snug 1.3 / body 1.55 / loose 1.7. Tracking: tight -0.01em / wide 0.06em / widest 0.12em. -- **`font-synthesis: none` on the document root** so an unloaded weight or style fails visibly instead of rendering a browser-faked bold or italic. Only the weights listed above exist; any other weight is a bug, not a rendering. -- Express a typographic feature through its dedicated CSS property, never the raw axis or feature tag: `font-weight` not `font-variation-settings: "wght"`, `font-variant-numeric: tabular-nums` not `font-feature-settings: "tnum" 1`, `font-optical-sizing` not `"opsz"`. +- Families: `--font-sans` **Geist Sans** (UI), `--font-display` **Space Grotesk** (display numerals and hero), `--font-mono` **Geist Mono** (meta and tabular numerals). +- Weights loaded: Geist Sans 400/500/600 · Space Grotesk 500/600 · Geist Mono 400/500. Any other weight is a bug, not a rendering. +- Scale: `--fs-xs 12 / sm 14 / base 16 / md 17 / lg 20 / xl 22 / 2xl 28 / 3xl 34 / 4xl 44 / 5xl 60`. +- **Size floors:** long-form body about 16, inputs and menus about 14, captions 13, rarely below 12. Below 18, stay at weight 400 or above; weights under 300 do not exist in this system. +- **Emphasis within a role is one weight step up, never a size change.** +- Line-height by role: display 1.05 / heading 1.15 to 1.2 / row 1.35 / body 1.55 / meta 1.4. Unitless values only. **Anything that wraps to 3 or more lines takes at least 1.4**, even in a height-constrained row. +- **Tracking is size-specific.** Large display text takes negative tracking; body sits at 0; small uppercase labels take positive tracking. A single `letter-spacing` value across the scale is wrong somewhere. +- **`font-synthesis: none` on the document root.** Verify first that every required weight loads, because disabling synthesis must never erase emphasis. +- **Apply `-webkit-font-smoothing: antialiased` and `-moz-osx-font-smoothing: grayscale` once on the root**, never per component. Web only. +- Express a typographic feature through its dedicated CSS property, never the raw axis or feature tag: `font-weight` not `font-variation-settings: "wght"`, `font-variant-numeric: tabular-nums` not `font-feature-settings: "tnum" 1`. +- **`text-box: trim-both cap alphabetic` on badges, chips and pill labels**, so text sits optically centred rather than low. Progressive enhancement; unsupported browsers keep the default leading. Web only. +- **Light text on a dark surface is compensated on three axes**: add `0.01em` tracking, add `0.05` to the line-height, and add one weight step where the face needs it. This is the legibility check that the maximum-contrast target requires, and it answers the halation risk the direction ADR recorded. + +**There is no serif. Cut by Thomas against the rendered reference, 2026-08-15.** Instrument Serif was carried as D66 decision 9's provisional fourth family on warm surfaces. It is dropped, so **the direction ADR's ban on a display serif as a second warmth source stands unamended**, and D66 decision 9 resolves to "cut". Three families is the whole system. A warm surface gets its warmth from space, size and the mark, never from a second face. ### Spacing (base 4) -**The scale is these thirteen values and nothing else:** +**The scale is these ten values and nothing else** (chosen by Thomas against the rendered reference, 2026-08-15): ``` -0 4 8 12 16 20 24 28 32 40 48 56 64 +0 4 8 12 16 24 32 48 64 96 ``` -A spacing value outside that set is a defect. The set is enumerated rather than described as "a multiple of 4" because a machine has to be able to check it: `local/spacing-scale` reads exactly this list, and "base 4" as prose let 6, 10, 14, 18 and 22 accumulate across a third of the app before anyone counted. +It drops 20, 28, 40 and 56, which are the values the 1,436 existing violations cluster around, so there is less to choose wrongly between, and its jumps widen at the top to serve the spacious direction. -Negative values are legal only at the negation of a scale step (`-8`, not `-6`), and only where a negative offset is genuinely the layout - never to undo a parent's padding (see **Bans**). +**The rule the values satisfy: the gap between two groups is at least 2x the gap within a group.** 8 inside a group means 16 or more between groups. Uniform gaps everywhere are the tell of no decision. This is the mechanical grounding the scale has. -| token | px | typical use | -|---|---|---| -| `space-1` | 4 | icon-to-label inside a dense control | -| `space-2` | 8 | within-group gaps, chip padding | -| `space-3` | 12 | row internal gaps | -| `space-4` | 16 | card padding, list-row horizontal padding | -| `space-5` | 20 | row vertical padding, right-rail module gap | -| `space-6` | 24 | section padding, between-group air | -| `space-7` | 28 | dense section padding | -| `space-8` | 32 | between sections | -| `space-10` | 40 | screen top/bottom padding | -| `space-12` | 48 | between major blocks | -| `space-14` | 56 | large hero inset | -| `space-16` | 64 | hero and empty-state breathing room | +**12 and 24 survive** because those are the measured control clearances below. -**What the scale governs:** `margin` and `padding` (every side, every logical and React Native `Horizontal`/`Vertical` variant), `gap` / `rowGap` / `columnGap`, and the positional insets (`top` / `right` / `bottom` / `left` / `start` / `end` / `inset*`). It governs them wherever they are written - a Tailwind utility, a Tailwind arbitrary value, a JSX inline `style={{ }}` object, or a React Native `StyleSheet.create` object. Inline style objects are where most of Orbit's spacing actually lives and are invisible to CSS tooling; they are not a loophole. +**Two padding roles sit on top of the scale**, because a card that hugs its text reads as cramped no matter how correct the gaps around it are: -**What the scale does not govern:** `width` and `height`. An avatar diameter, an icon box, a sheet height are component dimensions, not layout rhythm. They answer to the primitives kit, not to this scale. Folding them in would force an exemption list wide enough to make the gate meaningless. +| role | value | +|---|---| +| card and panel padding | **24** | +| list-row padding | **16** | + +| between | clearance | +|---|---| +| adjacent bordered or filled controls | 12 | +| around borderless text and icon controls | 24 | +| unrelated control groups | 24 or more, and at least 2x the intra-group gap | -Mechanics: use a flex or grid container with `gap-*`. **Never `space-x-*` / `space-y-*`, never a margin for sibling spacing.** +A spacing value outside the chosen set is a defect. The set is enumerated rather than described as "a multiple of 4" because a machine has to check it. -**The named exemptions - these three, and nothing else.** An exemption is a category with a reason, never a value added to the scale because code already used it: +Negative values are legal only at the negation of a scale step, and only where a negative offset is genuinely the layout. + +**What the scale governs:** `margin` and `padding` (every side, every logical and React Native variant), `gap` / `rowGap` / `columnGap`, and the positional insets. It governs them in a Tailwind utility, a Tailwind arbitrary value, a JSX inline `style={{ }}` object, and a React Native `StyleSheet.create` object. + +**What the scale does not govern:** `width` and `height`. Those are component dimensions and answer to the primitives kit. + +Mechanics: use a flex or grid container with `gap-*`. **Never `space-x-*` / `space-y-*`, and never a margin for sibling spacing.** + +**The named exemptions, these three and nothing else:** | name | what it covers | why | |---|---|---| -| `pill-button-geometry` | the locked size table in `packages/shared/src/theme/button.ts` (heights 38/40/50/56, `paddingX` 18/26/30, icon `gap` 7/9/10) | A primitive's internal geometry, off-scale by history and frozen as shared data. It is read through `PillButton`, never hand-tuned per call. **The exemption is scoped to that one file.** Re-typing 9 or 26 into a chip, badge, or toolbar is not covered - that is the leak this scoping exists to stop. | -| `hairline-inset` | `±1` on a positional inset only (`top` / `right` / `bottom` / `left` / `start` / `end` / `inset*`) | Aligning an element to a 1px hairline is a rendering fact, not a spacing decision. It never applies to padding, margin, or gap. | -| `explicit-allow` | values passed to the rule's `allow` option | The escape hatch, and it is deliberately loud: adding one is a diff to the lint config, reviewed like any other. Per **Standing rules / product-and-content #2**, expanding the system is a request, not a judgement call. | +| `pill-button-geometry` | the locked size table in `packages/shared/src/theme/button.ts` | A primitive's internal geometry, frozen as shared data. **The exemption is scoped to that one file.** | +| `hairline-inset` | `±1` on a positional inset only | Aligning to a 1px hairline is a rendering fact, not a spacing decision. | +| `explicit-allow` | values passed to the rule's `allow` option | The loud escape hatch. Adding one is a diff to the lint config. | -**Space unevenly on purpose:** tight within a group, real air between groups. Uniform gaps everywhere is the tell of no decision, not a system. +**Units: px everywhere, for parity.** React Native has no `rem`. Two web-only exceptions are sanctioned platform adapters: media-query breakpoints and the `max-width` of a text container may use `rem`, so both respond to the reader's base font size. -### Dark mode (the primary theme, purple scheme, byte-exact targets) +### Dark mode (the primary theme, byte-exact) -The dark surfaces below are the **white-alpha ladder composited over the frozen canvas**. The alpha ladder is the mechanism; the hexes are what it resolves to on the purple scheme. +Every value is derived in OKLCH against the canvas and measured. Do not eyeball a replacement. ``` ---bg #070910 /* frozen canvas, neutral / faint-cool, NOT violet-tinted */ ---bg-card rgba(248,250,252,0.04) /* THE card. Habit tonal panels, Astra card, PlanCard. -> #111319 */ ---bg-field rgba(248,250,252,0.05) /* Field, OTP fill */ ---bg-well rgba(248,250,252,0.06) /* emoji wells, icon squares -> #16181E */ ---bg-elev rgba(248,250,252,0.06) /* elevated / hover step, same alpha as the well. NOT the card. */ ---bg-elev-2 rgba(248,250,252,0.10) /* the highest step -> #1F2128 */ ---bg-sunk rgba(0,0,0,0.28) /* recessed wells */ ---hairline rgba(255,255,255,0.08) /* #FFFFFF14 */ ---hairline-ghost rgba(255,255,255,0.10) /* #FFFFFF1A - the habit tonal panel's ghost-edge ring ONLY */ ---hairline-strong rgba(255,255,255,0.16) /* #FFFFFF29 */ ---fg-1 #F6F7F9 --fg-2 #C7CBD2 --fg-3 #888E99 --fg-4 #565C67 ---primary #8659EA /* fill / graphic only. Never small text on canvas. */ ---primary-soft #B69BF8 /* accent TEXT only */ ---primary-dim #8659EA2E /* the accent tint */ ---primary-pressed #6E44D2 ---fg-on-primary #FFFFFF /* scheme x mode resolved; see hand-tune log */ ---status-done var(--primary) · empty rgba(255,255,255,0.22) · skip #888E99 ---status-overdue rgb(254,154,0) · bad rgb(251,44,54) · frozen rgb(0,211,243) ---status-overdue-text rgb(254,154,0) · bad-text rgb(251,44,54) /* = base; AA >= 4.5 on canvas */ ---fg-on-bad rgb(2,6,24) · --fg-on-overdue rgb(2,6,24) /* painted ON the bad|overdue fill; per-mode, see hand-tune log */ ---selection-bg rgba(var(--primary-rgb),0.32) ---scrim rgba(0,0,0,0.55) /* THE modal/sheet/dialog backdrop. Theme-independent. Web `bg-black/55`, mobile `tokens.scrim`. */ +--bg #09090B /* canvas. oklch(0.141 0.004 285.8). White on it 19.90:1 */ +--bg-card rgba(250,250,250,0.04) /* THE card -> #131315 */ +--bg-field rgba(250,250,250,0.06) /* field, OTP fill -> #171719 */ +--bg-well rgba(250,250,250,0.08) /* emoji wells, icon squares -> #1C1C1E */ +--bg-elev #1C1C1E /* OPAQUE. Overlay panel, menus, popovers */ +--bg-elev-2 rgba(250,250,250,0.12) /* the highest inline step -> #262628 */ +--bg-hover rgba(250,250,250,0.14) /* THE hover surface -> #2B2B2C. 1.31:1 against the resting card */ +--bg-sunk rgba(0,0,0,0.28) /* recessed wells -> #060608 */ +--hairline rgba(255,255,255,0.08) /* separator: divides content */ +--border-control rgba(255,255,255,0.08) /* border: encloses a control. Same value today, separate role */ +--hairline-ghost rgba(255,255,255,0.10) +--hairline-strong rgba(255,255,255,0.16) +--fg-1 #F4F4F6 /* 18.11:1 */ --fg-2 #C9C9CC /* 12.04:1 */ +--fg-3 #8F8F93 /* 6.18:1 */ --fg-4 #5D5D60 /* 3.03:1, clears the 3:1 non-text floor */ +--primary PENDING GRANT 1 /* fill and graphic ONLY. See "The accent" */ +--primary-soft PENDING GRANT 1 /* accent TEXT on the canvas */ +--primary-pressed PENDING GRANT 1 /* the fill, 16% toward the canvas */ +--primary-hover PENDING GRANT 1 /* the fill, 12% toward its own foreground */ +--primary-rgb PENDING GRANT 1 +--primary-dim PENDING GRANT 1 /* the fill at 18% over the canvas */ +--fg-on-primary PENDING GRANT 1 /* white on a dark fill, the canvas ink on a light fill */ +--status-done var(--fg-1) /* UNBOUND from the accent. The brightest neutral */ +--status-empty var(--fg-4) /* ring track. 3.03:1 */ +--status-skip var(--fg-3) +--status-frozen var(--fg-2) /* NEUTRAL. See the note below */ +--status-overdue #FE9A00 /* 9.32:1, hue 65.4 */ +--status-bad #FB2C36 /* 5.23:1, hue 25.4 */ +--fg-on-bad #020618 --fg-on-overdue #020618 +--selection-bg the fill at alpha 0.32 +--scrim rgba(0,0,0,0.55) /* THE overlay backdrop. Theme-independent */ ``` -There is **no `--gradient-header`** and **no glow shadow** in the #539 target. Both are deleted, not softened. (Staging: both are still live on `main` behind the `no-decorative-glow` warn-to-error flip; see the Enforcement table and the spec-versus-main note in **Primitives kit**.) +**The canvas is near-black, not pure black.** `#09090B` is the measured Pierre value; Linear sits at `#08090a`. White on `#09090B` is 19.90:1 against 21.00:1 on pure black, so the contrast cost is 5% and the halation cost is real. Maximum contrast is a floor plus a legibility check, never "as much as possible". + +**The neutral ramp is effectively hueless and stays that way.** Every neutral carries chroma at or below 0.006, which is below the nameable threshold, and holds one hue end to end. A ramp that does not lean on the accent hue does not have to be re-derived the next time the accent moves, which is the cost this redesign just paid. -### Light mode (MANDATORY, ships with every surface, all 6 schemes) +**`--status-frozen` is retired as a hue.** Measured 2026-08-15: the old `#00D3F3` sits 12.0 degrees from the new accent, inside the 15-degree band where two hues read as one colour, so streak-freeze and done would have looked like the same state. **Frozen renders as a neutral chip plus the snowflake glyph.** This removes a colour from the system, and the no-colour-only rule already required the glyph. -The freeze re-anchored dark only. Light keeps its slate-50 canvas and opaque white cards, minus the deleted gradient and glow. Its hairline alphas (0.08 / 0.16 ink) now match dark's, so the two modes share one ring weight. +**`--status-done` is the second status neutralised, on the same precedent.** It was `var(--primary)`, so the brand colour and the completed state were one byte. Three rules in this document already forbade that: derivation rule 6's 15-degree separation, the accent note's "a static element rendered in the accent is as misleading as an interactive one rendered neutral", and "fill exactly one action per view", which a six-habit list with four done broke six times over. **Done now renders as an `--fg-1` disc with a filled check.** The neutral status ranking is done `--fg-1`, frozen `--fg-2`, skip `--fg-3`, empty `--fg-4`, so three neutral statuses can share one column and stay distinguishable. + +### Light mode (MANDATORY, ships with every surface) + +Light is first-class and dark is primary. After the scheme collapse the matrix is **two variants**. ``` ---bg #F8FAFC · --bg-card #FFFFFF (opaque white cards) · --bg-elev #FFFFFF · --bg-elev-2 #FFFFFF · --bg-sunk rgb(241,245,249) ---hairline rgba(2,6,24,0.08) · --hairline-ghost rgba(2,6,24,0.10) · --hairline-strong rgba(2,6,24,0.16) ---fg-1 rgb(15,23,43) · --fg-2 rgb(49,65,88) · --fg-3 rgb(98,116,142) · --fg-4 rgb(144,161,185) ---primary-soft = --primary /* on light, the accent is already dark enough to be text. See the accent split below. */ ---status-empty rgba(2,6,24,0.18) · skip rgb(98,116,142) · overdue rgb(225,113,0) · bad rgb(231,0,11) · frozen rgb(0,146,184) ---status-overdue-text rgb(180,91,0) · bad-text rgb(231,0,11) /* overdue darkened to AA; bad = base */ ---fg-on-bad rgb(255,255,255) · --fg-on-overdue rgb(2,6,24) ---selection-bg rgba(var(--primary-rgb),0.18) +--bg #FAFAFA · --bg-card #FFFFFF (opaque white cards) · --bg-elev #FFFFFF · --bg-elev-2 #FFFFFF +--bg-sunk rgba(9,9,11,0.04) +--bg-hover rgba(9,9,11,0.06) +--status-done var(--fg-1) · empty var(--fg-4) · skip var(--fg-3) · frozen var(--fg-2) /* the five neutrals resolve through the fg ramp in BOTH variants */ +--hairline rgba(9,9,11,0.08) · --border-control rgba(9,9,11,0.08) +--hairline-ghost rgba(9,9,11,0.10) · --hairline-strong rgba(9,9,11,0.16) +--fg-1 #1A1A1D /* 16.64:1 */ --fg-2 #424247 /* 9.57:1 */ +--fg-3 #68686D /* 5.31:1 */ --fg-4 #89898D /* 3.34:1 */ +--primary PENDING GRANT 1 /* light mode always takes the DARK fill, with white on it */ +--primary-soft PENDING GRANT 1 /* derived against #FAFAFA, not against the canvas */ +--fg-on-primary #FFFFFF +--selection-bg the fill at alpha 0.18 ``` -### The accent, and why `--primary` and `--primary-soft` are two tokens +**Dark is not light reversed.** Reversal is the starting point. Vividness comes down, the dark end needs more separation than the light end, and every pair is remeasured, because contrast is not symmetric. -This split is the single most-load-bearing token decision in the spec, and the accent-AA gate is designed to assert it directly (bundle 4 builds it; see `## Enforcement`). It exists because **no single violet can satisfy both floors at once, and the two floors contradict**: +**One switching mechanism.** A class (or the mobile mode value) is the switch, and `prefers-color-scheme` only sets the initial value. Never let a media query own some tokens and a class own others. -- "white text ON the accent >= 4.5:1" needs a **dark** accent, -- "the accent as small text ON the dark canvas >= 4.5:1" needs a **light** accent. +### The accent: the method is settled, the hue is open -Orbit resolves this by splitting the roles rather than compromising the color: +**The fill treatment is settled and the exact hue is not.** `design/reference.html` narrowed the field to two families; the final byte is chosen on the Claude Design canvas during the design-system build and written back here. Everything below holds whichever hue wins. -| token | role | floor | purple/dark measurement | -|---|---|---|---| -| `--primary` `#8659EA` | **fill and graphic only**: CTA background, FAB, progress ring, done dots, level bar, active tab | white on it >= 4.5 (text-on-fill) **and** it on canvas >= 3.0 (graphic) | white on `#8659EA` = **4.54:1** ✓ · `#8659EA` on `#070910` = **4.38:1** ✓ | -| `--primary-soft` `#B69BF8` | **accent text only**: an accent-colored word, link, or numeral on the canvas | it on canvas >= 4.5 (text) | `#B69BF8` on `#070910` = **8.6:1** ✓ | +**The shortlist is two, narrowed by Thomas on 2026-08-15.** Both take a dark fill with white on it: -Consequences that are not negotiable: +| candidate | value | note | +|---|---|---| +| **Warm orange** | `#C4530F` at hue 45, and neighbouring warm-orange values are still open | The tightest separation of any candidate: 20 degrees from both `--status-overdue` and `--status-bad`. It only clears because `--status-done` is unbound from the accent, which removed the frequent adjacency of a done ring beside an overdue chip | +| **Rose** | `#BF4D8A` at hue 350, **moved from 15** | At 15 it sat 10 degrees from the destructive red, so the primary CTA and the delete button read as the same colour. At 350 it clears by 35 and stays a pink-red | -- **`--primary` is never small text on the canvas.** If you want an accent-colored word, it is `--primary-soft`. This is why a blanket "primary as text >= 4.5" assertion would be wrong: it would false-fail a fill-only accent. -- White-on-`--primary` clears AA at **4.54:1**, a thin margin. A future accent change must re-measure, not eyeball. -- The accent is off the indicted Tailwind violet-500 (`#7f46f7`) deliberately. Do not drift back toward it. -- On light, the accent is already dark (`rgb(99,29,242)`, measured **6.71:1** on `#F8FAFC`), so `--primary-soft` equals `--primary` there. The split only bites where the accent must be light enough to read on a dark canvas. +**Emerald is discarded** (2026-08-15). Green binds the brand to a success verdict rather than to a state, and it places Orbit in the green-checkmark habit-tracker slot that `BRAND.md` names as a positioning failure. -**Accent rationing.** The accent appears on: the active tab, progress and ring indicators, done dots, the primary CTA, the FAB, and active nav. That is the whole list. It is **never** decorative on a card, a row, a border, a heading, or an icon that is not communicating state. Rationing is what makes the accent mean something. +**Three roles, three floors, and every candidate clears all three:** -**Fixing a failing contrast ratio:** move the **OKLCH L channel only**. Chroma has negligible effect on contrast, so hold C and H fixed and the color stays recognisably itself. This is the method that keeps a future scheme's AA fix from drifting the hue and re-opening the locked accent identity. +| token | role | floor | +|---|---|---| +| `--primary` | **fill and graphic only, and only for what is NEXT**: CTA background, FAB, progress toward an unfinished goal, level bar, active tab, active nav | `--fg-on-primary` on it >= 4.5 **and** it on canvas >= 3.0 | +| `--primary-soft` | **accent text only**: an accent-coloured word, link, or numeral on the canvas | it on canvas >= 4.5 | +| `--fg-on-primary` | whatever sits on the fill | 4.5 on the fill | -### Per-scheme accents (exact) +**One fill treatment, settled 2026-08-15: a dark fill with white on it.** `--primary` is the lightest value at which white still clears 4.5 on it, and `--fg-on-primary` is always `#FFFFFF`. The light-fill alternative, a bright fill carrying the canvas ink, was rendered and rejected by looking. -| Scheme | dark primary / pressed | light primary / pressed | -|---|---|---| -| purple | `#8659EA` / `#6E44D2` | rgb(99,29,242) / rgb(81,15,211) | -| blue | rgb(43,127,255) / rgb(21,93,252) | rgb(21,93,252) / rgb(20,71,230) | -| green | rgb(0,201,80) / rgb(0,166,62) | rgb(0,166,62) / rgb(0,130,54) | -| rose | rgb(255,32,86) / rgb(236,0,63) | rgb(236,0,63) / rgb(199,0,54) | -| orange | rgb(255,105,0) / rgb(245,73,0) | rgb(245,73,0) / rgb(202,53,0) | -| cyan | rgb(0,184,219) / rgb(0,146,184) | rgb(0,146,184) / rgb(0,117,149) | +Consequences that hold either way: -Each scheme needs its own `--primary-soft` satisfying the text floor on its canvas, derived from its primary by the L-only method above. Purple/dark is the only one measured to a byte so far (`#B69BF8`); the rest are derived and asserted by the accent-AA gate, per scheme, per mode. +- **`--primary` is never small text on the canvas.** Use `--primary-soft` for an accent word, even where the two resolve to the same byte. +- **A selected state may carry the accent on its glyph and label**, because selection is a live position rather than a finished one. **Completion is not selection**: a done row never takes the accent. +- A future accent change re-measures all three floors. It never eyeballs them. -### Shape, shadow, motion, icons +**Accent rationing.** The accent takes exactly **four roles**, and nothing outside them. The list is stated as roles rather than as components, because a component list reads as exhaustive and then silently contradicts the primitives table. -- Radii: 8 / 12 / 16 / 18 / 20, pill 999. Cards and habit tonal panels 18, emoji wells 12-14, fields 14, sheets **26 top radius**, CTAs pill. -- **Nested rounded surfaces use concentric radii: outer = inner + padding.** A parent and its inset child never share a radius. (The 46px emoji well at r14 inside a habit panel at r18 is the reference.) -- Shadows: hairline `0 0 0 0.5px rgba(255,255,255,0.06)`, sh-1 `0 1px 2px rgba(0,0,0,.20)`, sh-2 `0 4px 16px rgba(0,0,0,.28)`, sh-3 `0 12px 40px rgba(0,0,0,.45)`. **There is no primary glow.** Shadows model real occlusion under a lifted surface (sheets, menus); they are never a depth decoration and never carry the accent hue. -- Motion: `--ease-standard cubic-bezier(0.2,0,0,1)`, `--ease-out cubic-bezier(0.16,1,0.3,1)`, `--ease-in` for exits, durations 160/220/280. Transform + opacity only. Presets stay in `packages/shared/src/theme/motion.ts`. Full governance in **Motion** below. -- Icons: **Tabler**, always through the per-platform barrel `@/components/ui/icons` (never a direct `@tabler/*` import - the barrel wraps Tabler to a Lucide-compatible prop shape so a future set-swap is one file, and `no-restricted-imports` enforces this). strokeWidth **1.8** default, **2.2** active/emphasis, size 22 default. The barrel exports icons under their familiar Lucide names, so a swap of the underlying set never touches a callsite. The orange Sparkles ✨ is never an AI/default marker (identity is the Astra glyph); see Habit list. -- Hit targets: 44 min / 56 comfortable. See **Accessibility** for how to reach the minimum without growing the glyph. -- **Align optically, not geometrically.** Icons with directional or asymmetric mass (chevrons, play triangles, a leading plus, a glyph in a circular well) need a 1-2px nudge off mathematical center. This applies to the NavHeader back chevron, the ListRow trailing chevron, and PillButton leading icons. +| role | where it lands | +|---|---| +| **The next action** | the primary CTA, the FAB, an empty state's invitation arc | +| **Current position** | the active tab, active nav, a selected card or option including its tint and ring, a focused field's ring | +| **Progress toward something unfinished** | a progress bar or ring that has not completed | +| **Identity** | one element inside the logo mark, and only there | -### Derivation rules, per-scheme neutral tinting +**It never marks completion.** A progress ring at 100% goes neutral, a done row is neutral, and a streak total is a record rather than a next action. It is **never** decorative on a card, a row, a border, a heading, a static badge or chip, or an icon that is not communicating state. **Fill exactly one action per view.** Put the colour on the background, not the label: a filled button reads as primary, accent-coloured text on a neutral button reads as a link. -Each scheme tints the neutral ramp; dark/light/system per scheme; 12 variants total. +**One colour, one meaning, in both directions.** Treat two hues within 15 degrees as the same colour. A status hue inside that band of the accent must move or be retired. Equally, an interactive element rendered neutral is as misleading as a static element rendered in the accent. -1. **Alpha tokens are scheme-independent constants.** `--bg-card`, `--bg-elev`, `--bg-field`, `--bg-well`, `--bg-elev-2`, `--bg-sunk`, all three hairlines, `--status-empty` are white-alpha (dark) / ink-alpha (light) and inherit tint optically from the canvas beneath. They are identical across all 6 schemes. Preserve this mechanism; it is what makes the surface ladder cost nothing per scheme. -2. **Opaque neutrals re-derive per scheme via OKLCH:** convert each neutral (`--bg`, `--fg-1..4` dark; `--bg`, `--bg-sunk`, `--fg-1..4` light) to OKLCH; **lock L and C per token** (the ramp shape, shared by all schemes); hue varies per scheme. -3. **Per-scheme neutral hue:** purple = the hue of the frozen canvas `#070910`, which is neutral / faint-cool and deliberately **not** the old violet-tinted 265.1322. Other 5 schemes: `neutralHue(s) = accentHue(s) + Δ` where `Δ` is the purple offset, then hand-tuned per scheme so each looks intentional (clamp chroma in the 50-170° hue band to avoid murk). Every hand-tune is one documented line below. -4. **Acceptance (testable):** `createTokensV2('purple','dark')` and the web `.scheme-purple.dark` CSS resolve to exactly `#070910` bg, `#F6F7F9` fg-1, `#C7CBD2` fg-2, `#888E99` fg-3, `#565C67` fg-4, `#8659EA` primary, `#B69BF8` primary-soft. Light purple: `#F8FAFC` bg, white cards, `#0F172B` fg-1. A shared unit test asserts this. -5. **Status colors:** overdue/bad/frozen are fixed (not scheme-tinted) per mode. `--status-done = var(--primary)`. -6. **Primary-derived tints** (the accent at α = 0.08/0.10/0.12/0.15/0.18/0.28): web exposes `--primary-rgb` per scheme × mode; mobile gets `tintFromPrimary(tokens, alpha)` in `lib/theme.ts`. Never hardcode violet rgba in a component. There is no 0.45 glow alpha; it was deleted with the glow. -7. **Raw-slate mapping rule** (legacy porting): translate `--slate-200/300 → fg-2`, `--slate-400 → fg-3`, `--slate-500/600 → fg-4`, `rgba(248,250,252,α) → surface/hairline tokens`, literal `#fff` on primary → `fg-on-primary`. **Never copy a raw slate var into app code.** -8. **`color-scheme: light dark` is declared on the web document root**, with a matching `theme-color` meta, so scrollbars, native form controls, and the pre-CSS canvas follow the active mode. Without it the browser paints a light scrollbar against the dark canvas. Web only. +**Fixing a failing contrast ratio: move the OKLCH L channel only.** Chroma has negligible effect on contrast, so hold C and H and the colour stays recognisably itself. Remeasure after every change; never assume a fix landed. -**Recompute note for bundle 5:** the canvas, fg ramp, hairline alphas, and accent all moved in the 2026-07-17 freeze. The derivation *mechanism* above is unchanged, but every per-scheme byte and every AA measurement other than the purple/dark ones stated in this doc must be recomputed against the new anchors, and the hand-tune log re-verified line by line. Do not assume a pre-freeze byte still holds. +### Derivation rules -Hand-tune log (rule 3): +1. **Alpha tokens are constants.** The surface ladder and the hairlines are white-alpha on dark and ink-alpha on light. They inherit tint optically from the canvas beneath. This is what makes the ladder cost nothing. +2. **The ladder steps must be two visible surfaces.** Adjacent steps sit at least 0.02 alpha apart. The pre-D66 ladder ran 0.04 / 0.05 / 0.06 / 0.06, and `#151517` against `#171719` is not a step. +3. **Any surface that can sit over arbitrary content is authored opaque**, never as an alpha value. That is `--bg-elev`: the overlay panel, menus and popovers. +4. **Opaque neutrals re-derive in OKLCH**: lock C per token, hold one hue across the ramp, and step L evenly in perceived lightness. +5. **Both ends stop short of pure black and pure white.** Neither can carry hue. +6. **Status colours are fixed per mode**, are never accent-tinted, and each stays more than 15 degrees from the accent hue. **`--status-done` is inside this rule, not an exception to it.** It was `var(--primary)`, which is zero degrees from the accent, and that was the same defect that retired `--status-frozen`. +7. **Primary-derived tints** come from `--primary-rgb` (web) and `tintFromPrimary()` (mobile). Never hardcode an accent `rgba` in a component. +8. **`color-scheme: light dark` is declared on the web document root**, with a matching `theme-color` meta. Web only. +9. **`prefers-contrast: more` is a token-override layer, not a third variant.** Override only the tokens that carry the contrast: raise the surface ladder toward solid, give controls a defined border, and widen each foreground gap by at least 15 points of perceived lightness. Then remeasure against the preferred APCA thresholds. Widening without remeasuring is not a fix. +10. **Suppress transitions during a theme flip.** Inject `*,*::before,*::after{transition:none !important}`, force a synchronous style flush, then remove it on the next frame. Without it every colour transition fires at once and the switch smears. -- **Ramp hue drift (all schemes):** the neutral ramp is not iso-hue; each neutral token carries a hue offset relative to the canvas hue, preserved across schemes so purple stays byte-exact. This drift is deliberate. Do not "fix" it to constant hue. -- **green:** Δ-derived hue 121.74° sat in the 50-170° murk band (olive); nudged to 140 with chroma ×0.6 on both groups, giving cool sage instead of khaki. -- **orange:** Δ-derived hue 18.36° read rose-tinted; nudged to 32 (warm brown-black canvas). -- **blue / cyan:** canvas chroma gamut-clamped at the locked L; cyan's fg ramp also clamped. Values sit at the sRGB gamut ceiling ×0.985 so web CSS and the TS pipeline resolve identical bytes. -- **fg-on-primary (scheme × mode):** white fails 4.5:1 on green/orange/cyan (both modes) and blue/rose (dark), so those eight resolve to the canvas ink rgb(2,6,24). White stays on purple (both modes), blue light, rose light. Data in `color-schemes.ts` `fgOnPrimary`, mirrored per scheme × mode in `globals.css`. -- **fg-on-bad / fg-on-overdue (per mode):** the fixed bad / overdue fills flip lightness across modes, so their text flips too. `fg-on-bad` = canvas ink on the lighter dark-mode red, white on the deeper light-mode red; `fg-on-overdue` stays canvas ink in both modes (white fails AA on amber). +### Shape, shadow, motion, icons + +- Radii, enumerated: **0 / 8 / 12 / 16 / 20 / 28**, pill 999. Cards and habit panels 20, wells 12, fields 12, chips and badges 8, overlay panel **28 top radius**, CTAs pill. +- **The app and store icon radius is set by the platform mask** (the iOS superellipse, the Android adaptive mask) and is the one radius outside this scale. It never appears on a UI surface. +- **The pill radius means interactive.** Radius 999 is reserved for something you can press. A static element never wears it, because a non-clickable shape identical to the buttons beside it collects dead clicks. Badges and status chips use radius 8. +- **Nested rounded surfaces use concentric radii: outer = inner + padding.** A parent and its inset child never share a radius. Where the padding exceeds 24, treat the layers as separate surfaces and choose each radius independently. +- Shadows: sh-1 `0 1px 2px rgba(0,0,0,.20)`, sh-2 `0 4px 16px rgba(0,0,0,.28)`, sh-3 `0 12px 40px rgba(0,0,0,.45)`. **There is no glow.** Shadows model occlusion under a lifted surface and never carry a hue. +- **Shadows are for elevation, borders are for structure.** Replace a border that only faked depth with a shadow. Keep a border that divides content or marks a state: dividers, table boundaries, input outlines, selected and focus states. +- **On dark, the 1px ring does the work.** Layered depth shadows are barely visible on a near-black canvas, so a lifted surface reads from its inset hairline plus the scrim beneath it. Reserve sh-2 and sh-3 for surfaces genuinely floating over a scrim. +- **Images carry a 1px outline** at `rgba(255,255,255,0.10)` on dark and `rgba(0,0,0,0.10)` on light, with `outline-offset: -1px` so the ring hugs the corner radius. Never a tinted neutral, which reads as dirt on the image edge, and never `border`, which changes layout. +- Motion: `--ease-standard cubic-bezier(0.2,0,0,1)`, `--ease-out cubic-bezier(0.16,1,0.3,1)`, `--ease-in` for exits. Movement durations 160/220/280; hover durations 240 for a control and 380 for a surface. Transform and opacity only for movement. Full governance in **Motion**. + +#### Icons + +- **Tabler**, always through the per-platform barrel `@/components/ui/icons`. Never a direct `@tabler/*` or `lucide-*` import: the barrel wraps Tabler to one prop shape so a future set swap is one file. `no-restricted-imports` enforces this and **lands with #210**, which migrates the callsites as one sweep. +- **Sizes are the set's native grid: 16, 20, 24. The default is 24.** Tabler is drawn on a 24 grid (`width="24" height="24"`, read from the source 2026-08-15), so an off-grid size such as 22 renders with fractional scaling and looks soft. Inline with text, size to about 1em to 1.25em so the pair scales together. +- **Stroke matches the optical weight of the adjacent text**, on the 24 grid: `1.5` beside 400-weight text at 14 to 16, `2` beside 500 and 600, `2.5` beside 700 or an emphasised standalone glyph. Tabler's own default is 2. One stroke strategy per surface; never mix icon sets on one toolbar. +- **One SVG, recoloured per state.** Icons use `currentColor` and take their states from colour and opacity, never from separate assets. Strip any hardcoded `fill` on import. +- **Outline is the default; fill marks the active state.** Use the pair to communicate state, not interchangeably. +- **Every icon is legible at 16.** Test at the smallest size it renders. Prefer a simplified glyph for small contexts over scaled-down detail. +- **Align optically, not geometrically.** Icons with directional or asymmetric mass need a 1 to 2px nudge off mathematical centre. For a pill with a trailing icon, the icon-side padding is the text-side padding minus 2. +- **Under RTL, flip only direction-dependent glyphs**: navigation chevrons and arrows, text-block glyphs, send-style glyphs. Never flip logos, checkmarks, physical objects, or media playback controls. +- Hit targets: 44 minimum, 56 comfortable. See **Accessibility**. ## Type roles -Use the semantic classes (web `.t-*`) / shared role data (`packages/shared/src/theme/type-roles.ts`), not raw sizes. +Use the semantic roles, not raw sizes. + +| Role | Family | Size/Weight | Line-height | Tracking | Colour | +|---|---|---|---|---|---| +| hero | Space Grotesk | 60/600 | 1.05 | -0.025em | fg-1 | +| display | Space Grotesk | 44/600 | 1.05 | -0.02em | fg-1 | +| h1 | Geist Sans | 28/600 | 1.15 | -0.015em | fg-1 | +| h2 | Geist Sans | 22/500 | 1.20 | -0.01em | fg-1 | +| row | Geist Sans | 17/400 | 1.35 | 0 | fg-1 | +| body | Geist Sans | 16/400 | 1.55 | 0 | fg-1 | +| secondary | Geist Sans | 14/400 | 1.55 | +0.005em | fg-2 | +| meta | Geist Mono | 12/400 | 1.40 | +0.02em, tabular | fg-3 | +| eyebrow | Geist Mono | 12/500 | 1.20 | +0.08em, UPPERCASE | fg-3 | +| num | Space Grotesk | inherit/500 | 1 | 0, tabular | fg-1 | +| num-xl | Space Grotesk | 44/600 | 1 | -0.02em, tabular | fg-1 | -| Role | Family | Size/Weight | Extras | Color | -|---|---|---|---|---| -| eyebrow | Rubik | 12/500 | +0.08em, UPPERCASE | fg-3 | -| display | Rubik | 34/500 | tight tracking, lh 1.15 | fg-1 | -| hero | Inter | 60/700 | -0.02em, lh 1.15 | fg-1 | -| h1 | Rubik | 28/500 | tight, lh 1.3 | fg-1 | -| h2 | Rubik | 22/500 | -0.01em, lh 1.3 | fg-1 | -| row | Rubik | 18/400 | lh 1.3 | fg-1 | -| body | Rubik | 16/400 | lh 1.55 | fg-1 | -| secondary | Rubik | 14/400 | lh 1.55 | fg-2 | -| meta | Roboto | 12/400 | +0.02em, tabular | fg-3 | -| num | Roboto | inherit/500 | tabular | fg-1 | -| num-xl | Inter | 44/700 | -0.02em, lh 1, tabular | fg-1 | +**A visual type role is not a semantic level.** `display`, `h1` and `h2` are looks; `