diff --git a/DESIGN.md b/DESIGN.md index 87dc3dc946..f258683ae4 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -9,18 +9,22 @@ colors: accent-solid-dark: "oklch(0.76 0.15 250)" on-accent-light: "#ffffff" on-accent-dark: "#171717" - surface-light: "oklch(1 0 0)" - canvas-light: "oklch(0.975 0 0)" + surface-raised-light: "oklch(1 0 0)" + surface-base-light: "oklch(0.975 0 0)" + surface-sunken-light: "oklch(0.945 0 0)" ink-light: "oklch(0.17 0.005 286)" - surface-dark: "oklch(0.205 0.004 286)" - canvas-dark: "oklch(0.18 0.004 286)" - ink-dark: "oklch(0.92 0.004 286)" - info-light: "oklch(0.68 0.13 240)" + surface-raised-dark: "oklch(0.205 0.004 286)" + surface-overlay-dark: "oklch(0.225 0.004 286)" + surface-base-dark: "oklch(0.18 0.004 286)" + surface-sunken-dark: "oklch(0.14 0.004 286)" + ink-dark: "oklch(0.95 0.004 286)" + info-light: "oklch(0.50 0.13 240)" info-dark: "oklch(0.74 0.13 240)" - success-light: "oklch(0.55 0.17 145)" + success-light: "oklch(0.50 0.17 145)" success-dark: "oklch(0.60 0.17 145)" - warning: "oklch(0.66 0.18 55)" - destructive-light: "oklch(0.58 0.24 28)" + warning-light: "oklch(0.50 0.18 55)" + warning-dark: "oklch(0.66 0.18 55)" + destructive-light: "oklch(0.50 0.24 28)" destructive-dark: "oklch(0.70 0.19 22)" typography: display-1: { fontSize: "28px", fontWeight: 400, lineHeight: 1.4286 } @@ -37,22 +41,20 @@ typography: code: { fontSize: "14px", fontWeight: 400, lineHeight: 1.4286 } badge-label: { fontSize: "12px", fontWeight: 500, lineHeight: 1.6667 } rounded: + inner: "4px" control: "6px" - surface: "8px" - modal: "12px" + card: "10px" + container: "12px" + page: "16px" pill: "999px" - astryx-element: "0.625rem" - astryx-container: "0.75rem" - astryx-page: "1.75rem" - astryx-full: "9999px" spacing: { space-0-5: "2px", space-1: "4px", space-1-5: "6px", space-2: "8px", space-2-5: "10px", space-3: "12px", space-4: "16px", space-5: "20px", space-6: "24px", space-8: "32px", space-10: "40px", space-12: "48px", space-16: "64px" } components: - button-default: { typography: "{typography.label}", rounded: "{rounded.astryx-element}", padding: "8px 12px", height: "32px" } - button-primary-light: { backgroundColor: "{colors.primary}", textColor: "{colors.on-accent-light}", typography: "{typography.label}", rounded: "{rounded.astryx-element}", height: "32px" } - button-primary-dark: { backgroundColor: "{colors.accent-solid-dark}", textColor: "{colors.on-accent-dark}", typography: "{typography.label}", rounded: "{rounded.astryx-element}", height: "32px" } - input-default: { typography: "{typography.body}", rounded: "{rounded.astryx-element}", height: "32px" } - badge: { typography: "{typography.badge-label}", rounded: "{rounded.astryx-full}", padding: "0 8px", height: "20px" } - card-default: { rounded: "{rounded.astryx-container}", padding: "12px" } + button-default: { typography: "{typography.label}", rounded: "{rounded.card}", padding: "8px 12px", height: "32px" } + button-primary-light: { backgroundColor: "{colors.primary}", textColor: "{colors.on-accent-light}", typography: "{typography.label}", rounded: "{rounded.card}", height: "32px" } + button-primary-dark: { backgroundColor: "{colors.accent-solid-dark}", textColor: "{colors.on-accent-dark}", typography: "{typography.label}", rounded: "{rounded.card}", height: "32px" } + input-default: { typography: "{typography.body}", rounded: "{rounded.card}", height: "32px" } + badge: { typography: "{typography.badge-label}", rounded: "{rounded.pill}", padding: "0 8px", height: "20px" } + card-default: { rounded: "{rounded.container}", padding: "12px" } --- # Design System: Maka @@ -71,21 +73,78 @@ This document governs the default light and dark themes. Optional palettes may c Frontmatter is a snapshot of the current default theme. When it diverges from source or contract tests, source and tests win and this document must be refreshed. -## 2. Colors +## 2. Surfaces -The palette is cool-neutral and quiet. Light mode places white work surfaces on a near-white canvas; dark mode uses close zinc tones separated by hairlines. +Depth is a ladder, not a decoration. Every background in the app resolves to one of four semantic tiers, each derived from `--background` with cumulative offsets (palettes override only `--background`; the ladder follows). -- **Brand mark** is fixed `#71a8fd`; it identifies Maka and is never the general CTA color. -- **Interaction accent** follows the active palette for focus, links, selection, and live state. -- **Primary solid accent** is the contrast-safe variant for filled controls and other accent-colored text or icons. -- **Surface, canvas, and ink** create hierarchy through tone; derive secondary text, borders, and washes from ink. -- **Info, success, warning, and destructive** are meanings, not decoration. Enabled is not automatically success. +| Tier | Token | Role | Light | Dark | +|---|---|---|---|---| +| sunken | `--surface-sunken` | sidebar rail, recessed chrome | 237 | 9 | +| base | `--surface-base` | shell canvas behind plates | 247 | 17 | +| raised | `--surface-raised` | cards, content plates, reading surfaces | 255 | 23 | +| overlay | `--surface-overlay` | menus, popovers, dialogs, toasts | 255 | 27 | -**The Signal, Not Texture Rule.** Accent communicates action or state. Never use it as a background flood, gradient, glow, or substitute for hierarchy. +(Values are measured rendered pixels, not aspirations; the ink/surface contract tests hold them.) + +**The Height Rule.** Height maps monotonically to lightness in both modes, and reading surfaces always occupy the brightest tier of their mode. In light mode the ladder tops out at pure white, so `raised` and `overlay` share the fill and overlay separation hands off to the floating recipe (§5). Light mode "higher = darker" is permanently forbidden — it makes elevation shadows contradict the fill. + +**The Canvas Recedes Rule** (owner decision 2026-06-20). The canvas is gray; content surfaces are white. The sidebar sits on `sunken`, the shell on `base`, and content plates on `raised`. Contrast between canvas and plate — not hairlines — is the primary separator of the shell. + +**Legacy names.** The semantic tiers are canonical. Old names are aliases and their resolved values never change out from under consumers: `--surface-canvas` → base, `--background` → raised (it is the card fill, not the page color), `--background-elevated`, `--color-background-card`, `--color-background-popover` → overlay, `--card-bg`, `--color-background-surface` → raised. + +## 3. Ink + +Prose uses exactly three tiers, spaced at an even ~2× contrast rhythm, all above WCAG AA. Measured against `--surface-raised`: + +| Tier | Token | Light | Dark | +|---|---|---|---| +| primary | `--foreground` | 19.1:1 | 15.5:1 | +| secondary | `--foreground-secondary` | 9.8:1 | 9.2:1 | +| muted | `--muted-foreground` | 4.8:1 | 4.7:1 | + +- **The Three-Tier Reading Rule.** Prose uses primary, secondary, or muted. Neutral washes are surfaces, not extra text tiers. `--foreground-dimmed` is an alias of secondary and must never regain its own definition (contract-tested). +- **The One Colorspace Rule.** Every derivation inside a token family uses one colorspace (`oklch` for ink, contract-tested). Mixing `srgb` and `oklch` derivations produces "same literal, different value" drift. Known exception: dark `--surface-overlay` still derives via an srgb mix — a scheduled unification in T2–T4, not a precedent. +- **Links use the solid accent tier** (`--accent-solid`), never raw `--accent` — the accent identifies interaction; the solid tier is the only accent variant that clears text contrast on every palette. + +## 4. Borders + +Three strengths, each a job, spaced at ~1.6× like the ink ladder: + +- `--border-soft` (6% ink): quiet separation inside a plate — rails, row dividers that fills can't carry. +- `--border` (10% ink): structural boundaries between regions. +- `--border-strong` (16% ink): emphasis chrome only. Its legitimate jobs, from the live inventory: selected/active outlines and emphasized boundaries (onboarding, plan-mode, chat turn/quote chrome, the Astryx `--color-border-emphasized` mapping). Two call sites borrow it as a strong neutral *tint* rather than a border — a scrollbar thumb color and a separator glyph color — and are queued to migrate onto ink-derived tokens in T2–T4. It is not "the border for when you're unsure." +- `--shadow-minimal-flat` is historically a 1px ring wearing box-shadow clothing (`0 0 0 1px`), not an elevation step; it belongs to this chapter in spirit and migrates to a ring-named border token in T2–T4 (cross-package consumers exist in `packages/ui`). + +**The One Means Rule.** Each boundary picks one separator: a fill step, a line, or a shadow — never stacked on the same edge. + +## 5. Elevation -**The Three-Tier Reading Rule.** Prose uses primary, secondary, or muted foreground. Neutral washes are surfaces, not extra text tiers. +Default surfaces are flat. Depth comes first from the surface ladder, then a line, then shadow only when an element genuinely floats above the plane. -## 3. Typography +- Product elevation names alias the theme scale: `--elevation-raised` (low), `--elevation-overlay` (med), `--elevation-drag` (high). A scale only gets used when product code can name it — the theme shipped three shadows for months and product CSS consumed one, because the names meant nothing at a call site. +- **The Floating Recipe.** Every portal surface (menu, popover, dialog, toast) is: `--surface-overlay` fill + `--border-soft` ring + `--elevation-overlay` + `overflow: hidden` + container radius. No portal invents its own mix. +- Dark mode relies on tone and rings before shadow. Neon edges and lifted-everything styling are forbidden. +- Native shell vibrancy is allowed only in designated material; generic glassmorphism is not. + +**The One Working Plane Rule.** Dividers separate responsibilities; cards and shadows do not fragment the workspace into a dashboard grid. + +## 6. Radius + +Nothing interactive is square. One ladder, assigned monotonically by box height: + +| Radius | Tier | Assign to | +|---|---|---| +| 4px | inner | chips, keycaps, nested inlays inside a control | +| 6px | control | buttons, inputs, segmented items (≤ 36px tall) | +| 10px | card | cards, rows-as-cards, list containers (the single card value — 8/10/12 coexistence is over) | +| 12px | container | modals, panels, portal surfaces | +| 16px | page | page-level plates and hero surfaces | +| full | pill | badges, pills, circular controls | + +- **The Full-Bleed Rule.** `border-radius: 0` is legal only on true full-bleed rows — an element flush with its container on both sides. Radius and gap move together: if it has breathing room, it has corners. +- **Proportional marks.** Product-drawn icon plates use ratio-owned radius (~25–27% of the box edge), recorded in prose because Stitch accepts only absolute units. + +## 7. Typography Use the system UI stack with explicit platform CJK fallbacks; Geist Variable is a late fallback. Code uses Geist Mono Variable, JetBrains Mono, then platform monospace. Chinese and Latin must read as one interface. @@ -100,29 +159,30 @@ Use the system UI stack with explicit platform CJK fallbacks; Geist Variable is **The Four-Pixel Line Rule.** Text line boxes land on the 4px grid. Mono is technical, never decorative. -## 4. Elevation +## 8. Color Specification -Default surfaces are flat. Depth comes first from canvas-to-surface tone, then a hairline, then shadow only when an element genuinely floats. +The palette is cool-neutral and quiet; color is generated to spec, not picked by eye. -- Use `--shadow-minimal-flat` for compact tool previews that need an edge without visible lift. -- Use Astryx `low`, `med`, or `high` elevation through component APIs for floating controls, popovers, dialogs, and overlays. -- Dark mode relies on tone and rings before shadow. Neon edges and lifted-everything styling are forbidden. -- Native shell vibrancy is allowed only in designated material; generic glassmorphism is not. +- **Brand mark** is fixed `#71a8fd`; it identifies Maka and is never the general CTA color. +- **Interaction accent** follows the active palette for focus, selection, and live state; **links and accent-colored text use the solid tier** (§3). +- **Status families** (success / active / attention / error / neutral — there is no "info" status semantic) are generated, not picked: one lightness per mode with each hue keeping its own chroma. Light mode is generated at L=0.50 (contrast vs white spans 5.5–6.3:1; the residual spread is hue physics — at equal L, yellow carries more luminance than blue — and flattening it would abandon the shared-L premise that makes them a family). This regeneration fixed two AA failures the old hand-picked values shipped (info 2.82:1, warning 3.29:1). Dark mode keeps its pre-2.0 values (all ≥4.5:1); regenerating dark at its own single L is a scheduled separate round. A louder band at ~90% gamut chroma exists only for 8px status dots — dots must read at a glance; washes must not shout. +- **Tinted surfaces** (status washes behind rows and banners) derive from the same status hues; hand-rolled `oklch()` status washes at call sites are forbidden — consume the family. +- **Identity colors** (avatars, channel marks) live in one 4.2–4.8:1 contrast band; desaturation for muted states happens at constant OKLab lightness. -**The One Working Plane Rule.** Dividers separate responsibilities; cards and shadows do not fragment the workspace into a dashboard grid. +**The Signal, Not Texture Rule.** Accent communicates action or state. Never use it as a background flood, gradient, glow, or substitute for hierarchy. -## 5. Components +## 9. Components Use Astryx primitives as the default seam. New work composes product meaning through published props, tokens, and stable `themeProps` extension points; internal-DOM overrides are acknowledged transitional states, not precedent. -- **Controls:** Maka uses a 20/24/28/32/36/40px height ruler with 32px as the default; Astryx owns the 28/32/36px variants. Hover is restrained; press may use `scale(0.98)`; keyboard focus is always visible. -- **Fields:** labels, descriptions, and validation belong to the field primitive; input focus belongs to its control. Keep disabled reasons discoverable through the owning control’s tooltip; do not rebuild field chrome around a bare input. +- **Controls:** Maka uses a 20/24/28/32/36/40px height ruler with 32px as the default; Astryx owns the 28/32/36px variants. Hover is restrained; press may use `scale(0.98)`; keyboard focus is always visible. At most one inverted (filled) element per control. +- **Fields:** labels, descriptions, and validation belong to the field primitive; input focus belongs to its control. Keep disabled reasons discoverable through the owning control's tooltip; do not rebuild field chrome around a bare input. - **Badges and status:** Badge is 20px high and pill-shaped. Choose semantic variants by meaning, not hue; use status dots for success, active, attention, error, or neutral. -- **Cards:** In the Maka theme, Astryx Card uses 12px radius, 12px default padding, and no resting elevation. Astryx components own their geometry; product-drawn square icon plates use the ratio-owned 27% radius, recorded in prose because Stitch accepts only absolute radius units. +- **Cards:** Astryx Card uses container radius, 12px default padding, and no resting elevation. Astryx components own their geometry. - **Workspace:** conversation, tool activity, artifacts, browser state, and generated files stay connected to the task that produced them. Assistant messages remain quiet and avatar-free. - **Custom companion:** a desktop pet is the sole mascot exception: user-supplied, disabled by default, decorative, pointer-transparent, hidden from assistive technology, and reduced-motion aware. It never conveys required status or speaks for the agent. -## 6. Do's and Don'ts +## 10. Do's and Don'ts ### Do: @@ -132,9 +192,15 @@ Use Astryx primitives as the default seam. New work composes product meaning thr - **Do** preserve keyboard focus, disabled reasons, loading and error states, and reduced-motion behavior. - **Do** keep optional palette inventories in source while preserving documented roles and contrast. -### Don't: +### Don't (the forbidden list — each item is contract-tested or review-blocked): -- **Don't** use generic AI gradients, glowing borders, sparkle, decorative “thinking,” or default glassmorphism. +- **Don't** write a bare `oklch()` status color or wash at a call site — consume the generated families (§8). +- **Don't** use `border-radius: 0` off a full-bleed row (§6). +- **Don't** hardcode `background: white` or any literal surface color — resolve a ladder tier (§2). +- **Don't** put more than one inverted element in a single control. +- **Don't** mix `srgb` and `oklch` derivations inside one token family (§3). +- **Don't** make light mode's "higher" darker (§2), stack two separators on one edge (§4), or invent a portal recipe (§5). +- **Don't** use generic AI gradients, glowing borders, sparkle, decorative "thinking," or default glassmorphism. - **Don't** personify the agent through mascots, fake emotion, excessive avatars, or chat ornament; the optional user-supplied pet is the only exception. - **Don't** turn every region into a card or every status into a colored pill. - **Don't** introduce another accent, spacing ruler, radius tier, icon system, text axis, or parallel component path. diff --git a/apps/desktop/src/main/__tests__/ink-ladder-contract.test.ts b/apps/desktop/src/main/__tests__/ink-ladder-contract.test.ts new file mode 100644 index 0000000000..dbea1fbce6 --- /dev/null +++ b/apps/desktop/src/main/__tests__/ink-ladder-contract.test.ts @@ -0,0 +1,50 @@ +import { strict as assert } from 'node:assert'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { describe, it } from 'node:test'; +import { REPO_ROOT } from './css-test-helpers.js'; + +/** + * The ink ladder's aliasing invariant (visual system 2.0, T1). + * + * `--foreground-dimmed` and `--foreground-secondary` were both written as + * "80% ink", but one mixed in srgb and the other in oklch, so the same words + * produced two different colours — 10.51:1 against 12.01:1, measured. That is + * the failure this pins: not a wrong value, but a SECOND definition of one + * tier that drifted because nothing said the two had to agree. + * + * Asserted on the source text rather than a rendered page because the point is + * that dimmed has no independent definition to drift from. A screenshot would + * only prove they happen to match today. + */ +const TOKENS_PATH = join(REPO_ROOT, 'apps', 'desktop', 'src', 'renderer', 'maka-tokens.css'); + +describe('ink ladder', () => { + it('keeps --foreground-dimmed an alias, never its own mix', async () => { + const css = await readFile(TOKENS_PATH, 'utf8'); + const declarations = [...css.matchAll(/^\s*--foreground-dimmed:\s*([^;]+);/gm)].map( + (match) => match[1].trim(), + ); + + assert.ok(declarations.length > 0, '--foreground-dimmed must still be defined'); + for (const value of declarations) { + assert.equal( + value, + 'var(--foreground-secondary)', + 'dimmed is the secondary tier under another name; giving it its own color-mix is how the two drifted apart before', + ); + } + }); + + it('derives every ink tier in one colour space', async () => { + const css = await readFile(TOKENS_PATH, 'utf8'); + // A tier mixed in srgb while its siblings mix in oklch is the exact defect + // above, and it is invisible in review because the percentages match. + const srgbInk = [...css.matchAll(/^\s*(--[a-z-]*foreground[a-z-]*):\s*color-mix\(in srgb[^;]*;/gm)]; + assert.deepEqual( + srgbInk.map((match) => match[1]), + [], + 'ink tiers derive in oklch; an srgb mix gives the same words a different colour', + ); + }); +}); diff --git a/apps/desktop/src/renderer/maka-tokens.css b/apps/desktop/src/renderer/maka-tokens.css index 0c9446d3d2..3a80c21335 100644 --- a/apps/desktop/src/renderer/maka-tokens.css +++ b/apps/desktop/src/renderer/maka-tokens.css @@ -69,7 +69,31 @@ A wash wide enough to be the whole boundary reads as a gray slab pasted next to the content rather than a second panel, which is the note PR-UI-FRAME-4 left here and the reason this lands at 0.025 and not higher. */ - --surface-canvas: oklch(from var(--background) calc(l - 0.025) c h); + --surface-canvas: var(--surface-base); + + /* -- Surface ladder (visual system 2.0, T1) -------------------------- + Four rungs; height maps monotonically to lightness, and the READING + surface always occupies this mode's brightest rung: pure white in light, + the lightest gray in dark. + + The middle of this ladder is not new. The owner's 2026-06-20 decision + above already put a gray plate behind pure-white content, and that IS + this model -- `--background` was always the CARD, never the page. What + was missing were the two ends: nothing below the canvas for a sidebar to + recede to, and no surface of its own for overlays, which had been pinned + to the card white. + + The semantic names are canonical; the older names around them are + aliases that keep resolving to exactly what they resolved to before. + Nothing is forced to migrate here -- see DESIGN.md, Surface ladder. */ + --surface-sunken: oklch(from var(--background) calc(l - 0.055) c h); + --surface-base: oklch(from var(--background) calc(l - 0.025) c h); + --surface-raised: var(--background); + /* Light has no room above white, so overlay carries the same FILL as raised + and is told apart by the overlay recipe (border-soft + elevation). + Unpinning it from `--background` is what gives dark a rung to climb and + the recipe something to hang on: a semantic split, not a colour change. */ + --surface-overlay: var(--surface-raised); /* PR-GRAY-CARD-LIFT-3 (WAWQAQ msg `053ca971`): in the new gray-plate / white-card hierarchy, "elevated" cards live on the same pure white as base content cards — the lift comes from the @@ -77,7 +101,7 @@ `--background-elevated` mixed 1.5% foreground into background, which read as a faint gray on the new shell. Pinning it to the same `--background` keeps card surfaces consistently white. */ - --background-elevated: var(--background); + --background-elevated: var(--surface-overlay); /* PR-UI-ALIGN-0: warm near-black (#141414-like) at a faint warm hue, so ALL derived grays (#636261/#838280-like) pick up the reference's warm cast instead of reading as cold neutral. */ @@ -87,7 +111,11 @@ built on. zinc-family hue 286 at the same chroma reads clean and cool like the reference sidebar. */ --foreground: oklch(0.17 0.005 286); - --foreground-dimmed: color-mix(in srgb, var(--foreground) 80%, var(--background)); + /* Alias, not a tier. It was written as "80% ink" like --foreground-secondary + but mixed in srgb where that one mixes in oklch, so the same words gave + two different colours (10.51:1 vs 12.01:1 measured). One family, one + colour space: derive every ink in oklch. */ + --foreground-dimmed: var(--foreground-secondary); /* Brand accent = logo blue (oklch L0.70 C0.135 h250), sampled from apps/desktop/assets/icon.png (owner decision 2026-07-03). --success stays @@ -125,9 +153,19 @@ --warning's orange h55 (info + warning were indistinguishable). Blue is also consistent with the accent (h250) and unambiguously reads as informational, not cautionary. */ - --info: oklch(0.68 0.13 240); /* blue — informational / "ask" mode */ - --success: oklch(0.55 0.17 145); /* green — connected, ok */ - --destructive: oklch(0.58 0.24 28); /* red — errors, denied */ + /* Status family, specified rather than picked (visual system 2.0, T1). + One lightness for all four hues, each keeping its own chroma, so the + family reads as one system instead of four independent choices. + + Measured against white before this: info 2.82:1, warning 3.29:1, success + 4.55:1, destructive 4.81:1 — a 1.7x spread with TWO of the four under the + 4.5:1 AA floor. At L=0.50 they land 5.54-6.33:1 together. Residual spread + is hue, not drift: yellow carries more luminance than blue at equal + lightness, and equalising that would mean abandoning the shared L that + makes them a family. */ + --info: oklch(0.50 0.13 240); /* blue — informational / "ask" mode */ + --success: oklch(0.50 0.17 145); /* green — connected, ok */ + --destructive: oklch(0.50 0.24 28); /* red — errors, denied */ /* Legacy brand affordance for a few emphasized surfaces. New checked control states use --control instead. @@ -163,7 +201,7 @@ hue (h55); info is now cool blue (h240), so the two tiers read as clearly different families — no longer the near-identical ambers they once were. Still derived nowhere else, safe across palettes. */ - --warning: oklch(0.66 0.18 55); + --warning: oklch(0.50 0.18 55); --warning-text: color-mix(in oklab, var(--warning) 50%, var(--foreground)); /* === foreground solid mix scale === @@ -194,7 +232,21 @@ /* PR-UI-ALIGN-0: visible hairline borders (≈#e6e6e6) like the reference's card/container outlines — maka's 5% was nearly invisible, which flattened the floating-card hierarchy. */ ---border: oklch(from var(--foreground) l c h / 0.10); + /* Border tiers (visual system 2.0, T1). `--border` is the STRUCTURAL line: + the edge of a panel, a card, a table. `--border-soft` is the quiet one for + seams inside a surface — rails, row separators, the outline that only has + to hint at a boundary the fill has already made. + + Four strengths were in play before this: 5% on the sidebar rail (declared + locally in sidebar.css), 8% on --card-border-color, 10% here, and 16% on + --border-strong. Two of those are tiers and two are drift. The soft tier + is what the 5% rail and the 8% card edge converge on; their call sites + move in T2-T4, since T1 does not touch product CSS. + + Pairs with the DESIGN.md rule that one boundary picks ONE mechanism — + fill step, line, or shadow, never all three stacked. */ + --border: oklch(from var(--foreground) l c h / 0.10); + --border-soft: oklch(from var(--foreground) l c h / 0.06); --border-strong: oklch(from var(--foreground) l c h / 0.16); /* === border stroke width (#520 PR4 item 14) ============================ Border color was already tokenized; the width was bare px in every @@ -210,8 +262,18 @@ --border-width-thick: 2px; --border-width-accent: 3px; --muted: oklch(from var(--foreground) l c h / 0.05); - --muted-foreground: color-mix(in oklch, var(--foreground) 50%, var(--background)); - --foreground-secondary: color-mix(in oklch, var(--foreground) 80%, var(--background)); + /* Ink ladder (visual system 2.0, T1): three tiers about 2x apart, all above + 4.5:1. Measured against --surface-raised, light reads 19.1 / 9.8 / 4.8. + + The audit asked for a 3x gap, copied from a reference that reaches 3.6x + with only TWO text tiers. Three tiers standing on a 4.5:1 AA floor cannot + span 3x without the bottom two colliding, so the rhythm is even instead: + roughly geometric, which is what three tiers can actually hold. + + 50% muted was the one real defect — 4.21:1 in light and 3.89:1 in dark, + already under AA in the mode nobody checked. */ + --muted-foreground: color-mix(in oklch, var(--foreground) 54%, var(--background)); + --foreground-secondary: color-mix(in oklch, var(--foreground) 74%, var(--background)); --state-hover-bg: oklch(from var(--foreground) l c h / 0.04); --state-selected-bg: oklch(from var(--foreground) l c h / 0.065); /* PR5 FOREGROUND-ALPHA-TOKEN-0: raw foreground-alpha tiers for component @@ -247,8 +309,30 @@ --background); it carries its darwin overrides in "DARWIN GLASS" below. */ --color-bg-container: var(--background); + /* === elevation (visual system 2.0, T1) ============================= + Product names for the theme's shadow scale, so the right answer is + findable. The generated theme ships --shadow-low/med/high and product CSS + had used them exactly once, reaching instead for hand-rolled shadows — + a scale nobody can name is a scale nobody uses. + + Three names, three jobs: `raised` for a card lifting off its plate, + `overlay` for anything in a portal, `drag` for something under the + cursor. In-flow elements do not cast shadows; that rule is unchanged, + this only gives the legitimate path a name. + + Consumers move in T2-T4 — T1 does not touch product CSS. */ + --elevation-raised: var(--shadow-low); + --elevation-overlay: var(--shadow-med); + --elevation-drag: var(--shadow-high); + /* === shadow recipes === - Flat elevation uses a foreground-derived 1px ring in both themes. */ + Flat elevation uses a foreground-derived 1px ring in both themes. + + NOT an elevation tier and NOT dead: this is a 1px ring wearing a + box-shadow, and it has five live consumers (two of them in + packages/ui/src/styles.css). The T1 audit read its three DEFINITION + sites as its only mentions and called it dead; deleting it would have + taken two production styles with it. It stays, named for what it is. */ --shadow-minimal-flat: rgba(0,0,0,0) 0 0 0 0, rgba(0,0,0,0) 0 0 0 0, @@ -510,8 +594,20 @@ It is corner-adjacent with a uniform inset. If a proposed shrink is neither, it is not concentric — pick the tier the element's ROLE calls for instead. */ + /* Radius language (visual system 2.0, T1). Two vocabularies were running in + parallel and disagreeing on the card: this file said 8, Astryx's own scale + said 10 (--radius-element), and --radius-modal said 12. Three numbers for + one idea is how a radius stops meaning anything. + + They land on one set: 4 inner / 6 control / 10 card / 12 modal / full. + The card tier resolves to 10 rather than 8 — Astryx's element rung is the + one its own components already round to, so meeting it there is what stops + our cards from disagreeing with the controls inside them. + + Assignment is monotonic in box height, and `border-radius: 0` is legal + only on an edge-to-edge row. See DESIGN.md, Radius language. */ --radius-control: 6px; - --radius-surface: 8px; + --radius-surface: 10px; --radius-modal: 12px; --radius-pill: 999px; /* Square icon plates (provider logo, about logo, feature status) are @@ -755,9 +851,20 @@ the shell (Astryx painted its own surface over the frame plate until this branch handed the columns their materials); against the product's #171719 content it rendered a near-black #09090b rail. */ - --surface-canvas: oklch(from var(--background) calc(l - 0.025) c h); - --background-elevated: color-mix(in srgb, var(--foreground) 2%, var(--background)); - --foreground: oklch(0.92 0.004 286); + --surface-canvas: var(--surface-base); + /* Dark inverts the direction, not the rule: higher is still lighter and the + reading surface still takes the brightest rung. Unlike light there IS + room above raised, so overlay climbs a real step. */ + --surface-sunken: oklch(from var(--background) calc(l - 0.065) c h); + --surface-base: oklch(from var(--background) calc(l - 0.025) c h); + --surface-raised: var(--background); + --surface-overlay: color-mix(in srgb, var(--foreground) 2%, var(--background)); + --background-elevated: var(--surface-overlay); + /* 0.92 measured 14.11:1, just under the 15:1 the reading tier asks for. */ + --foreground: oklch(0.95 0.004 286); + /* Dark's ink-to-surface gap differs, so one percentage cannot put secondary + in the 8.5-10:1 band for both modes: 74% gives 9.8 light but 8.2 dark. */ + --foreground-secondary: color-mix(in oklch, var(--foreground) 78%, var(--background)); --accent: oklch(0.74 0.15 250); /* Dark mode keeps the same pale-blue CTA chip + deep-blue text as light @@ -786,6 +893,12 @@ --info: oklch(0.74 0.13 240); /* blue — see light-mode note */ --success: oklch(0.60 0.17 145); --destructive: oklch(0.70 0.19 22); + /* Dark warning is stated here because --warning is the ONE status hue that + had no dark override: info, success and destructive each carry their own, + so lowering the light value to L=0.50 dragged dark down with it and put + dark warning at 2.83:1, well under AA. This restores its measured 5.44:1 + and makes the family's dark half uniform in shape. */ + --warning: oklch(0.66 0.18 55); --opacity-overlay: 0.06; diff --git a/scripts/check-dead-css.mjs b/scripts/check-dead-css.mjs index 8ff03bb347..534d301180 100755 --- a/scripts/check-dead-css.mjs +++ b/scripts/check-dead-css.mjs @@ -193,6 +193,20 @@ const RESERVED_SCALE_TOKENS = new Set([ '--z-dropdown', '--z-tooltip', '--z-modal', + // Surface ladder sunken/base/raised/overlay (visual system 2.0 T1). The + // sunken rung's consumer is the sidebar, which recedes to it in T2; the + // ladder only reads as a ladder with its bottom rung present, and defining + // the scale is precisely what T1 is for. + '--surface-sunken', + // Border strength tiers soft/structural/strong (visual system 2.0 T1). The + // soft tier's consumers are the sidebar rail and the card edge, which + // converge on it in T2-T4; T1 defines the scale without touching product CSS. + '--border-soft', + // Elevation tiers raised/overlay/drag (visual system 2.0 T1) -- product + // names for the theme's shadow scale; consumers adopt them in T2-T4. + '--elevation-raised', + '--elevation-overlay', + '--elevation-drag', // Control-height scale, 20/24/28/32/36/40 on the 4px ruler. '--h-control-xl', '--h-control-2xl',