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

` and `

` are structure. Choose them independently, do not skip heading levels, and keep one `h1` per surface. Never pick a heading element for its default size. -**A visual type role is not a semantic level.** `display`, `h1`, `h2` are looks; `

`, `

`, `

` are structure. Choose them independently: do not skip heading levels, keep one `h1` per surface, and never borrow a heading style for non-heading content. +**Heading sizes descend with level.** A subordinate heading never renders more prominently than its parent. A heading is never smaller than body text unless it is deliberately a label-style overline. + +**Tabular numerals on any value that changes.** A timer, a counter, a streak count and a price all shift layout as they update. + +**Text stays selectable**, including chrome. Use `user-select: none` only on a specific drag or gesture surface. ### Measure and wrapping -- **Cap body and prose measure at 45-75 characters, target ~65ch.** A paragraph never spans the full container. This is the strongest single consensus in the harvest and it bites hardest at the desktop breakpoint, where the main column is already capped at ~740px. The mobile 412px shell is naturally inside the band; the web desktop surfaces are where this must be applied explicitly. -- **Break an unbroken token only when running prose cannot otherwise fit its container.** Paragraphs, links, headings, list items, and inline code use `overflow-wrap: anywhere`; ordinary words keep their existing break points. Fenced code and tables preserve their line structure and scroll horizontally inside their own keyboard-reachable boxes. -- `text-wrap: balance` on headings, `text-wrap: pretty` on body and description copy. Skip both in long-form. **Never hand-break with `
`**: pt-BR rewraps anyway and the break lands mid-phrase. -- **A display or hero heading never exceeds 2-3 lines.** Test heading copy at every breakpoint in **both** locales. Fix an over-wrapping heading by widening the container and reducing the size, never by accepting the wrap. -- **Ration eyebrows: at most one per three sections, hero included.** When in doubt drop it; the headline alone carries the section. An eyebrow labels a section, it never enumerates one. Numbered meta-labels ("SECTION 01", "QUESTION 05") are banned outright: delete, do not restyle. +- **Cap body and prose measure at 45 to 75 characters, target about 65ch.** A paragraph never spans the full container. Tune line-height with the measure: a wider line needs more leading. +- **Break an unbroken token only when running prose cannot otherwise fit.** Paragraphs, links, headings, list items and inline code use `overflow-wrap: anywhere`. +- `text-wrap: balance` on headings, `text-wrap: pretty` on body and description copy. Skip both in long-form. **Never hand-break with `
`.** +- **A display or hero heading never exceeds 2 to 3 lines.** Test heading copy at every breakpoint in **both** locales. Fix an over-wrapping heading by widening the container and reducing the size, never by accepting the wrap. +- **Ration eyebrows: at most one per three sections, hero included.** An eyebrow labels a section, it never enumerates one. Numbered meta-labels are banned outright. +- **When text is truncated, keep the full value reachable** if the hidden text carries meaning. +- **Text aligns to the start edge.** Numbers in a table align to the trailing edge. Justified text does not exist in this interface. +- **Underlines take their metrics from the font**: `text-underline-position: from-font` and `text-decoration-thickness: from-font`, with `text-decoration-skip-ink: auto`. Only colour animates reliably on a real underline; build any other animated underline as its own element. ## Layout & spacing -- **A card is not a layout primitive.** Group with spacing and alignment first. A card earns its place only when its content is a genuinely separable, actionable object. This is the upstream rule that prevents nesting, and it is the mechanism behind the habit list's grouped tonal panel. -- **Name one focal element per view before building.** Make it win by size, weight, contrast, and surrounding whitespace, and deliberately demote everything else. Only one element animates prominently at a time. -- **At any decision point keep simultaneously-considered options at <= 4.** Top-level nav <= 5, form fields <= 4 per visual group, 1 primary plus 1-2 secondary actions with the rest in a menu. 5 to 7 requires grouping or progressive disclosure; 8+ is a defect. -- **Give flex and grid children `min-width: 0`** (and `min-height: 0` for grid) so long unbroken content shrinks instead of blowing out the track. **Never put a fixed width on a text container.** Budget 30-40% string expansion for pt-BR everywhere, not just on buttons. -- **Align shared elements across side-by-side cards to the same Y** (title, description, price, CTA) and pin each card's CTA to its bottom, so the buttons form one line regardless of content length above. Applies to the PlanCard row and the StatTile grid. -- **Build multi-column layouts with CSS Grid**, never flexbox percentage math (`w-[calc(33%-1rem)]`). Web only. -- **Structural hacks are banned:** negative margins undoing a parent's padding, escape-hatch `calc()`, and absolute positioning used to dodge layout flow. Fix the layout instead. -- **Respect `env(safe-area-inset-*)` on fixed elements**, using `max(, env(...))` so there is a floor, and ship `viewport-fit=cover`. This is load-bearing for the TabBar, the FAB, and the 26px-radius Sheet. -- **A sidebar uses the same background as the canvas**, never a different surface color. A hairline is the only separation it earns. Web only. +- **A card is not a layout primitive.** Group with space and alignment first. A card earns its place only when its content is a genuinely separable, actionable object. This is the upstream rule that stops eight identical rectangles. +- **Group with space, not lines**, in this order: negative space, then a background shape when a group must read as one unit, then a separator line as a last resort for dense data. When a separator is genuinely earned, keep it quiet, and **never pair it with a large gap: the gap already did the job**. +- **Keep controls distinct from content, in both directions.** An interactive element carries a background, a border, or a consistent control zone. A static element never wears control styling. +- **Name one focal element per view before building.** Make it win by size, weight, contrast and surrounding space, and demote everything else deliberately. Only one element animates prominently at a time. +- **At any decision point keep simultaneously-considered options at 4 or fewer.** Top-level nav 5 or fewer, form fields 4 or fewer per visual group, 1 primary plus 1 to 2 secondary actions with the rest in a menu. 5 to 7 needs grouping or progressive disclosure; 8 or more is a defect. +- **Give flex and grid children `min-width: 0`** so long unbroken content shrinks instead of blowing out the track. **Never put a fixed width or a fixed height on a text container**; use `max-width` plus wrapping, and `min-height` where a floor is needed. Plan for substantial pt-BR growth rather than one universal percentage. +- **Align to shared edges.** Every stray edge reads as noise. Use one spacing step per level of subordination. +- **Order by importance.** The most important content sits near the top and the leading edge. Within a row, identifying content leads and metadata and actions trail. Think in leading and trailing, never left and right, and use logical properties (`margin-inline-start`, `padding-inline-end`, `inset-inline-start`, `text-align: start`) for anything direction-dependent. +- **Hint at hidden content.** Progressive disclosure needs a visible affordance: let the next item peek 16 to 32px past the scroll edge, or show a disclosure control whose label states what is hidden. +- **Align shared elements across side-by-side cards to the same Y** and pin each card's CTA to its bottom, so the buttons form one line. +- **Build multi-column layouts with CSS Grid**, never flexbox percentage math. Prefer container queries so a component adapts to its column rather than the viewport. Web only. +- **Structural hacks are banned:** a negative margin undoing a parent's padding, an escape-hatch `calc()`, and absolute positioning used to dodge layout flow. +- **Respect `env(safe-area-inset-*)` on fixed elements**, using `max(, env(...))`, and ship `viewport-fit=cover`. +- **Content bleeds, controls float.** Backgrounds and media may reach the viewport edge; controls and text stay inside the layout margins and safe areas. +- **Breakpoints come from the content, not from device presets.** Hold the expanded layout while it genuinely fits and collapse late. Test the smallest and largest sizes first. +- **Never park a critical action where it can be clipped**: the bottom of a resizable pane, below the fold of a fixed-height overlay, behind the keyboard. If an overlay's body scrolls, its action row does not. +- **A sidebar uses the same background as the canvas.** A hairline is the only separation it earns. Web only. ## Primitives kit -> **Spec-versus-main note (2026-07-24):** this kit describes the #539 target. Details marked -> *(spec-only)* are ahead of `main` until the redesign's primitive tickets land: the `--scrim` token, -> PillButton's `caution` variant and `xs` size, `EmptyState.matchActionFooterWidth`, `InfoCard.tone`, -> ListRow's rule handoff (`settings-row` on `main` still draws its own default bottom hairline via -> `divider`), InfoCard's borderless body (`main` still paints an inset primary ring), StatTile's -> `--bg-card` fill and fixed value/label boxes (`main` is still on `--bg-field` with no -> truncation or clamp), and every "no glow" / "GradientTop is deleted" claim (`GradientTop`, -> `--gradient-header`, and the PillButton/FAB glow are still live on `main`, staged behind the -> `no-decorative-glow` warn-to-error flip in the Enforcement table). Everything else in the table -> exists on `main` today. +Web in `apps/web/components/`, mobile mirror in `apps/mobile/components/`: same name, same props, same behaviour. -Web in `apps/web/components/`, mobile mirror in `apps/mobile/components/`: same name, same props, same behavior. +**Expose component state as `data-*` attributes** so styling and tests hook onto state rather than class names. A boolean attribute is present or absent, never `"false"`. An enumerated attribute carries a string value. | Primitive | Key specs | Web | Mobile | |---|---|---|---| -| NavHeader | 56px, centered UPPERCASE Rubik 13/500 +0.09em title, back chevron 26/2.0, right slot help (40px circled, inset 1.5px hairline-strong ring) / close / share | `ui/app-bar.tsx` | `ui/app-bar.tsx` | -| SectionTitle | Rubik 20/500 -0.01em, 24/14 padding | `ui/section-label.tsx` | `ui/section-label.tsx` | -| ListRow | 16/20 padding, icon 22/1.8 in 26px slot, title Rubik 18/400, desc 14 fg-3, value + trailing + chevron 22 fg-4, **draws no rule of its own** *(spec-only, see note)*, danger=status-bad | `ui/settings-row.tsx` | `ui/settings-row.tsx` | -| SettingsGroup | the only owner of row separation: renders a hairline *between* adjacent rows and never after the last, so a rule can never trail into a section break or stack against a bordered element | `ui/settings-group.tsx` | `ui/settings-group.tsx` | -| Switch | 48×28 pill, 22px white thumb, on=primary / off=rgba(fg,0.16) | inside settings-row | inside settings-row | -| Radio/RadioRow | 24px, selected=primary fill + 9px white dot, else inset 2px fg-4 ring | `ui/select-check.tsx` | `ui/select-check.tsx` | -| Badge | pill 3/9px, 10.5/600 +0.06em UPPERCASE; tones violet/soft/outline/amber | `ui/badge.tsx` (+ pro-badge) | same | -| PillButton | pill CTA, 5 variants × 4 sizes off the shared `BUTTON_SIZES` geometry: primary (accent fill, **no glow** *(spec-only, see note)*) / secondary (fg-1 bg + canvas text) / ghost (inset 1.5px hairline-strong) / destructive (status-bad fill + fg-on-bad) / caution (status-overdue fill + fg-on-overdue); md = h50·26px pad·Rubik 16/500·18 icon·9 gap (default), sm = h40, xs = h38 (grounded desktop-sidebar Criar), lg = h56. Hugs content; caps ~360px at desktop. Full canon in **Buttons** | `ui/pill-button.tsx` | `ui/pill-button.tsx` | -| StatTile | radius 18, `--bg-card` + inset hairline ring, emoji 28, value Inter 24/700 held to one line in a 29px box (web truncates with an ellipsis, mobile shrinks the font to 0.7), label 15/20 fg-2 clamped to **2 lines inside a fixed 40px reservation** so side-by-side tiles keep one baseline when a longer pt-BR label wraps. Tile and both text boxes carry `min-width: 0` *(fill + overflow model spec-only, see note)* | `ui/stat-tile.tsx` | same | -| PlanCard | radius 18, selected = `--primary-dim` tint + inset 1.5px primary ring; price Inter 22/700 | `upgrade/plan-card.tsx` | same | -| InfoCard | radius 18, borderless tonal aside (**no ring** *(spec-only, see note)*); tone `quiet` (default, recedes) = `--bg-elev` bg + fg-3 icon, tone `accent` (focal call-out) = `rgba(var(--primary-rgb),0.14)` bg + `--primary-soft` icon; icon 22/1.8, 16/20 padding, gap 12 | `ui/info-card.tsx` | same | -| Field | min-height 54, radius 14, `--bg-field` + inset hairline, **visible persistent label** 14/500 fg-2 | `ui/field-input.tsx` | `ui/app-text-input.tsx` | -| OTP | 6 boxes 48×58, radius 14, `--bg-field`, active inset 2px primary, Roboto 26/500. Paste of a whole code MUST work | `ui/code-input.tsx` | `ui/code-input.tsx` | -| Sheet | backdrop `--scrim` (rgba(0,0,0,0.55)), panel 26px top radius, grabber 44×5 hairline-strong, title Rubik 24/500 | `ui/app-overlay.tsx` | `bottom-sheet-modal.tsx` | -| TabBar + FAB | top hairline, opaque canvas bg, **max 5 destinations**, icon 24 (active primary 2.2 / inactive fg-4 1.8), label 11; FAB 60px primary circle, ring `0 0 0 6px var(--bg)`, **no glow** *(spec-only, see note)* | `navigation/bottom-tab-bar.tsx` | `navigation/bottom-tab-bar.tsx` | -| Satellite | 96px empty-state glyph, fg-4 strokes + primary arc. The empty half of the **state triad** | `ui/satellite-glyph.tsx` | same (react-native-svg) | -| VerifiedBadge | scalloped check, `--primary-dim` disc | `ui/verified-badge.tsx` | same | -| ProgressBar | 8px pill track rgba(fg,0.08), primary fill | `ui/progress-bar.tsx` | same | -| ProgressRing | thin band, `innerRadius` 0.94 (~6px stroke), primary sweep on a fg-4 track | right rail / Today | same | -| HabitRow | inside a tonal panel: 46px emoji well radius 14 `--bg-well`, name Rubik 16/500, meta 13 fg-3 + streak flame, trailing 30px check ring, per-row `⋮` overflow menu | `habits/habit-row.tsx` | `habits/habit-row.tsx` | - -`GradientTop` is **deleted** in the #539 target. It backed the `--gradient-header` wash. *(Spec-only, see note: both are still live on `main`, staged behind the `no-decorative-glow` Enforcement row.)* +| NavHeader | 56px, centred UPPERCASE Geist Mono 13/500 +0.09em title, back chevron 24/2.0, right slot help / close / share | `ui/app-bar.tsx` | `ui/app-bar.tsx` | +| SectionTitle | Geist Sans 20/500 -0.01em | `ui/section-label.tsx` | `ui/section-label.tsx` | +| ListRow | icon 24/1.5 in a 28px slot, title Geist Sans 17/400, desc 14 fg-3, value + trailing chevron 24 fg-4, **draws no rule of its own**, danger = status-bad | `ui/settings-row.tsx` | `ui/settings-row.tsx` | +| SettingsGroup | the only owner of row separation: a hairline *between* adjacent rows, never after the last | `ui/settings-group.tsx` | `ui/settings-group.tsx` | +| Switch | 48x28 pill, 22px thumb, on = primary / off = `rgba(fg,0.16)` | inside settings-row | inside settings-row | +| Radio/RadioRow | 24px, selected = primary fill + 9px dot, else inset 2px fg-4 ring | `ui/select-check.tsx` | `ui/select-check.tsx` | +| Badge | **radius 8 chip, never a pill**, Geist Mono 10.5/500 +0.06em UPPERCASE, `text-box` trimmed; tones accent / soft / outline / caution | `ui/badge.tsx` | same | +| PillButton | pill CTA, radius 999, 5 variants x 4 sizes off shared `BUTTON_SIZES`. Full canon in **Buttons** | `ui/pill-button.tsx` | `ui/pill-button.tsx` | +| StatTile | radius 20, `--bg-card` + inset hairline ring, value Space Grotesk 24/600 tabular held to one line, label 14/20 fg-2 clamped to 2 lines in a fixed reservation | `ui/stat-tile.tsx` | same | +| PlanCard | radius 20, selected = `--primary-dim` tint + inset 1.5px primary ring; price Space Grotesk 22/600 | `upgrade/plan-card.tsx` | same | +| InfoCard | radius 20, borderless tonal aside, **one tone**: `--bg-elev` with an fg-3 icon. There is no accent variant, because a static informational card is not one of the four accent roles | `ui/info-card.tsx` | same | +| Field | min-height 54, radius 12, `--bg-field` + inset `--border-control`, **visible persistent label** 14/500 fg-2 | `ui/field-input.tsx` | `ui/app-text-input.tsx` | +| OTP | 6 boxes, radius 12, `--bg-field`, active inset 2px primary, Geist Mono 26/500, `type="text" inputmode="numeric"`, `autocomplete="one-time-code"`, `spellcheck="false"`. Paste of a whole code MUST work | `ui/code-input.tsx` | `ui/code-input.tsx` | +| Overlay | see **Overlay** | `ui/app-overlay.tsx` | `bottom-sheet-modal.tsx` | +| TabBar + FAB | top hairline, opaque canvas bg, **max 5 destinations**, icon 24 (active primary 2.0 / inactive fg-4 1.5), label 11; FAB 60px primary circle, ring `0 0 0 6px var(--bg)` | `navigation/bottom-tab-bar.tsx` | `navigation/bottom-tab-bar.tsx` | +| Satellite | 96px empty-state glyph, fg-4 strokes + primary arc | `ui/satellite-glyph.tsx` | same | +| ProgressBar | 8px pill track `--fg-4`, primary fill | `ui/progress-bar.tsx` | same | +| ProgressRing | thin band, primary sweep on a `--fg-4` track | right rail / Today | same | +| HabitRow | inside a tonal panel: 46px emoji well radius 12 `--bg-well`, name Geist Sans 16/500, meta 13 fg-3, trailing 30px status ring (done `--status-done` filled with a filled check, empty `--status-empty` track, overdue `--status-overdue` ring, frozen `--status-frozen` plus snowflake, skip `--status-skip`), per-row overflow menu | `habits/habit-row.tsx` | `habits/habit-row.tsx` | + +## Overlay + +One primitive covers the drawer, sheet, dialog and modal. It presents as a bottom sheet below the `sm` breakpoint and as a centred dialog at `sm` and above. **83 callers inherit whatever this primitive gets wrong**, which is why the rules below are mechanical. + +### The library, per platform + +| platform | library | read live 2026-08-15 | +|---|---|---| +| **web** | **`@base-ui/react`**, `Dialog` from the `./dialog` subpath | 1.7.0, MIT, `github.com/mui/base-ui`, peer `react ^19` | +| **mobile** | **`@lodev09/react-native-true-sheet`** | 3.11.3, already installed | + +**Web rationale.** `apps/web` ships no overlay library at all today. `app-overlay.tsx` hand-rolls the backdrop, the focus trap, the body-scroll lock, the overlay stack and the portal in one 375-line component that already carries a `no-giant-component` deferral. The root cause is visible in the markup: it renders `` rather than calling `showModal()`, so it gets none of the platform's free behaviour and has to rebuild the trap, the background inertness and the Escape handling by hand, and then needs `z-[9999]` because it never enters the top layer. Base UI's `Dialog` supplies all of that behind one `modal` prop, which is code standard 11 applied exactly. + +Adopting it deletes `apps/web/lib/overlay-stack.ts` and two live defects: the `z-[9999]` that violates this document's own arbitrary-z-index ban, and a grabber with no gesture handler, so web drag-to-dismiss is decorative today and nothing is lost by not replacing it. Anatomy to build against: `Dialog.Root` (`open` / `onOpenChange`), `Portal`, `Backdrop`, `Viewport`, `Popup` (`initialFocus` / `finalFocus`), `Title`, `Description`, `Close`. Note the peer dependency `@date-fns/tz ^1.2.0`, which `apps/web` does not yet have; `date-fns ^4.4.0` is already present. + +**Mobile rationale.** The library is not the defect, the wrapper is. TrueSheet handles detents, scroll coordination, the dimmed backdrop, the keyboard and the Android back button natively. `@gorhom/bottom-sheet` stays banned by `local/no-gorhom-sheet`, because its `present()` and portal no-op on the New Architecture in release builds. **Never navigate in the same tick as closing a sheet.** + +**Migrating the 83 callers is R-ticket work, not this ticket.** + +### Sizing + +- **An overlay is content-height by default.** It grows to its content and stops. +- **A fixed detent is allowed only where the content is genuinely long, and never as the default.** `bottom-sheet-modal.tsx` currently defaults to `['50%','80%']`, which is the defect: a short sheet opens with a third of the panel empty. +- An overlay never exceeds 85% of the viewport height. Past that it is a screen, not an overlay. + +### Scroll ownership + +- **Exactly one scroll container per overlay, owned by the primitive.** +- **A caller never nests its own scroll container inside a scrollable sheet.** The mobile wrapper currently passes TrueSheet's native `scrollable` AND renders its own `ScrollView` inside it. That is what makes the reset-account sheet open already scrolled past its own warning copy, with no way to scroll back up. +- **An overlay never opens scrolled away from its own first line.** +- `overscroll-behavior: contain` on the scroll container, so scrolling inside never scrolls the page behind it. + +### Anatomy + +Grabber (mobile presentation only) · header (title, optional description, close) · body (the single scroll container) · footer (actions, pinned, never scrolling with the body) · safe-area and keyboard insets on both. + +**The panel is `--bg-elev`, authored opaque.** An overlay sits over arbitrary content, so a translucent panel cannot have its text contrast checked. The backdrop is `--scrim`. Radius 28 at the top on mobile presentation, 20 all round at `sm` and above. + +### Dismissal contract + +- Escape, backdrop press, the close button and Android back all route through one `requestDismiss(reason)` path. Escape dismisses whatever opened last. +- A dirty form blocks interactive dismissal and routes to a confirm instead. It never discards silently. +- On open, set the background `inert`, then move focus into the panel. **For a destructive confirmation, focus the least destructive action.** +- On close, restore focus to the trigger. If the trigger is gone, move focus to the nearest logical container. +- The scrim is a control, not decoration, so it keeps its pointer events. + +### States + +All eight, by name: default · hover · focus · active · disabled · loading · error · empty. `design/reference.html` renders the overlay at **both short content and long content**, so the sizing and scroll rules are visible rather than described. ## Buttons -`PillButton` (`ui/pill-button.tsx`, mirrored web + mobile) is the one pill CTA. Its geometry is shared data in `packages/shared/src/theme/button.ts` (`BUTTON_SIZES`) so the two platform mirrors cannot drift. Over-wide and over-wordy buttons are the two AI-slop tells this rule kills. +`PillButton` is the one pill CTA. Its geometry is shared data in `packages/shared/src/theme/button.ts` so the two mirrors cannot drift. -- **Variants:** `primary` (accent fill, no glow), `secondary` (fg-1 fill, canvas text), `ghost` (transparent, inset 1.5px hairline-strong ring), `destructive` (status-bad fill, fg-on-bad text), `caution` (status-overdue amber fill, fg-on-overdue text - the reversible-danger sibling of `destructive`, for the account-reset / fresh-start action; full account deletion stays `destructive`). `ConfirmDialog` builds its paired action row from `PillButton` itself - `ghost` for cancel, `primary` / `destructive` for confirm - never a hand-rolled pill. -- **Sizes:** `xs` (h38, 18px pad, 14px label, 16 icon, 7 gap; the grounded desktop-sidebar Criar), `sm` (h40, 18px pad, 14px label, 16 icon, 7 gap), `md` (h50, 26px pad, 16px label, 18 icon, 9 gap; the default), `lg` (h56, 30px pad, 17px label, 20 icon, 10 gap). A size is a fixed height + horizontal padding + label / icon / gap set; never hand-tune per call. -- **Width, hug by default.** A pill sizes to its content. A lone CTA in a wide container caps at ~360px and never spans a desktop content column. Full-width (`fullWidth`, or a phone-shell stretch) is sanctioned ONLY in: (1) the single primary action of a mobile bottom-sheet or dialog, (2) a form submit at or below the mobile breakpoint (auth, onboarding, support, create flows), (3) a full-screen empty-state primary CTA. `ConfirmDialog`'s paired action row is also allowed. Everywhere else the pill hugs. `fullWidth` enforces this itself: it stretches below the `sm` breakpoint and **releases to intrinsic (hug) width at `sm` and up**, capped at ~360px and centred in both block and flex parents - so a callsite never needs a `self-center` or width patch, and passing `fullWidth` can never produce a desktop slab. -- **The one matched-width exception: a stacked action+footer CTA pair.** `EmptyState`'s `matchActionFooterWidth` prop (web + mobile) is the single sanctioned carve-out from hug-by-default, scoped exactly to the case of a primary pill stacked directly over a secondary/ghost pill as one visual unit (e.g. the habit-list empty state's "Ask Astra" over "Create"). When set, both pills size to the wider of the two instead of each hugging its own label - two pills of visibly different width read as an unrelated pair, not a matched set, and that mismatch is itself an AI-slop tell (#539 row 9). Mechanism: web uses a CSS grid wrapper (default `justify-items: stretch` sizes every track to its widest child); mobile sets the actions `View`'s `alignItems` to `stretch` instead of `center` (Yoga resolves the same way - container width from the widest child's intrinsic size, then all children stretch to it). This does **not** reopen hand-tuned widths generally - `PillButton` itself is untouched, the sizing happens only in the shared `EmptyState` wrapper, and only when a caller explicitly opts in with both an `action` and a `footer` pill present. -- **Labels, 1-2 words, action-first.** Strip words the surrounding dialog title or section header already carries ("Log all" becomes "Log", "Registrar todos" becomes "Registrar"). pt-BR runs longer than en, so the size scale must absorb the longer string without going full-bleed. -- **One label per CTA intent per surface, and the name survives the whole flow.** The button that says "Publish" produces "Published", never "Submit" for "Save changes". Nav, hero, and footer pointing at one action use one string. This has real teeth here: every string exists twice (`en.json` + `pt-BR.json`) and the shortening rule above is exactly when a label and its toast drift apart. -- **Icons, a leading glyph where it aids recognition** (create → plus, confirm → check, destructive → trash), sized and gapped from the size token. Icon-only pills MUST carry a localized `aria-label` / `accessibilityLabel`. No decorative icons. -- **Press feedback:** see **Motion**. A pill scales to 0.96-0.97 on pointer-down, never lower. +- **Variants:** `primary` (accent fill, `--fg-on-primary` text, no glow), `secondary` (fg-1 fill, canvas text), `ghost` (transparent, inset 1.5px hairline-strong ring), `destructive` (status-bad fill), `caution` (status-overdue fill). `ConfirmDialog` builds its action row from `PillButton`, never a hand-rolled pill. +- **Sizes:** `xs` / `sm` / `md` (default) / `lg`. A size is a fixed height plus horizontal padding plus label, icon and gap. Never hand-tune per call. +- **Width, hug by default.** A lone CTA in a wide container caps at about 360px and never spans a desktop content column. Full-width is sanctioned ONLY in: the single primary action of a mobile overlay, a form submit at or below the mobile breakpoint, and a full-screen empty-state primary CTA. +- **The one matched-width exception:** `EmptyState.matchActionFooterWidth`, for a primary pill stacked directly over a secondary pill as one visual unit. Two pills of visibly different width read as an unrelated pair, which is itself a slop tell. +- **Labels are verb-first and 1 to 2 words.** Strip words the surrounding title already carries. A confirmation button repeats the consequence, so the dialog is answerable without reading the body: "Delete habit", never "Yes". +- **One label per CTA intent per surface, and the name survives the whole flow.** +- **Keep submit enabled until the request starts**, then disable with a spinner and **keep the original label**, because the label is what tells assistive tech which button is busy. +- **Icons: a leading glyph where it aids recognition**, sized and gapped from the size token, with the icon-side padding 2px tighter. An icon-only pill MUST carry a localized accessible label. +- **Press feedback:** `scale(0.96)` on pointer-down over 150ms, `ease-out`, via a CSS transition so a release mid-press returns smoothly. A `static` prop disables it where motion would distract. ## Surface rules -- **Translucency ladder on dark:** 0.04 card / 0.05 field / 0.06 well / 0.10 elev-2, white-alpha over the canvas. Surfaces stack by alpha, not by lighter hexes. +- **Translucency ladder on dark:** 0.04 card / 0.06 field / 0.08 well / 0.12 elev-2, white-alpha over the canvas. Surfaces stack by alpha, not by lighter hexes. - **Opaque white cards on light.** Never translucent. -- **Inset 1px hairline rings instead of borders** (web `box-shadow: inset 0 0 0 1px var(--hairline)`; RN border with the same color reads equivalently). -- **No opaque card-on-card on dark.** -- **Minimal dividers.** Separate with whitespace or an inset ring first; a hairline rule is earned only between two adjacent flat rows. **Separation is the container's job, never the row's** - a row cannot know whether it is last or what follows it, so a row that draws its own rule inevitably trails one into a section break. Rows render flat; wrap them in `SettingsGroup` when adjacent rows earn a rule between them. Never stack two hairlines. -- **Blur and glass are never a default or a decoration.** A backdrop blur must be rare and purposeful or absent. An animated blur stays <= 8px, short, one-time, and never on a large surface. Orbit's ladder is an alpha ladder, not a blur ladder; with glow gone, backdrop-blur is the likeliest place decoration creeps back in. +- **Inset 1px hairline rings instead of borders.** +- **No opaque card-on-card on dark, and never a translucent surface stacked on another translucent surface.** Legibility collapses. +- **D34, one separation device per boundary.** Separation uses exactly ONE of a surface step, a hairline, or space. Never two. Space is the default choice among the three. +- **Separator and border are separate roles**, even though `--hairline` and `--border-control` carry the same value today. A separator divides content; a border encloses a control. They diverge the first time inputs are restyled. +- **Separation is the container's job, never the row's.** A row cannot know whether it is last, so a row that draws its own rule trails one into a section break. Never stack two hairlines. +- **Blur and glass are never a default or a decoration.** Orbit's ladder is an alpha ladder, not a blur ladder. An animated blur stays at 8px or less, short, one-time, and never on a large surface. +- **Do not use flat grey text on a translucent surface.** The whole dark ladder is translucency, so secondary text on a card takes the next weight step up rather than a dimmer colour. + +## Habit list + +- **Every top-level habit lives on its own tonal panel:** `--bg-card` plus an inset `--hairline-ghost` ring, radius 20. Single-row for a simple habit, multi-row for a family on ONE panel. +- **Panel row height matches across kinds.** A single-row panel is sized to the same row height as a family's parent row. +- The panel is the quietest step so the `--bg-well` emoji squares inside it read as the lighter elevation. +- **Two levels inline, then drill in.** An accent chevron opens in focus; a grey caret expands in place; a grey chevron is a collapsed family. Drilling shows a breadcrumb to climb back. +- **Sub-habit rows:** indent, smaller well, dimmer text. **Zero connector or tree lines.** +- **The per-row overflow menu stays.** +- **Habit emoji render in full colour.** +- **A row's status lives in its trailing ring, and never in the accent.** Done is an `--fg-1` disc with a filled check; empty is an `--status-empty` track; overdue takes `--status-overdue`; frozen is neutral plus the snowflake; skip is `--status-skip`. The accent enters this column only on progress toward something unfinished. +- **Never animate the habit list's data while the user is reading or acting on it.** + +## Listing + +How a collection renders at every size. This is checkable, not a taste call. + +| count | rendering | +|---|---| +| **0** | The empty state: Satellite glyph, one line naming what belongs here, one primary CTA. Never a blank region and never bare text. | +| **1** | The normal row treatment. Never a special "only one" layout. | +| **few (2 to 7)** | The normal list. No pagination, no search, no count. | +| **many (8 to 20)** | The normal list plus a count in `meta`. Add search only if the item names are user-authored. | +| **too many (21 or more)** | Virtualize or paginate, and show a persistent filter or search. **Never let a wrapping pill row carry it**: the checklist-template pills break at 15 today, and that is the defect this rule exists to stop. | -## Habit list (frozen treatment) +A collection whose item count can exceed 20 declares its "too many" behaviour before it ships. -The approved Today mockups define this exactly. It is the surface the whole design freeze was fought over. +## States -- **Every top-level habit lives on its own tonal panel:** `--bg-card` + an inset `--hairline-ghost` ring, radius 18. Single-row for a simple habit, multi-row for a family (parent + sub-habits on ONE panel). A childless habit is never a flat row sitting next to family panels. -- **Panel row height matches across kinds.** A single-row panel is sized to the same row height as a family's parent row: web ~70px (via zeroed panel vertical padding), mobile ~66px. -- The panel is `--bg-card` (the quietest step) so the `--bg-well` emoji squares inside it read as the lighter elevation. The panel recedes; the content does not. -- **Two levels inline, then drill in.** A node with children beyond level 2 shows a **violet `›`** (open in focus); a grey `⌄` means expand in place; a grey `›` means a collapsed family. Drilling makes the node the root and shows a breadcrumb (`‹ Water › Água da manhã`) to climb back: full width, always legible, unlimited depth. Reuses `onDrillInto` / `canDrillInto` in `habit-row.tsx`. -- **Sub-habit rows:** indent + smaller well + dimmer text. **Zero connector or tree lines.** Connector lines are an AI-slop tell. -- **The per-row `⋮` overflow menu stays.** The mockup omits it; the mockup is a mockup. The mockups define the visual language (surfaces, spacing, tokens, panel treatment, ring, layout), not the complete control set. Existing per-row affordances (kebab, swipe actions) are retained unless a decision explicitly removes them. -- **Habit emoji render in full color.** The mockup shows them monochrome only because the design tool tints emoji by their fill. Do not judge icon color from the mockup. The orange Sparkles ✨ used as the default/AI habit marker is removed; color emoji stay. -- **Never animate the habit list's data while the user is reading or acting on it.** See **Motion**. +**Every component ships its full state set before it is done: default, hover, focus, active, disabled, loading, error, empty, at capacity.** A missing state is an unfinished interface, not a follow-up. -## States +**Every data surface ships the loading / empty / error triad.** -**Every component ships its full state set before it is done: default, hover, focus, active, disabled, loading, error, empty.** A missing state is an unfinished interface, not a follow-up. This is where the two platform mirrors drift, so it is checked per diff. +- **Loading:** a skeleton for any operation expected to exceed about 300ms. Below that, show nothing rather than a flashing spinner. The skeleton is **shaped like the final layout** and occupies the final dimensions, so nothing shifts when data lands. Set `aria-busy="true"` on the updating region. +- **Empty:** a composed invitation to act. It says what this place is and how to fill it, with one clear next action. A search or filter empty state names the query and offers an exit. **Never park persistent information in an empty state**; it disappears the moment content exists. +- **Error:** see **Voice**. +- **Disabled:** use native `disabled` when a control is genuinely unavailable. Use `aria-disabled="true"` only when the control must stay focusable and discoverable, and then block activation in the handler and explain why nearby. Never set both. A natively disabled control cannot carry a tooltip, so the reason goes in visible text beside it. +- **At capacity (the ninth state):** the surface has reached a real boundary, for example a plan limit or a list ceiling. **A boundary is not an error**: it uses neutral tokens, never `--status-bad`. It states the limit and the one action that changes it, and it carries **no upgrade call to action**. -**Every data surface ships the loading / empty / error triad.** The Satellite glyph is the primitive that exists to serve the empty half; if you shipped a data surface with no empty state, you did not use it. +## Voice -- **Loading:** show a skeleton for any operation expected to exceed ~300ms. Below that, show nothing rather than a flashing spinner. The skeleton is **shaped like the final layout** and occupies the final content's dimensions, so nothing shifts when data lands. Never a generic centered spinner. -- **Empty:** a composed invitation to act (Satellite glyph + one line of copy + one primary CTA), never a blank region and never bare text. -- **Error:** see **Copy** below. +**Pillars.** Calm. No shame language on a missed day. No exclamation on success. Quiet celebration. Describe, never sell. "ADHD-friendly" is allowed; "treats ADHD" is never. -## Copy +**pt-BR register: "você", plain and warm, never corporate and never slang.** Address the reader directly. "Orbit" and "Astra" are never translated, and both carry `translate="no"` so auto-translation cannot garble them. -- **Prefer an icon over a text label wherever a universal glyph exists** (add, close, copy, share, edit, delete, search, back, more, settings). Icon-only controls MUST carry a localized accessible label in both locales. `BulkActionBarV2` is the house pattern. -- **Words are reserved for:** primary and destructive CTAs that commit something, menu and list rows, and actions with no established glyph. -- **Strings stay short:** 1-2 words on buttons, chips, tabs, and labels. Sentences live only in body, description, and empty-state copy. -- **Say it once.** No header restating the intro beneath it, no explanation in two places on one surface. Each element does exactly one job: a label labels, an example demonstrates, nothing quietly does double duty. -- **Name every control by what the person controls and recognizes, never by how the system is built.** A person manages notifications, not webhook config. This bites hardest on Orbit's implementation-flavored concepts (sync, streak freeze, MCP, retrospective). Describe rather than sell; specific beats clever. -- **Store copy in natural case and control presentation with `text-transform`.** Never type UPPERCASE into a string: every string is duplicated across `en.json` and `pt-BR.json`, so baked-in caps double the cost of any restyle and fork the locales. Headings and descriptions are sentence case. The only uppercase surfaces are the locked roles: eyebrow, NavHeader title, Badge. -- **Errors appear next to where the action happened**, in plain language answering what happened, why, and how to fix it. Never blame the user. Never use humor in an error. Never a bare code or "Invalid input". Use active voice. No exclamation mark on success. -- **Reserve confirmation dialogs for genuinely destructive, irreversible actions.** Prefer easy undo for slips. A confirmation names the specific action and its consequence ("Delete 'Água da manhã'? This can't be undone."), never "Are you sure?". A confirmation on a reversible action trains users to click through the ones that matter. -- **Never invent precise-looking numbers.** A figure either comes from real data, is explicitly marked mock in the markup, or does not ship. -- **"Orbit" and "Astra" are never translated.** No em dashes in copy: use a comma, a period, or a hyphen. -- **Astra's register:** calm, no em dash, no exclamation. +**Tone flexes with the stakes:** warm on success, onboarding and empty states; neutral on routine actions and settings; calm and plain on errors and destructive confirmations; serious and explicit on data loss. + +**The six Pierre rules**, extracted mechanically: + +1. **The product speaks in the first person.** Astra speaks: "Me diz o que você fez hoje. Eu registro." +2. **Short question, short answer.** +3. **A supporting line explains the MECHANISM, never restates the promise.** Every word is new. +4. **Genuinely colloquial** where pt-BR allows it. Never "otimize", never "potencialize". +5. **Zero exclamation marks.** Confidence, not euphoria. +6. **A CTA is a plain verb.** "Começar grátis", never "Comece sua jornada". + +### Rules + +- **Strings stay short:** 1 to 2 words on buttons, chips, tabs and labels. Sentences live only in body, description and empty-state copy. +- **Say it once.** No header restating the intro beneath it. Each element does exactly one job. +- **Ban supporting copy by default.** Do not add a subtitle, a helper line, or a descriptive sentence beneath a heading, a label, a card, or a settings row. Prefer one concise, self-explanatory heading. Add supporting copy only when Thomas asks for it, or when it genuinely prevents a misunderstanding or an error, and **never** to restate the heading above it. **The form-field carve-out is explicit and survives unchanged:** an input still carries a visible, persistent label, and its helper text still lives in the markup, per "a placeholder is never a field's only label" in **Accessibility**. The two rules are not in conflict, because a field label is not supporting copy. +- **Name every control by what the person controls**, never by how the system is built. +- **A settings toggle is labelled for its ON state.** "Send read receipts", never "Don't send read receipts". Link directly to a referenced setting rather than describing the path to it. +- **One capitalization policy per element type.** Sentence case is the default. **Store copy in natural case and control presentation with `text-transform`.** Never type UPPERCASE into a string. +- **A multi-step flow uses one vocabulary.** Pick "Continue" or "Next" and keep it. +- **Link text carries standalone meaning.** "Read the billing docs", never "Click here" or a bare "Learn more". +- **Errors say how to fix, next to where it broke.** Plain language, active voice, no blame, no humour, no bare code, no "Oops". Phrase hints positively and show them before the mistake. A validation error never clears the input. If the same error keeps firing, redesign the interaction rather than rewording it. +- **Reserve confirmation dialogs for genuinely destructive, irreversible actions.** A confirmation names the specific action and its consequence, never "Are you sure?". +- **Never invent precise-looking numbers.** A figure comes from real data, is explicitly marked mock in the markup, or does not ship. +- **Never build a sentence by concatenating fragments around a variable.** Word order changes per language. Use full templated strings with pluralization. +- **Match the verb to the input device**: "tap" on mobile, "click" on web with a pointer, "select" when both are possible. +- **No em dash and no en dash** anywhere in copy, code, or this document. Use a comma, a period, or a hyphen. + +### The banned-word set + +Enumerated and greppable, so `/deslop` can execute it over 2,905 i18n keys without a taste call. Each entry declares its scope. **microcopy** = i18n string values. **long-form** = the landing page, ADRs and store copy. **both** = everywhere. + +| # | pattern | scope | +|---|---|---| +| 1 | `!` on a success, completion or celebration string | both | +| 2 | "It's not X. It's Y." and every binary-contrast frame | long-form | +| 3 | "not just X, but Y" | long-form | +| 4 | Throat-clearing openers: "In today's world", "Let's face it", "The truth is" | long-form | +| 5 | Faux-insight setups: "What nobody tells you", "Here's the thing" | long-form | +| 6 | Colon reveals in a heading: "One habit: everything changes" | long-form | +| 7 | Importance puffery: "crucial", "pivotal", "vital", "essential", "game-changing" | both | +| 8 | "delve", "leverage" (verb), "harness", "unlock", "elevate", "empower", "supercharge", "seamless", "robust", "cutting-edge", "revolutionary" | both | +| 9 | Weasel attribution: "experts agree", "studies show", "science says" | both | +| 10 | Fake-strong verbs where a plain one exists: "utilize" for use, "commence" for start | both | +| 11 | Synonym cycling for one product noun across a surface | both | +| 12 | Negative listing: "no fluff, no gimmicks, no nonsense" | long-form | +| 13 | Dramatic one-word fragmentation: "Simple. Powerful. Yours." | long-form | +| 14 | Mechanical rule of three in a single sentence | long-form | +| 15 | Title-case headings | both | +| 16 | Boldface used for emphasis inside running body copy | long-form | +| 17 | Em dash and en dash | both | +| 18 | "Oops", "Uh oh", "Whoops" | both | +| 19 | "Are you sure?" as a confirmation body | microcopy | +| 20 | "Click here", "Read more", bare "Learn more" | both | +| 21 | "the user" in reader-facing copy | both | +| 22 | "otimize", "potencialize", "maximize" (pt-BR) | both | +| 23 | "Comece sua jornada" and journey framing | both | +| 24 | A descriptive subtitle restating the heading above it | both | +| 25 | "treats ADHD", "cures", "fixes your brain" | both | + +**The structural tells do not apply to UI microcopy.** A button label has no narrative shape. Scope explicitly stating the theme, dialogue used for philosophical debate, and flat event escalation to long-form only. ## Desktop density & orientation -- At the desktop breakpoint, content composes **horizontally**: multi-column layouts, side rails, and grids. A single stretched mobile column is a defect, not a layout. -- **The main content column caps at ~740px and is centered.** -- **Right rail (desktop), top to bottom:** progress ring · stats (Restantes / Sequência / Nível + bar / Conquistas) · Consistência 7-day mini bar chart · Próxima conquista progress module · Astra pill. The rail uses `justify-content: space-between` so the modules distribute evenly, the Astra pill pins to the bottom, and Próxima conquista sits directly above it. Rail gap 20; no redundant per-section top padding. -- **Sidebar:** grounded at the bottom with the account chip and a Criar button (height 38) above it, on the canvas background with a hairline as its only separation. -- Primary app sections are one click away in the desktop sidebar. A section reachable only through Profile is buried. -- **Never hide core functionality at a breakpoint**, and keep one information architecture across every context. Adapt the layout, not the feature set or the mental model. -- **Match a feature's flow shape to its neighbors**, not just its surface: the same progressive-disclosure depth, the same modal-vs-full-page and inline-vs-route and save-on-blur-vs-submit choices, the same conceptual weight given the same visual weight, the same nouns and verbs. -- **A modal is never the first thought.** Exhaust inline and progressive-disclosure alternatives before reaching for one. +- At the desktop breakpoint, content composes **horizontally**. A single stretched mobile column is a defect, not a layout. +- **The main content column caps at about 740px and is centred.** +- **The right rail's contents are re-decided on the Claude Design canvas.** Its existence is a sanctioned divergence; its module list is not frozen here. +- **Sidebar:** grounded at the bottom with the account chip and a create button above it, on the canvas background with a hairline as its only separation. +- Primary app sections are one click away in the desktop sidebar. +- **Never hide core functionality at a breakpoint**, and keep one information architecture across every context. Adapt the layout, not the feature set. +- **Match a feature's flow shape to its neighbours**, not just its surface. +- **A modal is never the first thought.** Exhaust inline and progressive-disclosure alternatives first. +- **Do not overload the entry point.** The first screenful is a table of contents, not the whole book. + +### The allowed shell divergences, enumerated + +Exactly these four, and nothing more. Everything below the shell stays parity-bound. + +1. Navigation chrome: sidebar (web) versus tab bar (mobile). +2. The desktop stats rail. +3. The command palette and keyboard shortcuts. +4. Hover affordances on that shell chrome. + +**Any new divergence found during canvas work comes back as a request, never as a judgement call.** ### Stacking -One semantic z-index scale, shared across both platforms. Overlays stack on a named tier, never a hand-picked number. Web reads it as `z-` utilities backed by the `--z-index-*` tokens (both in `app/globals.css`; they are explicit `@utility` blocks because Tailwind v4 has no z-index theme namespace); mobile reads `zLayers.` from `@orbit/shared/theme` (`packages/shared/src/theme/z-layers.ts`). Values are spaced by 100 (1000–1700) so they sit far above local stacking and leave room without inviting off-scale literals. +One semantic z-index scale, shared across both platforms. Overlays stack on a named tier, never a hand-picked number. -Six tiers ascend: `dropdown` (1000) < `sticky` (1100) < `modalBackdrop` (1200) < `modal` (1300) < `toast` (1600) < `tooltip` (1700). Plus two Orbit carve-outs a generic scale gets wrong: +Six tiers ascend: `dropdown` (1000) < `sticky` (1100) < `modalBackdrop` (1200) < `modal` (1300) < `toast` (1600) < `tooltip` (1700). Plus two carve-outs: -- **`celebration` (1500)** sits **just below `toast`**: an achievement/all-done/goal/streak-freeze overlay is modal-ish but transient, and a toast may still need to surface over it. -- **`tourSpotlight` (1400)** sits **above `modal`**, inverting the usual order, because a tour points AT modals. +- **`celebration` (1500)** sits just below `toast`. +- **`tourSpotlight` (1400)** sits above `modal`, because a tour points AT modals. -Local sibling stacking is a different thing and stays local: lifting content above a `::before`, ordering a sticky table header, sending a decoration behind its content. Use a small `z-[1..9]` / standard `z-{n}` utility (web) or `zIndex: 1..9` (mobile) for those - they are not overlays and have no tier on this scale. Android shadow `elevation` is a depth cue, orthogonal to stacking, and is unaffected. The one banned thing is the arms-race literal: an arbitrary `z-[9999]` or raw `zIndex: 10003` that means "add until it works." +Local sibling stacking stays local: `z-[1..9]` or `zIndex: 1..9`. The one banned thing is the arms-race literal. ## Sub-screen navigation -Every sub-screen (anything that is not a top-level tab or sidebar destination) shows a visible back affordance (NavHeader back chevron), on both platforms and at every breakpoint. Hardware or browser back is never the only way out. +Every sub-screen shows a visible back affordance, on both platforms and at every breakpoint. Hardware or browser back is never the only way out. + +**Wayfinding.** Every screen answers: where am I, where can I go, what is there, and how do I get out. + +**On a client-side route change**, update `document.title` to the new context (most specific first) and move focus to the new view's `h1` (given `tabindex="-1"`) or to `main`. Restore scroll position on back and forward navigation; scroll to top on forward navigation. Web only. ## Motion -Motion is governed on two axes: **whether** to animate, then **how**. The first axis is the one that was missing, and it subtracts. +Motion is governed on two axes: **whether** to animate, then **how**. The first axis subtracts. **This section carries verbatim from the pre-D66 spec (D66 decision 11). Spacious argues for less motion, not different rules.** ### Whether (the gate) -**Gate every animation on frequency.** How often does the user see this? +**Gate every animation on frequency.** | frequency | budget | |---|---| @@ -384,186 +590,273 @@ Motion is governed on two axes: **whether** to animate, then **how**. The first | occasional (modals, sheets, drawers, toasts, settings) | the standard scale below | | rare / first-run (onboarding, milestone, celebration) | the only place the delight budget lives | -**Every animation must name its purpose from this closed list:** feedback · spatial consistency · state indication · preventing a jarring change · explanation · delight (rare/first-run tier only). **If you cannot name it, delete it.** Apply the removal test: take it away; if nobody notices, it was decoration. +**Every animation must name its purpose from this closed list:** feedback · spatial consistency · state indication · preventing a jarring change · explanation · delight (rare/first-run tier only). **If you cannot name it, delete it.** -**Never animate data the user is trying to read or act on.** The habit list, streaks, and retrospective metrics are information-dense functional UI; motion there hinders. +**Never animate data the user is trying to read or act on.** **When motion is wrong, fix it in this order and stop at the earliest move that works:** (1) delete, (2) reduce, (3) fix the easing, (4) fix origin or physicality, (5) make it interruptible, (6) move it to the GPU, (7) make timing asymmetric, (8) polish. **Deleting outranks tuning.** -**Delight amplifies, never blocks.** Under ~1 second total, never delaying core functionality, always skippable, and rationed to earned moments (completion, a first-time action, a milestone crossing, error recovery). Never distributed across a surface. If the user notices the delight more than the goal, cut it. +**Delight amplifies, never blocks.** Under about 1 second total, never delaying core functionality, always skippable, and rationed to earned moments. + +**Motion is never the only feedback channel.** Every animated state change also carries a static cue: a colour change, an icon swap, or a label. + +**A high-frequency interaction gets instant feedback or an `opacity` / `background-color` transition of 150ms or less.** Never a full entrance on a hover or a keystroke. + +### Hover, and why it is not governed by the frequency gate + +**A clickable thing with no hover state is a defect.** A hover transition is feedback, not decoration, so the frequency gate does not suppress it: the gate subtracts animations that play at you, and a hover state answers you. Every interactive element carries one. + +**A hover reads slower than a press.** The durations below are deliberately longer than the movement scale: a press is a confirmation and wants to be immediate, while a hover is an invitation and wants to arrive. These values were set by feel against the rendered reference, not derived. Thomas chose the slowest of three rendered options on 2026-08-15. + +| target | duration | what changes | +|---|---|---| +| a surface (row, card, panel, list item) | **380ms** | background goes to `--bg-hover`, and its hairline to `--hairline-strong` | +| a control (button, chip, segmented control, icon button) | **240ms** | fill or label colour only | +| a link | **380ms** | colour, plus an underline scaling from the leading edge | + +- **Hover is its own surface role and is never borrowed from the elevation ladder.** The ladder's steps are sized for stacking, not for being seen against one particular resting surface. Measured 2026-08-15: `--bg-elev` against a resting `--bg-card` is **1.09:1**, which reads as nothing on a near-black canvas; `--bg-hover` is **1.31:1**, which reads. **A hover step must clear 1.25:1 against the surface it replaces.** If a role has no token, add the token rather than borrowing one whose value looks right today. +- **Only an interactive surface gets a hover state.** A static card that lights up under the pointer advertises a click that does nothing. This is the "controls distinct from content" rule read in the other direction, and it is the more common half to get wrong. +- **A container suppresses its own hover while the pointer is on an interactive descendant.** Otherwise a button inside a card lights both, and the pointer appears to be in two places at once. On web, `:hover:not(:has(button:hover, a:hover, [role="button"]:hover))`. +- **Declare the transition on the base rule, never inside `:hover`.** A transition declared inside `:hover` applies on the way in and not on the way out, so the state arrives smoothly and snaps away. That single mistake is most of what makes an interface feel cheap. +- **Hover moves exactly one step.** One surface level, one colour step. Two simultaneous changes read as a different component, not the same one under a pointer. +- **Hover never uses `transform`.** Transform belongs to press. A card that lifts on hover is the SaaS-template tell. +- **Name the properties.** Never `transition: all`. +- **Gate hover behind `@media (hover: hover) and (pointer: fine)`**, so a touch tap does not latch a hover state that then sticks until the next tap elsewhere. +- **Easing is `--ease-standard` in both directions** for a colour change. Reserve `--ease-out` for anything that moves. ### How -- **Transform and opacity only**, named explicitly. Durations 160 / 220 / 280. Presets in `packages/shared/src/theme/motion.ts`. Respect `prefers-reduced-motion`. -- **Quiet motion travels 10-20px, not 40px.** Shorten the distance before weakening the easing; when motion feels slow, shorten the duration before reaching for a sharper curve. Distance, not curve, is the difference between calm and dramatic at a fixed duration. -- **Motion must be interruptible.** Use CSS transitions (not keyframes) for anything rapidly re-triggered, retarget from the element's live presentation value on interrupt, and never lock out input while a transition runs. This is what makes rapid habit-toggling feel responsive rather than stuck. -- **Never animate an entrance from `scale(0)`.** Nothing appears from nothing. Enter at `scale(0.95)` + `opacity: 0`. -- **Exits are faster and softer than entrances** (~75% of the entrance duration), using a small fixed translate, never a full-height collapse. Slow where the user is deciding, fast where the system is responding. -- **Mirror `exit` against `initial`.** An element entering from `{ opacity: 0, y: 20 }` leaves to `{ opacity: 0, y: 20 }`, not to an unrelated transform. A reversible transition exits along the path it entered. -- **Easing by direction:** entrances ease out (`--ease-out`), exits ease in (`--ease-in`), view and mode transitions ease in-out, state changes use `--ease-standard`. -- **Linear easing is reserved for honest time representation** (progress bars, loading, scrub). A linear curve on a spatial move reads mechanical. Orbit has exactly the surfaces this exempts: ProgressBar and the ring sweep. -- **Springs vs curves, by the motion's origin:** user-driven motion (drag, flick, swipe) and anything interruptible mid-flight uses a spring, **critically damped by default** (damping ratio 1.0, response 0.3-0.4s), **no overshoot**. System-driven state changes and feedback use an eased curve with a fixed duration. There is no bounce and no elastic curve in Orbit. -- **Trigger-anchored overlays** (popover, dropdown, tooltip, menu) scale from their trigger via `transform-origin`, never from center. Modals are the exception and stay centered. -- **Directional slides are reserved for hierarchical navigation** (list → detail, the habit-list drill-in) and ordered sequences (prev/next, pagination), where direction encodes position. Lateral or unordered navigation (tab to tab) cross-fades or does not animate; a directional slide there falsely implies spatial depth. -- **Press feedback** is a transform `scale(0.96-0.97)` on `:active` / pressed over ~160ms, never below 0.95; any squash or stretch stays within 0.95-1.05. It **fires on pointer-down, never on release**, and stays continuous for the whole duration of a drag or sheet gesture rather than only when the gesture commits. -- **Gate hover-triggered motion behind `@media (hover: hover) and (pointer: fine)`**, never behind `(pointer: coarse)`, so a touch tap does not fire a false hover state. `apps/web` ships a 412px mobile-first shell used on touch, so this is live. Web only. -- **Stagger group entrances 30-80ms per item**, cap the total (~10 items × 50ms = 500ms), and never block interaction while it plays. -- **`AnimatePresence mode="wait"` runs exit and enter sequentially and nearly doubles perceived duration.** Halve each element's duration when using it, or a compliant 280ms preset silently becomes 560ms. Use `mode="popLayout"` for lists so exiting and entering items do not compete for layout space. -- **Pause looping animations when their element is off-screen.** This includes Orbit's own sanctioned loops (the `status-sweep` stroke-dashoffset keyframe, skeletons). -- **SVG `stroke-dashoffset` ring and progress sweeps are sanctioned** (paint-only, no layout). Derive `stroke-dasharray` from `path.getTotalLength()` (rounded up by 1) at runtime; never hardcode the dash length, or the stroke pre-reveals or over-draws. Animate the inner piece, not its container. +- **Transform and opacity only**, named explicitly. Durations 160 / 220 / 280. +- **Use CSS transitions for interactive state changes** so they retarget mid-flight. Reserve keyframes for one-shot staged sequences. +- **Quiet motion travels 10 to 20px, not 40px.** Shorten the distance before weakening the easing. +- **Motion must be interruptible.** Retarget from the element's live presentation value on interrupt, and never lock out input while a transition runs. +- **Never animate an entrance from `scale(0)`.** Enter at `scale(0.95)` plus `opacity: 0`. +- **Exits are faster and softer than entrances** (about 75% of the entrance duration), using a small fixed translate. +- **Mirror `exit` against `initial`.** A reversible transition exits along the path it entered. +- **Easing by direction:** entrances ease out, exits ease in, view and mode transitions ease in-out, state changes use `--ease-standard`. +- **Linear easing is reserved for honest time representation** (progress bars, loading, scrub). +- **Springs are critically damped, damping ratio 1.0, response 0.3 to 0.4s, no overshoot.** There is no bounce and no elastic curve in Orbit. This is a deliberate hardening of the source rule, which permits a bounce on momentum-carrying gestures; Orbit does not. +- **Trigger-anchored overlays** scale from their trigger via `transform-origin`, never from centre. Modals stay centred. +- **Directional slides are reserved for hierarchical navigation** and ordered sequences. Lateral navigation cross-fades or does not animate. +- **Press feedback** is `scale(0.96)` on pointer-down, never on release, and stays continuous for the whole duration of a drag or sheet gesture. +- **Respond on pointer-down, not on release.** Audit every debounce, artificial timer and transition wait on the input path. +- **Gate hover-triggered motion behind `@media (hover: hover) and (pointer: fine)`.** Web only. +- **Stagger group entrances 30 to 80ms per item**, cap the total at about 500ms, and never block interaction while it plays. Do not stagger a routine, high-frequency interaction. +- **`AnimatePresence mode="wait"` nearly doubles perceived duration.** Halve each element's duration when using it. Use `mode="popLayout"` for lists. +- **Use `initial={false}` so a stateful element does not animate its default state on first render**, and verify a full refresh still looks right; do not apply it where the component relies on `initial` for a genuine first-time entrance. +- **An icon state swap cross-fades** at `opacity` 0 to 1, `scale` 0.25 to 1, `blur` 4px to 0, with `bounce: 0`. Import from `motion/react`, which is what this repo installs. +- **Pause looping animations when their element is off-screen.** +- **SVG `stroke-dashoffset` ring and progress sweeps are sanctioned.** Derive `stroke-dasharray` from `path.getTotalLength()` at runtime; never hardcode the dash length. +- **Transition only what changes.** Always name exact properties. Never `transition: all`. +- **Use `will-change` sparingly**, only for `transform`, `opacity` and `filter`, and only when you observe first-frame stutter. ### Scroll reveals -- **A reveal never gates content visibility.** The pre-reveal state IS the visible state; the script only animates from it. Never ship CSS that hides content waiting on JS. A transition that never fires would otherwise ship a blank hero, which the hermetic screenshot gates would then bless. -- **A scroll reveal fires once per element** and is unobserved after firing. It never replays on scroll-back: a replaying reveal reads as a glitch, and live observers on every heading are a real cost. -- **Whole-section fade-and-rise on scroll is an AI-slop tell, not choreography.** Reserve scroll-triggered motion for moments that earn it. Never add page-load choreography. This names the landing page's `reveal.ts` directly, where the reveal is currently the entire motion budget. +- **A reveal never gates content visibility.** The pre-reveal state IS the visible state. +- **A scroll reveal fires once per element** and never replays on scroll-back. +- **Whole-section fade-and-rise on scroll is a slop tell, not choreography.** Never add page-load choreography. ## Accessibility -Orbit has no a11y gate today. This section is where it starts, and bundle 4 turns the checkable half into lint. **Automated accessibility testing covers only 30-50% of issues:** an axe or Lighthouse pass is a floor, never evidence of conformance, and a green Lighthouse a11y category is not a verdict. Keyboard and screen-reader checks stay manual. +The floor is **WCAG 2.2 Level AA**, and **WCAG is the gate while APCA is the tiebreaker above it**. **Automated testing covers only 30 to 50% of issues:** an axe or Lighthouse pass is an instrument reading, never a conformance verdict. Keyboard and screen-reader checks stay manual. + +**The test matrix is keyed `route-or-journey x state x viewport x input-or-AT mode`.** Cover authenticated, empty, populated, loading, success, validation-error, permission-error, dialog, menu, disclosure and toast states where they exist. For axe, select all five tags, because they are incremental: `wcag2a,wcag2aa,wcag21a,wcag21aa,wcag22aa`. + +| requirement | WCAG 2 AA | APCA preferred | +|---|---|---| +| Body text | 4.5:1 | Lc 90 | +| Large text (24px, or 18.5px bold) | 3:1 | Lc 60 | +| Non-text UI and graphics | 3:1 | Lc 30 | ### Perception -- **Never encode state or meaning in color alone.** Every status (done / overdue / bad / frozen / skip) must also be carried by an icon, a shape, a text label, or a position. Orbit's status language is currently dots and rings whose only differentiator is hue, across 6 schemes, which is unreadable to a colour-blind user. Contrast ratios do not fix this; redundancy of encoding does. -- **Non-text UI elements meet 3:1 contrast against their adjacent surface** (control borders, icons, focus rings, state indicators). Text stays at 4.5:1. This matters more now that glow is gone and hairlines carry the hierarchy. -- **Honour `prefers-reduced-transparency`** (raise surface opacity toward solid) and **`prefers-contrast: more`** (near-solid backgrounds plus a defined contrasting border). The whole dark surface ladder is white-alpha translucency, so reduced-transparency is directly load-bearing. -- **Never ship a full-viewport moving background, a slow ~0.2 Hz oscillation, or an abrupt light/dark brightness jump.** (The de-decorated base already forbids the first; this is the vestibular reason it also stays forbidden.) -- **When text is truncated** with `text-overflow: ellipsis` or `line-clamp`, keep the full value reachable (tooltip, expanded view) if the hidden text carries meaning. HabitRow names, ListRow descriptions, and friend names are all truncation candidates in a 412px shell. +- **Never encode state or meaning in colour alone.** Every status must also be carried by an icon, a shape, a text label, or a position. +- **Non-text UI elements meet 3:1 against their adjacent surface.** `--fg-4` is derived to exactly this floor at 3.03:1. +- **Measure the pair that actually renders**, not the page background, and remeasure in both modes. A pair that passes in light can fail in dark. +- **Honour `prefers-reduced-transparency`** (raise surface opacity toward solid) and **`prefers-contrast: more`** (see derivation rule 9). The whole dark ladder is white-alpha translucency, so reduced-transparency is directly load-bearing. +- **Never ship a full-viewport moving background, a slow oscillation near 0.2 Hz, or an abrupt light/dark brightness jump.** +- **When text is truncated, keep the full value reachable** if the hidden text carries meaning. ### Keyboard and focus -- **Every interactive element carries a visible `:focus-visible` state:** a 2px ring with an offset, painted from the accent token. Prefer `:focus-visible` over `:focus` so the ring appears only on keyboard navigation. A focus ring reads as a keyboard affordance, not decoration, so it survives the de-decorated base. **No surface may contain a keyboard trap.** -- **Never remove a focus outline** (`outline-none`, `outline: none`) without shipping a visible focus replacement in the same rule. -- **When a dialog, sheet, or overlay opens, move focus into it and trap focus inside; on close, restore focus to the trigger.** -- **Provide a "Skip to main content" link as the first focusable element on the web shell.** The desktop sidebar and tab bar put many links before content. It is visible on focus only, so the de-decorated base is untouched. Web only. -- **Never let a hit area overlap another interactive element's.** Reach the 44/56 minimum by expanding with an absolutely-positioned `::before` at a negative inset on a `position: relative` parent, not by growing the visible box and not by adding a wrapper that moves layout. Zero visual footprint. Web only for the pseudo-element mechanism; mobile uses `hitSlop`. -- **Never make functionality reachable only through hover.** A touch user cannot hover, so a hover-revealed action needs a tap-reachable equivalent. This is affordance reachability, distinct from gating hover *motion*. +- **Style `:focus-visible`, never bare `:focus`.** Prefer the browser's own indicator with `outline-offset: 2px`, because it adapts to platform and forced-colours settings. Use a custom 2px ring from the accent token only where the design requires it, and only after inspecting the whole perimeter against every adjacent colour it crosses. **No surface may contain a keyboard trap.** +- **Never remove a focus outline** without shipping a visible replacement in the same rule. In `forced-colors: active`, keep the default adjustment or use a system colour; never freeze the authored colour. +- **Use `:focus-within`** when a wrapper should light up while an inner input has focus. +- **Every pointer interaction needs a keyboard path**, following the ARIA APG patterns: Escape closes overlays, arrow keys move within composite widgets, Tab moves between widgets, Enter and Space activate. Only `tabindex="0"` and `tabindex="-1"`, never a positive value. Composite widgets use roving tabindex. +- **Provide a "Skip to main content" link as the first focusable element on the web shell**, and give in-page anchor targets `scroll-margin-top`. Web only. +- **The WCAG 2.5.8 AA baseline is a 24x24 CSS-pixel target. Orbit's product floor is higher: 44 minimum, 56 comfortable.** Reach it by expanding with an absolutely-positioned pseudo-element on the wrapping label or button, never on the input itself, and never by growing the visible box. Under the spacing exception, a 20px target needs at least a 4px gap. +- **Never let two hit areas overlap.** Shrink the expansion until they do not. +- **A decorative layer gets `pointer-events: none` and `aria-hidden="true"`**, so it never swallows clicks meant for the control beneath. A scrim that dismisses on click is a control and keeps its pointer events. +- **Add `touch-action: manipulation`** to interactive elements to remove the double-tap delay, and `touch-action: none` scoped to a surface implementing its own gestures. Set `-webkit-tap-highlight-color` to match the design. +- **Never make functionality reachable only through hover.** ### Semantics -- **Reach for semantic HTML before ARIA.** ARIA is the fallback when no native element carries the semantics, never the first solution. Web only. -- **Mark a purely decorative icon SVG `aria-hidden="true"`** so it is not announced. An icon that carries meaning gets a label on its control instead. This is the complement to the icon-only-control label rule: an icon sitting next to a text label should be silenced. +- **Reach for semantic HTML before ARIA.** `` navigates, ` - {menuOpen && ( -
e.stopPropagation()}> - - -
- -
- )} - - - - -
- {children ||
{id}
} -
- - ); -} - -// Inline rename — commits on blur or Enter. -function DCEditable({ value, onChange, style, tag = 'span', onClick }) { - const T = tag; - return ( - e.stopPropagation()} - onBlur={(e) => onChange && onChange(e.currentTarget.textContent)} - onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }} - style={style}>{value} - ); -} - -// ───────────────────────────────────────────────────────────── -// Focus mode — overlay one artboard; ←/→ within section, ↑/↓ across -// sections, Esc or backdrop click to exit. -// ───────────────────────────────────────────────────────────── -function DCFocusOverlay({ entry, sectionMeta, sectionOrder }) { - const ctx = React.useContext(DCCtx); - const { sectionId, artboard } = entry; - const sec = ctx.section(sectionId); - const meta = sectionMeta[sectionId]; - const peers = meta.slotIds; - const aid = artboard.props.id ?? artboard.props.label; - const idx = peers.indexOf(aid); - const secIdx = sectionOrder.indexOf(sectionId); - - const go = (d) => { const n = peers[(idx + d + peers.length) % peers.length]; if (n) ctx.setFocus(`${sectionId}/${n}`); }; - const goSection = (d) => { - // Sections whose artboards are all deleted have slotIds:[] — step past - // them to the next non-empty section so ↑/↓ doesn't dead-end. - const n = sectionOrder.length; - for (let i = 1; i < n; i++) { - const ns = sectionOrder[(((secIdx + d * i) % n) + n) % n]; - const first = sectionMeta[ns] && sectionMeta[ns].slotIds[0]; - if (first) { ctx.setFocus(`${ns}/${first}`); return; } - } - }; - - React.useEffect(() => { - const k = (e) => { - if (e.key === 'ArrowLeft') { e.preventDefault(); go(-1); } - if (e.key === 'ArrowRight') { e.preventDefault(); go(1); } - if (e.key === 'ArrowUp') { e.preventDefault(); goSection(-1); } - if (e.key === 'ArrowDown') { e.preventDefault(); goSection(1); } - }; - document.addEventListener('keydown', k); - return () => document.removeEventListener('keydown', k); - }); - - const { width = 260, height = 480, children } = artboard.props; - const [vp, setVp] = React.useState({ w: window.innerWidth, h: window.innerHeight }); - React.useEffect(() => { const r = () => setVp({ w: window.innerWidth, h: window.innerHeight }); window.addEventListener('resize', r); return () => window.removeEventListener('resize', r); }, []); - const scale = Math.max(0.1, Math.min((vp.w - 200) / width, (vp.h - 260) / height, 2)); - - const [ddOpen, setDd] = React.useState(false); - const Arrow = ({ dir, onClick }) => ( - - ); - - // Portal to body so position:fixed is the real viewport regardless of any - // transform on DesignCanvas's ancestors (including the canvas zoom itself). - return ReactDOM.createPortal( -
ctx.setFocus(null)} - onWheel={(e) => e.preventDefault()} - style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(24,20,16,.6)', backdropFilter: 'blur(14px)', - fontFamily: DC.font, color: '#fff' }}> - - {/* top bar: section dropdown (left) · close (right) */} -
e.stopPropagation()} - style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 72, display: 'flex', alignItems: 'flex-start', padding: '16px 20px 0', gap: 16 }}> -
- - {ddOpen && ( -
- {sectionOrder.filter((sid) => sectionMeta[sid].slotIds.length).map((sid) => ( - - ))} -
- )} -
-
- -
- - {/* card centered, label + index below — only the card itself stops - propagation so any backdrop click (including the margins around - the card) exits focus */} -
-
e.stopPropagation()} style={{ width: width * scale, height: height * scale, position: 'relative' }}> -
- {children ||
{aid}
} -
-
-
e.stopPropagation()} style={{ fontSize: 14, fontWeight: 500, opacity: .85, textAlign: 'center' }}> - {(sec.labels || {})[aid] ?? artboard.props.label} - {idx + 1} / {peers.length} -
-
- - go(-1)} /> - go(1)} /> - - {/* dots */} -
e.stopPropagation()} - style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8 }}> - {peers.map((p, i) => ( -
-
, - document.body, - ); -} - -// ───────────────────────────────────────────────────────────── -// Post-it — absolute-positioned sticky note -// ───────────────────────────────────────────────────────────── -function DCPostIt({ children, top, left, right, bottom, rotate = -2, width = 180 }) { - return ( -
{children}
- ); -} - -Object.assign(window, { DesignCanvas, DCSection, DCArtboard, DCPostIt }); - diff --git a/design/handoff/orbit/project/fig/fig-tokens.css b/design/handoff/orbit/project/fig/fig-tokens.css deleted file mode 100644 index 411cc2e32c..0000000000 --- a/design/handoff/orbit/project/fig/fig-tokens.css +++ /dev/null @@ -1,253 +0,0 @@ -/* Generated from Figma Variables. First mode = :root default. - FLOAT tokens are unitless — multiply by 1px in calc() where a length is needed. */ - -:root { - --amber-100: rgb(254,243,198); /* @kind color */ - --amber-200: rgb(254,230,133); /* @kind color */ - --amber-300: rgb(255,210,48); /* @kind color */ - --amber-400: rgb(255,185,0); /* @kind color */ - --amber-500: rgb(254,154,0); /* @kind color */ - --amber-50: rgb(255,251,235); /* @kind color */ - --amber-600: rgb(225,113,0); /* @kind color */ - --amber-700: rgb(187,77,0); /* @kind color */ - --amber-800: rgb(151,60,0); /* @kind color */ - --amber-900: rgb(123,51,6); /* @kind color */ - --amber-950: rgb(70,25,1); /* @kind color */ - --backdrop: rgba(0,0,0,0.2); /* @kind color */ - --black: rgb(0,0,0); /* @kind color */ - --blue-100: rgb(219,234,254); /* @kind color */ - --blue-200: rgb(190,219,255); /* @kind color */ - --blue-300: rgb(142,197,255); /* @kind color */ - --blue-400: rgb(81,162,255); /* @kind color */ - --blue-500: rgb(43,127,255); /* @kind color */ - --blue-50: rgb(239,246,255); /* @kind color */ - --blue-600: rgb(21,93,252); /* @kind color */ - --blue-700: rgb(20,71,230); /* @kind color */ - --blue-800: rgb(25,60,184); /* @kind color */ - --blue-900: rgb(28,57,142); /* @kind color */ - --blue-950: rgb(22,36,86); /* @kind color */ - --cyan-100: rgb(206,250,254); /* @kind color */ - --cyan-200: rgb(162,244,253); /* @kind color */ - --cyan-300: rgb(83,234,253); /* @kind color */ - --cyan-400: rgb(0,211,243); /* @kind color */ - --cyan-500: rgb(0,184,219); /* @kind color */ - --cyan-50: rgb(236,254,255); /* @kind color */ - --cyan-600: rgb(0,146,184); /* @kind color */ - --cyan-700: rgb(0,117,149); /* @kind color */ - --cyan-800: rgb(0,95,120); /* @kind color */ - --cyan-900: rgb(16,78,100); /* @kind color */ - --cyan-950: rgb(5,51,69); /* @kind color */ - --emerald-100: rgb(208,250,229); /* @kind color */ - --emerald-200: rgb(164,244,207); /* @kind color */ - --emerald-300: rgb(94,233,181); /* @kind color */ - --emerald-400: rgb(0,212,146); /* @kind color */ - --emerald-500: rgb(0,188,125); /* @kind color */ - --emerald-50: rgb(236,253,245); /* @kind color */ - --emerald-600: rgb(0,153,102); /* @kind color */ - --emerald-700: rgb(0,122,85); /* @kind color */ - --emerald-800: rgb(0,96,69); /* @kind color */ - --emerald-900: rgb(0,79,59); /* @kind color */ - --emerald-950: rgb(0,44,34); /* @kind color */ - --fuchsia-100: rgb(250,232,255); /* @kind color */ - --fuchsia-200: rgb(246,207,255); /* @kind color */ - --fuchsia-300: rgb(244,168,255); /* @kind color */ - --fuchsia-400: rgb(237,106,255); /* @kind color */ - --fuchsia-500: rgb(225,42,251); /* @kind color */ - --fuchsia-50: rgb(253,244,255); /* @kind color */ - --fuchsia-600: rgb(200,0,222); /* @kind color */ - --fuchsia-700: rgb(168,0,183); /* @kind color */ - --fuchsia-800: rgb(138,1,148); /* @kind color */ - --fuchsia-900: rgb(114,19,120); /* @kind color */ - --fuchsia-950: rgb(75,0,79); /* @kind color */ - --gray-100: rgb(243,244,246); /* @kind color */ - --gray-200: rgb(229,231,235); /* @kind color */ - --gray-300: rgb(209,213,220); /* @kind color */ - --gray-400: rgb(153,161,175); /* @kind color */ - --gray-500: rgb(106,114,130); /* @kind color */ - --gray-50: rgb(249,250,251); /* @kind color */ - --gray-600: rgb(74,85,101); /* @kind color */ - --gray-700: rgb(54,65,83); /* @kind color */ - --gray-800: rgb(30,41,57); /* @kind color */ - --gray-900: rgb(16,24,40); /* @kind color */ - --gray-950: rgb(3,7,18); /* @kind color */ - --green-100: rgb(220,252,231); /* @kind color */ - --green-200: rgb(185,248,207); /* @kind color */ - --green-300: rgb(123,241,168); /* @kind color */ - --green-400: rgb(5,223,114); /* @kind color */ - --green-500: rgb(0,201,80); /* @kind color */ - --green-50: rgb(240,253,244); /* @kind color */ - --green-600: rgb(0,166,62); /* @kind color */ - --green-700: rgb(0,130,54); /* @kind color */ - --green-800: rgb(1,102,48); /* @kind color */ - --green-900: rgb(13,84,43); /* @kind color */ - --green-950: rgb(3,46,21); /* @kind color */ - --indigo-100: rgb(224,231,255); /* @kind color */ - --indigo-200: rgb(198,210,255); /* @kind color */ - --indigo-300: rgb(163,179,255); /* @kind color */ - --indigo-400: rgb(124,134,255); /* @kind color */ - --indigo-500: rgb(97,95,255); /* @kind color */ - --indigo-50: rgb(238,242,255); /* @kind color */ - --indigo-600: rgb(79,57,246); /* @kind color */ - --indigo-700: rgb(67,45,215); /* @kind color */ - --indigo-800: rgb(55,42,172); /* @kind color */ - --indigo-900: rgb(49,44,133); /* @kind color */ - --indigo-950: rgb(30,26,77); /* @kind color */ - --lime-100: rgb(236,252,202); /* @kind color */ - --lime-200: rgb(216,249,153); /* @kind color */ - --lime-300: rgb(187,244,81); /* @kind color */ - --lime-400: rgb(154,230,0); /* @kind color */ - --lime-500: rgb(124,207,0); /* @kind color */ - --lime-50: rgb(247,254,231); /* @kind color */ - --lime-600: rgb(94,165,0); /* @kind color */ - --lime-700: rgb(73,125,0); /* @kind color */ - --lime-800: rgb(60,99,0); /* @kind color */ - --lime-900: rgb(53,83,14); /* @kind color */ - --lime-950: rgb(25,46,3); /* @kind color */ - --misc-surface-component: rgba(248,250,252,0.12); /* @kind color */ - --misc-surface: rgba(248,250,252,0.06); /* @kind color */ - --neutral-100: rgb(245,245,245); /* @kind color */ - --neutral-200: rgb(229,229,229); /* @kind color */ - --neutral-300: rgb(212,212,212); /* @kind color */ - --neutral-400: rgb(161,161,161); /* @kind color */ - --neutral-500: rgb(115,115,115); /* @kind color */ - --neutral-50: rgb(250,250,250); /* @kind color */ - --neutral-600: rgb(82,82,82); /* @kind color */ - --neutral-700: rgb(64,64,64); /* @kind color */ - --neutral-800: rgb(38,38,38); /* @kind color */ - --neutral-900: rgb(23,23,23); /* @kind color */ - --neutral-950: rgb(10,10,10); /* @kind color */ - --orange-100: rgb(255,237,212); /* @kind color */ - --orange-200: rgb(255,214,168); /* @kind color */ - --orange-300: rgb(255,184,106); /* @kind color */ - --orange-400: rgb(255,137,4); /* @kind color */ - --orange-500: rgb(255,105,0); /* @kind color */ - --orange-50: rgb(255,247,237); /* @kind color */ - --orange-600: rgb(245,73,0); /* @kind color */ - --orange-700: rgb(202,53,0); /* @kind color */ - --orange-800: rgb(159,45,0); /* @kind color */ - --orange-900: rgb(126,42,12); /* @kind color */ - --orange-950: rgb(68,19,6); /* @kind color */ - --pink-100: rgb(252,231,243); /* @kind color */ - --pink-200: rgb(252,206,232); /* @kind color */ - --pink-300: rgb(253,165,213); /* @kind color */ - --pink-400: rgb(251,100,182); /* @kind color */ - --pink-500: rgb(246,51,154); /* @kind color */ - --pink-50: rgb(253,242,248); /* @kind color */ - --pink-600: rgb(230,0,118); /* @kind color */ - --pink-700: rgb(198,0,92); /* @kind color */ - --pink-800: rgb(163,0,76); /* @kind color */ - --pink-900: rgb(134,16,67); /* @kind color */ - --pink-950: rgb(81,4,36); /* @kind color */ - --purple-100: rgb(243,232,255); /* @kind color */ - --purple-200: rgb(233,212,255); /* @kind color */ - --purple-300: rgb(218,178,255); /* @kind color */ - --purple-400: rgb(194,122,255); /* @kind color */ - --purple-500: rgb(173,70,255); /* @kind color */ - --purple-50: rgb(250,245,255); /* @kind color */ - --purple-600: rgb(152,16,250); /* @kind color */ - --purple-700: rgb(130,0,219); /* @kind color */ - --purple-800: rgb(110,17,176); /* @kind color */ - --purple-900: rgb(89,22,139); /* @kind color */ - --purple-950: rgb(60,3,102); /* @kind color */ - --red-100: rgb(255,226,226); /* @kind color */ - --red-200: rgb(255,201,201); /* @kind color */ - --red-300: rgb(255,162,162); /* @kind color */ - --red-400: rgb(255,100,103); /* @kind color */ - --red-500: rgb(251,44,54); /* @kind color */ - --red-50: rgb(254,242,242); /* @kind color */ - --red-600: rgb(231,0,11); /* @kind color */ - --red-700: rgb(193,0,7); /* @kind color */ - --red-800: rgb(159,7,18); /* @kind color */ - --red-900: rgb(130,24,26); /* @kind color */ - --red-950: rgb(70,8,9); /* @kind color */ - --rose-100: rgb(255,228,230); /* @kind color */ - --rose-200: rgb(255,204,211); /* @kind color */ - --rose-300: rgb(255,161,173); /* @kind color */ - --rose-400: rgb(255,99,126); /* @kind color */ - --rose-500: rgb(255,32,86); /* @kind color */ - --rose-50: rgb(255,241,242); /* @kind color */ - --rose-600: rgb(236,0,63); /* @kind color */ - --rose-700: rgb(199,0,54); /* @kind color */ - --rose-800: rgb(165,0,54); /* @kind color */ - --rose-900: rgb(139,8,54); /* @kind color */ - --rose-950: rgb(77,2,24); /* @kind color */ - --sky-100: rgb(223,242,254); /* @kind color */ - --sky-200: rgb(184,230,254); /* @kind color */ - --sky-300: rgb(116,212,255); /* @kind color */ - --sky-400: rgb(0,188,255); /* @kind color */ - --sky-500: rgb(0,166,244); /* @kind color */ - --sky-50: rgb(240,249,255); /* @kind color */ - --sky-600: rgb(0,132,209); /* @kind color */ - --sky-700: rgb(0,105,168); /* @kind color */ - --sky-800: rgb(0,89,138); /* @kind color */ - --sky-900: rgb(2,74,112); /* @kind color */ - --sky-950: rgb(5,47,74); /* @kind color */ - --slate-100: rgb(241,245,249); /* @kind color */ - --slate-200: rgb(226,232,240); /* @kind color */ - --slate-300: rgb(202,213,226); /* @kind color */ - --slate-400: rgb(144,161,185); /* @kind color */ - --slate-500: rgb(98,116,142); /* @kind color */ - --slate-50: rgb(248,250,252); /* @kind color */ - --slate-600: rgb(69,85,108); /* @kind color */ - --slate-700: rgb(49,65,88); /* @kind color */ - --slate-800: rgb(29,41,61); /* @kind color */ - --slate-900: rgb(15,23,43); /* @kind color */ - --slate-950: rgb(2,6,24); /* @kind color */ - --stone-100: rgb(245,245,244); /* @kind color */ - --stone-200: rgb(231,229,228); /* @kind color */ - --stone-300: rgb(214,211,209); /* @kind color */ - --stone-400: rgb(166,160,155); /* @kind color */ - --stone-500: rgb(121,113,107); /* @kind color */ - --stone-50: rgb(250,250,249); /* @kind color */ - --stone-600: rgb(87,83,77); /* @kind color */ - --stone-700: rgb(68,64,59); /* @kind color */ - --stone-800: rgb(41,37,36); /* @kind color */ - --stone-900: rgb(28,25,23); /* @kind color */ - --stone-950: rgb(12,10,9); /* @kind color */ - --teal-100: rgb(203,251,241); /* @kind color */ - --teal-200: rgb(150,247,228); /* @kind color */ - --teal-300: rgb(70,236,213); /* @kind color */ - --teal-400: rgb(0,213,190); /* @kind color */ - --teal-500: rgb(0,187,167); /* @kind color */ - --teal-50: rgb(240,253,250); /* @kind color */ - --teal-600: rgb(0,150,137); /* @kind color */ - --teal-700: rgb(0,120,111); /* @kind color */ - --teal-800: rgb(0,95,90); /* @kind color */ - --teal-900: rgb(11,79,74); /* @kind color */ - --teal-950: rgb(2,47,46); /* @kind color */ - --transparent: rgba(0,0,0,0); /* @kind color */ - --violet-100: rgb(229,213,255); /* @kind color */ - --violet-200: rgb(205,176,254); /* @kind color */ - --violet-300: rgb(180,140,252); /* @kind color */ - --violet-400: rgb(154,105,250); /* @kind color */ - --violet-500: rgb(127,70,247); /* @kind color */ - --violet-50: rgb(252,250,255); /* @kind color */ - --violet-600: rgb(99,29,242); /* @kind color */ - --violet-700: rgb(81,15,211); /* @kind color */ - --violet-800: rgb(66,14,166); /* @kind color */ - --violet-900: rgb(50,12,122); /* @kind color */ - --violet-950: rgb(34,9,79); /* @kind color */ - --white: rgb(255,255,255); /* @kind color */ - --yellow-100: rgb(254,249,194); /* @kind color */ - --yellow-200: rgb(255,240,133); /* @kind color */ - --yellow-300: rgb(255,223,32); /* @kind color */ - --yellow-400: rgb(253,199,0); /* @kind color */ - --yellow-500: rgb(240,177,0); /* @kind color */ - --yellow-50: rgb(254,252,232); /* @kind color */ - --yellow-600: rgb(208,135,0); /* @kind color */ - --yellow-700: rgb(166,95,0); /* @kind color */ - --yellow-800: rgb(137,75,0); /* @kind color */ - --yellow-900: rgb(115,62,10); /* @kind color */ - --yellow-950: rgb(67,32,4); /* @kind color */ - --zinc-100: rgb(244,244,245); /* @kind color */ - --zinc-200: rgb(228,228,231); /* @kind color */ - --zinc-300: rgb(212,212,216); /* @kind color */ - --zinc-400: rgb(159,159,169); /* @kind color */ - --zinc-500: rgb(113,113,123); /* @kind color */ - --zinc-50: rgb(250,250,250); /* @kind color */ - --zinc-600: rgb(82,82,92); /* @kind color */ - --zinc-700: rgb(63,63,71); /* @kind color */ - --zinc-800: rgb(39,39,42); /* @kind color */ - --zinc-900: rgb(24,24,27); /* @kind color */ - --zinc-950: rgb(9,9,11); /* @kind color */ -} diff --git a/design/handoff/orbit/project/fig/fig-typography.css b/design/handoff/orbit/project/fig/fig-typography.css deleted file mode 100644 index 822d0df24f..0000000000 --- a/design/handoff/orbit/project/fig/fig-typography.css +++ /dev/null @@ -1 +0,0 @@ -/* No TEXT/EFFECT styles in this file. */ diff --git a/design/handoff/orbit/project/orbit-app-components.jsx b/design/handoff/orbit/project/orbit-app-components.jsx deleted file mode 100644 index 1af0f091d8..0000000000 --- a/design/handoff/orbit/project/orbit-app-components.jsx +++ /dev/null @@ -1,561 +0,0 @@ -// ============================================================ -// Orbit — App-wide chrome (Linear-style redirect) -// Bottom nav with Plus FAB · Pull-quote · -// Confirm dialog · Toast · Streak badge · etc. -// ============================================================ - -// ─── Bottom nav: 4 tabs + centered Plus FAB ───────────────── -function BottomNav({ active = "today", onTab, onFab, astraUnread = false, showFab = true }) { - const tabs = [ - { id: "today", label: "Home", icon: "home" }, - { id: "chat", label: "Astra", icon: "sparkles", emphasize: true }, - { id: "calendar", label: "Calendar", icon: "calendar-days" }, - { id: "profile", label: "You", icon: "user" }, - ]; - return ( -
- {showFab && active !== "chat" && active !== "profile" && ( - - )} -
- {tabs.slice(0, 2).map(t => onTab && onTab(t.id)} unread={t.id === "chat" && astraUnread} />)} -
- {tabs.slice(2, 4).map(t => onTab && onTab(t.id)} />)} -
-
- ); -} - -function TabBtn({ tab, active, onClick, unread }) { - return ( - - ); -} - -// ─── Streak badge for app bar ─────────────────────────────── -function StreakBadge({ streak = 47 }) { - if (streak < 1) return null; - const active = streak >= 2; - return ( - - ); -} - -// ─── Theme + bell utility cluster ─────────────────────────── -function UtilityCluster({ dark, unread, streak, onToggleTheme, onBell }) { - return ( - <> - - - {streak != null && } - - ); -} - -// ─── SettingsRow ──────────────────────────────────────────── -function SettingsRow({ label, value, valueColor, accessory = "chevron", onTap, mono = false, children, leadingDot }) { - return ( -
-
- {leadingDot && } - {label} -
-
- {value && {value}} - {children} - {accessory === "chevron" && } -
-
- ); -} - -// ─── MonoToggle ON/OFF ────────────────────────────────────── -function MonoToggle({ on, onClick }) { - return ( - - ); -} - -// ─── SchemeSwatches ───────────────────────────────────────── -const SCHEME_COLORS = { - purple: "#7c3aed", blue: "#2563eb", green: "#16a34a", - rose: "#e11d48", orange: "#ea580c", cyan: "#0891b2", -}; -function SchemeSwatches({ active = "purple", onSelect, lockNonDefault = false }) { - const schemes = ["purple", "blue", "green", "rose", "orange", "cyan"]; - return ( -
- {schemes.map(s => { - const isActive = s === active; - const locked = lockNonDefault && s !== "purple"; - return ( - - ); - })} -
- ); -} - -// ─── Buttons ──────────────────────────────────────────────── -function PrimaryButton({ children, onClick, fullWidth = false, disabled = false, leading, style }) { - return ( - - ); -} - -function SecondaryButton({ children, onClick, fullWidth = false, leading, style }) { - return ( - - ); -} - -function QuietLink({ children, onClick, color = "var(--fg-3)", size = 13, italic = false, style }) { - return ( - - ); -} - -// ─── Inputs ──────────────────────────────────────────────── -function UnderlinedInput({ label, value, placeholder, type = "text", mono = false, trailing, autoFocus, large = false, multiline = false, rows = 3 }) { - return ( -
- {label &&
{label}
} -
- {multiline ? ( -