From eee82787b6ecb58ff41e7051a9b1b65086a93ac0 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 3 Sep 2026 11:00:05 +0800 Subject: [PATCH 1/3] fix(design-system): let a mode live in the value, not in a selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Astryx inverts a surface by setting `color-scheme: dark` on it — MediaTheme, which wraps every toast body and every overlay scrim — and its on-media token overrides move only text/icon primary and accent, because everything else is expected to follow `light-dark()` on its own. Maka's palette encodes its modes in a `.dark` selector on , which nothing under an inverted surface can follow. Once #4509 pointed Astryx's ink at the product tiers, that gap became visible: `` in an error toast painted the light-mode grey on #AA071E at 1.04:1. The hairlines and `--color-overlay-hover` were wrong on the same surfaces for the same reason — a near-black 4% hover wash on a dark plate. Every colour that differs by mode now carries both sides in its own value, as `light-dark(light, dark)`, declared once. The `.dark` block is down to two alphas; `--ring-soft` keeps one recipe with the pair inside it; the ten palette catalogues lose their second block each, and the default palette's dark block turns out to have been re-declaring nine derivations byte-for-byte. Nothing needed new plumbing: theme.ts, cached-theme-bootstrap.ts and the Storybook preview already set `color-scheme` beside the class, before the first paint and on every change. Values are unchanged — 134 tokens across 11 palettes and both modes compare identical before and after, the only delta being dark `--ring-soft` gaining light's two transparent placeholder layers. The toast's ink is a second decision, not a consequence of the first. Converted, its description lands at 2.99:1 — which is where Astryx's own secondary sits on that plate too, and still under AA. DESIGN.md §3's Tinted Surface Rule already says text on a tint is one tier; a solid inverted surface is that rule at full tint, so makaTheme.ts's onDark/onLight point text and icon secondary at the same on-color as primary. 1.04:1 → 7.61:1, and the rule gains no exception. ink-ladder-contract gains both halves: no token makaTheme aliases — nor anything those values transitively read — may be declared under `.dark`, and the generated media blocks must carry the collapsed tier. The set is derived from makaTheme.ts rather than listed, so the next alias is covered when it is written. Two mode switches went through the class alone. The e2e link-colour spec now sets both halves and waits for the mode to reach the content — Astryx's re-declares color-scheme on its own wrapper, so the app's subtree turns over one React commit after the root; the switch is now whole rather than half a frame mixed. The palette-matrix story needs neither: a palette block carries both modes and resolves against the root, so its `useIsDark` observer is gone. Generated-by: Claude Code --- DESIGN.md | 10 +- apps/desktop/e2e/link-color-contract.spec.ts | 21 +- .../__tests__/ink-ladder-contract.test.ts | 89 ++++ .../src/renderer/astryx-theme/maka.css | 4 + .../desktop/src/renderer/astryx-theme/maka.js | 8 +- .../src/renderer/astryx-theme/makaTheme.ts | 37 +- .../src/renderer/cached-theme-bootstrap.ts | 5 + apps/desktop/src/renderer/maka-tokens.css | 421 +++++++----------- apps/desktop/src/renderer/theme.ts | 13 +- .../ui/stories/palette-matrix.stories.tsx | 24 +- 10 files changed, 338 insertions(+), 294 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index fd4ae0d63d..9d1b185365 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -104,16 +104,16 @@ Depth is a ladder, not a decoration. Every background resolves to one of four se **The Canvas Recedes Rule** (owner decision 2026-06-20). The canvas is gray; content surfaces are white. Shell floor is two colours: sidebar and canvas share `base`, content plates sit on `raised`. Contrast between canvas and plate is the primary separator. -**Paper.** `--surface-paper` sits outside the ladder on purpose and has no dark override. It backs content whose contrast we neither author nor may invert: the sandboxed HTML-artifact iframe and the PDF embed, and QR codes, where dark-on-light is a scanning requirement rather than a preference. It is not a fifth tier and app chrome never uses it. +**Paper.** `--surface-paper` sits outside the ladder on purpose and carries one value rather than a light/dark pair. It backs content whose contrast we neither author nor may invert: the sandboxed HTML-artifact iframe and the PDF embed, and QR codes, where dark-on-light is a scanning requirement rather than a preference. It is not a fifth tier and app chrome never uses it. ## 3. Ink Prose uses exactly two tiers — `--foreground` and `--muted-foreground` — which is Astryx's own shape. `--muted-foreground` is 68% ink in `oklch`, one mix for both modes, chosen so the default palette lands on Astryx's `--color-text-secondary` (`#525252` / `#a3a3a3`, 7.8:1 / 7.1:1) and so all eleven palettes clear WCAG AA in both modes **against `--background`** (and `--surface-raised`, which the two prose tiers are measured on) — the worst is onedark light at 5.0:1. Any new tier must hold that floor on every palette, which is exactly what the retired middle rung could not do. - **The Two-Tier Reading Rule.** Prose is primary or muted. Hierarchy below that comes from size, weight and case — never a third grey. Neutral washes are surfaces, not extra text tiers. `--foreground-secondary` and `--foreground-dimmed` are retired and must not return, as a definition or as a call site (contract-tested). Both retirements are the same lesson: dimmed and secondary were both written as "80% ink" in different colorspaces and resolved 10.51:1 against 12.01:1, and secondary then forced muted down to 54% ink, where it failed AA in light mode on nine of the eleven palettes. -- **The Tinted Surface Rule.** A tinted surface (`--color-*-muted`) is a different reading surface from `--background`, and the AA numbers in this section are measured against `--background`. On a 0.24 tint neither `--muted-foreground` nor `--accent-solid` holds the floor in dark mode on any palette — muted lands under 2.9:1 everywhere, and the solid accent under 3.75:1. Text on a tint is `--foreground`; hierarchy there comes from weight, not from a second ink tier. +- **The Tinted Surface Rule.** A tinted surface (`--color-*-muted`) is a different reading surface from `--background`, and the AA numbers in this section are measured against `--background`. On a 0.24 tint neither `--muted-foreground` nor `--accent-solid` holds the floor in dark mode on any palette — muted lands under 2.9:1 everywhere, and the solid accent under 3.75:1. Text on a tint is `--foreground`; hierarchy there comes from weight, not from a second ink tier. **A solid inverted surface is the same rule with the tint turned all the way up** — a toast body, an overlay scrim, anything Astryx wraps in `MediaTheme`. There the muted rung has nothing to be muted against: on the error toast's `#AA071E` it measures 2.99:1, which is where Astryx's own secondary lands too. So the tier collapses there as well, via `onDark` / `onLight` in `makaTheme.ts` pointing text and icon secondary at the same on-color as primary. This is a deliberate deviation from upstream, not a repair of one. - **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, and the ladder is where it bites hardest because its rungs only mean anything relative to each other. -- **Astryx text/icon primary and secondary ARE this ladder.** `makaTheme.ts` tokens point `--color-text-primary` / `--color-icon-primary` at `--foreground` and `--color-text-secondary` / `--color-icon-secondary` at `--muted-foreground`, at the theme root. No bridge, no third value: `Text color="secondary"` and `color: var(--muted-foreground)` are the same grey everywhere in the app, except the deliberate inversions — the on-dark/on-light blocks and the four status banners, which re-declare the pair on the element itself. +- **Astryx text/icon primary and secondary ARE this ladder.** `makaTheme.ts` tokens point `--color-text-primary` / `--color-icon-primary` at `--foreground` and `--color-text-secondary` / `--color-icon-secondary` at `--muted-foreground`, at the theme root. No bridge, no third value: `Text color="secondary"` and `color: var(--muted-foreground)` are the same grey everywhere in the app, except the deliberate inversions — the on-dark/on-light blocks and the four status banners, which re-declare the pair on the element itself. Pointing an Astryx token at a product var carries an obligation the next such alias inherits: the var must encode its own modes in its value (§8), or it stops following the surface it is painted on. - **`--color-text-disabled` is a deliberate exemption, not an oversight.** Astryx's `light-dark(#a3a3a3, #525252)` measures 2.52:1 / 2.29:1 — under the AA floor the two prose tiers hold. It stays: a disabled control read at prose contrast stops reading as disabled, and the versions of this token that clear 4.5:1 looked wrong beside the enabled rows they sit in. It is not prose and it is not a third tier; it is the one place the floor is traded for the state being legible *as a state*. Do not "fix" it, and do not cite it as precedent for another sub-AA value — an audit that finds it has found the exemption, not a bug. - **Links use the solid accent tier** (`--accent-solid`), never raw `--accent` — the accent identifies interaction, and the solid tier is the only accent variant that clears text contrast on every palette. A selection wash or outline is not link text and derives from `--accent` instead — deriving a surface from the link name lets a text-contrast rule silently govern a background. @@ -177,9 +177,11 @@ Use the system UI stack with explicit platform CJK fallbacks; Geist Variable is The palette is cool-neutral and quiet; color is generated to spec, not picked by eye. +**The Mode Lives in the Value Rule.** A colour that differs between light and dark carries both sides in its own value — `light-dark(light, dark)`, declared once — and never in a `.dark` selector. This is not tidiness: Astryx inverts a surface by setting `color-scheme: dark` on it (every toast body, every overlay scrim), and a selector matched on `` cannot follow. A token declared under `.dark` keeps the page's mode on a surface that just declared itself the other one, which is how the error toast came to paint light-mode grey prose on its red plate at 1.04:1. A `light-dark()` value resolves against whatever `color-scheme` is in force where it is *used*, so it follows the page and the inverted surface both — including for tokens nobody has thought to check. Two alphas have nowhere to express a mode and stay on `.dark`; a shadow is not an exception, since only its colour differs and the pair goes inside the recipe. `color-scheme` is set on `` beside the class — before the first paint, and on every change — and Astryx's `` re-declares it on its own wrapper from that class, so the app's subtree turns over on that React commit: one repaint later than the root, and as a whole rather than half at a time. Contract-tested over every token `makaTheme.ts` aliases and everything those transitively read. + - **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). Astryx's own semantic components are the exception — `Badge` and `StatusDot` carry fixed literals inherited from the neutral theme and follow neither the palette nor the families below. -- **Status families** — `--success`, `--warning`, `--destructive` — are generated, not picked: one lightness per mode, each hue keeping its own chroma, every member clearing AA. These are Astryx's three statuses, and Maka has no fourth: there is **no `--info` colour**. "Info" is a semantic slot, not a hue — an informational surface paints with the accent and so follows the user's palette instead of introducing a second blue that competes with it. `` needed a different mechanism to get there: `.astryx-banner.info` re-declares the tint and the text colour **on the banner element itself**, and a declaration on the element beats any ancestor's by proximity whatever the layer. The only token route left — re-pointing `--color-text-blue` / `--color-background-blue` at the root — would also repaint `Badge color="blue"`, which this section keeps literal. It is converged by listing that selector on the unlayered accent bridge in `maka-tokens.css` — the same block, so the 0.24 construction is spelled once — with `--foreground` as its text, per the Tinted Surface Rule (§3); the solid accent is left to the banner's icon and button. The residual contrast spread within a mode is hue physics — at equal lightness, yellow carries more luminance than red — and flattening it would abandon the shared-lightness premise that makes them a family. All three are declared in one block per mode, because the time warning sat thirty lines from its siblings is the time it lost its dark override and dropped under AA unnoticed. A louder band at ~90% gamut chroma exists only for 8px status dots: dots must read at a glance, tinted surfaces must not shout. These are colors; what a state *means* is a separate vocabulary (§9). +- **Status families** — `--success`, `--warning`, `--destructive` — are generated, not picked: one lightness per mode, each hue keeping its own chroma, every member clearing AA. These are Astryx's three statuses, and Maka has no fourth: there is **no `--info` colour**. "Info" is a semantic slot, not a hue — an informational surface paints with the accent and so follows the user's palette instead of introducing a second blue that competes with it. `` needed a different mechanism to get there: `.astryx-banner.info` re-declares the tint and the text colour **on the banner element itself**, and a declaration on the element beats any ancestor's by proximity whatever the layer. The only token route left — re-pointing `--color-text-blue` / `--color-background-blue` at the root — would also repaint `Badge color="blue"`, which this section keeps literal. It is converged by listing that selector on the unlayered accent bridge in `maka-tokens.css` — the same block, so the 0.24 construction is spelled once — with `--foreground` as its text, per the Tinted Surface Rule (§3); the solid accent is left to the banner's icon and button. The residual contrast spread within a mode is hue physics — at equal lightness, yellow carries more luminance than red — and flattening it would abandon the shared-lightness premise that makes them a family. All three are declared in one block, each carrying both modes in its value, because the time warning sat thirty lines from its siblings is the time it lost its dark value and dropped under AA unnoticed. A louder band at ~90% gamut chroma exists only for 8px status dots: dots must read at a glance, tinted surfaces must not shout. These are colors; what a state *means* is a separate vocabulary (§9). - **Tinted surfaces** are Astryx's own tokens: `--color-success-muted`, `--color-warning-muted`, `--color-error-muted` and `--color-accent-muted`. Product CSS reads those four names and nothing else; a hand-rolled `oklch(from var(--destructive) …)` at a call site is forbidden. **One rung, no border rung, no strong tier** — the shape upstream ships. All four are `oklch(from var(--{hue}) l c h / 0.24)`, the alpha the neutral theme's own pastels already sit at, so a Banner and a product row painted side by side read as one material. Same weight is not enough: they must be the same **construction**, because an opaque tint does not compose, and a tinted chip on a tinted panel then resolves to its parent's exact pixel and vanishes. The three status rungs are emitted by `makaTheme.ts`; `--color-accent-muted` needs the unlayered bridge in `maka-tokens.css` instead, because `maka.css` re-declares the accent pair at component level and a root rule in the same layer would lose. A border around any surface, tinted or not, is `--border`: the fill or the ink already states the status, and a second tinted edge repeating it is §4's One Means Rule. The five that tried it — the stat tile's four outline tones, the two tool-output edges, the sandbox and error copy buttons — are gone, and their status is carried by ink instead. The Maka `--{status}-wash` / `-wash-border` vocabulary this replaced was a second authority for the same surface — its two tiers and four rungs each grew a call site that disagreed with the rung beside it. Palette swatches are not tinted surfaces: a swatch's job is to show a palette's real colour, so its literals stay. - **The solid tier is deliberately not converged.** `--success` / `--warning` / `--destructive` remain Maka's names for status ink, dots and solid fills, while Astryx calls the same idea `--color-success` / `--color-warning` / `--color-error`. Only the tinted tier has one authority today. Converging the solid tier means moving status text, status dots and status buttons at once; it is a change of its own, not an omission from this one. - **Identity colors** (avatars, channel marks) live in one 4.2–4.8:1 contrast band; desaturation for muted states happens at constant OKLab lightness. diff --git a/apps/desktop/e2e/link-color-contract.spec.ts b/apps/desktop/e2e/link-color-contract.spec.ts index 3781d4253c..16cd371faa 100644 --- a/apps/desktop/e2e/link-color-contract.spec.ts +++ b/apps/desktop/e2e/link-color-contract.spec.ts @@ -26,13 +26,30 @@ async function renderedLinkColors(page: Page, dark: boolean) { const renderedLink = document.querySelector('.settingsBotConfigDocLink')!; renderedLink.style.setProperty('transition', 'none', 'important'); root.setAttribute('data-maka-theme', 'tokyo-night'); + // Both halves, exactly as theme.ts setDarkClass does it: the class carries + // the mode to Astryx, and color-scheme is what resolves the palette's + // light-dark() pairs (DESIGN.md §8). Toggling the class alone leaves every + // colour on its light value. root.classList.toggle('dark', isDark); - await new Promise((resolve) => requestAnimationFrame(() => resolve())); + root.style.colorScheme = isDark ? 'dark' : 'light'; + + // Astryx's re-declares color-scheme on its own wrapper from React + // state that follows the class through a MutationObserver, so the app's + // subtree lands a commit after the root does. Wait for the mode to reach + // the content rather than for a frame: a rAF is not always enough, and a + // fixed delay would be a race with a number on it. + const settled = () => getComputedStyle(renderedLink).colorScheme === (isDark ? 'dark' : 'light'); + for (let attempt = 0; attempt < 120 && !settled(); attempt += 1) { + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + } const resolve = (value: string) => { const probe = document.createElement('span'); probe.style.setProperty('color', value, 'important'); - document.body.appendChild(probe); + // Inside the themed subtree, next to the link it is compared against — + // document.body sits OUTSIDE Astryx's wrapper and so resolves its + // light-dark() pairs against the root's color-scheme instead. + renderedLink.parentElement!.appendChild(probe); const color = getComputedStyle(probe).color; probe.remove(); return color; diff --git a/apps/desktop/src/main/__tests__/ink-ladder-contract.test.ts b/apps/desktop/src/main/__tests__/ink-ladder-contract.test.ts index 5e13042570..6d6c528489 100644 --- a/apps/desktop/src/main/__tests__/ink-ladder-contract.test.ts +++ b/apps/desktop/src/main/__tests__/ink-ladder-contract.test.ts @@ -40,6 +40,8 @@ const DESKTOP_ROOT = resolve(testDir, '../../..'); const REPO_ROOT = resolve(DESKTOP_ROOT, '../..'); const TOKENS_PATH = join(DESKTOP_ROOT, 'src', 'renderer', 'maka-tokens.css'); +const THEME_SOURCE_PATH = join(DESKTOP_ROOT, 'src', 'renderer', 'astryx-theme', 'makaTheme.ts'); +const THEME_CSS_PATH = join(DESKTOP_ROOT, 'src', 'renderer', 'astryx-theme', 'maka.css'); const SOURCE_ROOTS = [ join(DESKTOP_ROOT, 'src', 'renderer'), join(DESKTOP_ROOT, 'stories'), @@ -68,6 +70,38 @@ function withoutComments(source: string): string { return source.replaceAll(/\/\*[\s\S]*?\*\//g, ' ').replaceAll(/\/\/.*$/gm, ' '); } +/** Every `--token: value;` in a stylesheet, whatever selector it sits under. */ +function declarations(css: string): Map { + const byName = new Map(); + for (const [, name, value] of css.matchAll(/(--[a-z0-9-]+)\s*:\s*([^;{}]*);/g)) { + byName.set(name, [...(byName.get(name) ?? []), value]); + } + return byName; +} + +function referencedTokens(value: string): string[] { + return [...value.matchAll(/var\(\s*(--[a-z0-9-]+)/g)].map((match) => match[1]); +} + +/** + * The product tokens `makaTheme.ts` hands to Astryx, plus everything those + * values read, transitively. This is the set the media rule below binds — and + * it is derived from the theme rather than listed here, so a new alias is + * covered the moment it is written. + */ +function aliasedTokenClosure(themeSource: string, tokens: Map): Set { + const queue = [...withoutComments(themeSource).matchAll(/'(?:--[a-z0-9-]+)':\s*'([^']*)'/g)] + .flatMap((match) => referencedTokens(match[1])); + const closure = new Set(); + while (queue.length > 0) { + const name = queue.pop() as string; + if (closure.has(name)) continue; + closure.add(name); + for (const value of tokens.get(name) ?? []) queue.push(...referencedTokens(value)); + } + return closure; +} + describe('ink ladder', () => { it('keeps the retired ink names out of product source', async () => { const files = (await Promise.all(SOURCE_ROOTS.map(sourceFilesUnder))).flat(); @@ -105,3 +139,58 @@ describe('ink ladder', () => { ); }); }); + +describe('mode expression', () => { + it('keeps every token the Astryx theme aliases out of the .dark selector', async () => { + // DESIGN.md §8, The Mode Lives in the Value Rule. Astryx inverts a surface + // with `color-scheme: dark`, which a selector matched on cannot + // follow — so a colour parked under `.dark` keeps the page's mode on a + // toast body or an overlay scrim. The error toast painted light-mode grey + // on its red plate at 1.04:1 for exactly this reason. + const css = await readFile(TOKENS_PATH, 'utf8'); + const themeSource = await readFile(THEME_SOURCE_PATH, 'utf8'); + const closure = aliasedTokenClosure(themeSource, declarations(withoutComments(css))); + assert.ok(closure.size > 0, 'read no aliases out of makaTheme.ts — the token map moved'); + + const offenders: string[] = []; + for (const [, selector, body] of withoutComments(css).matchAll( + /(?:^|\})\s*([^{}]*\.dark[^{}]*)\{([^{}]*)\}/g, + )) { + for (const name of declarations(body).keys()) { + if (closure.has(name)) offenders.push(`${selector.trim()} → ${name}`); + } + } + + assert.deepEqual( + offenders, + [], + 'a colour that differs by mode carries both sides as light-dark(light, dark); only alphas and shadow recipes stay on .dark', + ); + }); + + it('collapses the ink tiers on an inverted surface', async () => { + // DESIGN.md §3, the Tinted Surface Rule at full tint: a solid inverted + // surface takes one ink tier. Astryx's own on-media defaults move primary + // alone, so dropping the makaTheme onDark/onLight blocks would silently put + // the muted rung back at 2.99:1 on the error toast. + const themeCss = await readFile(THEME_CSS_PATH, 'utf8'); + const missing: string[] = []; + for (const surface of ['dark', 'light']) { + const block = themeCss.match( + new RegExp(String.raw`\[data-astryx-media="${surface}"\]\s*\{([^}]*)\}`), + ); + assert.ok(block, `maka.css has no on-${surface} block — regenerate with npm run astryx:theme`); + for (const token of ['--color-text-secondary', '--color-icon-secondary']) { + if (!block[1].includes(`${token}: var(--color-on-${surface})`)) { + missing.push(`on-${surface} → ${token}`); + } + } + } + + assert.deepEqual( + missing, + [], + 'an inverted surface has one ink tier: secondary takes the same on-color as primary', + ); + }); +}); diff --git a/apps/desktop/src/renderer/astryx-theme/maka.css b/apps/desktop/src/renderer/astryx-theme/maka.css index 61aba8eeca..ebdd6bcba4 100644 --- a/apps/desktop/src/renderer/astryx-theme/maka.css +++ b/apps/desktop/src/renderer/astryx-theme/maka.css @@ -501,6 +501,8 @@ --color-text-primary: var(--color-on-dark); --color-icon-primary: var(--color-on-dark); --color-accent: var(--color-on-dark); + --color-text-secondary: var(--color-on-dark); + --color-icon-secondary: var(--color-on-dark); } [data-astryx-media="light"] { @@ -508,6 +510,8 @@ --color-text-primary: var(--color-on-light); --color-icon-primary: var(--color-on-light); --color-accent: var(--color-on-light); + --color-text-secondary: var(--color-on-light); + --color-icon-secondary: var(--color-on-light); } } } diff --git a/apps/desktop/src/renderer/astryx-theme/maka.js b/apps/desktop/src/renderer/astryx-theme/maka.js index 5100dd0acf..1711f5e85a 100644 --- a/apps/desktop/src/renderer/astryx-theme/maka.js +++ b/apps/desktop/src/renderer/astryx-theme/maka.js @@ -428,7 +428,9 @@ export const makaTheme = { "color-scheme": "dark", "--color-text-primary": "var(--color-on-dark)", "--color-icon-primary": "var(--color-on-dark)", - "--color-accent": "var(--color-on-dark)" + "--color-accent": "var(--color-on-dark)", + "--color-text-secondary": "var(--color-on-dark)", + "--color-icon-secondary": "var(--color-on-dark)" } }, __onLight: { @@ -436,7 +438,9 @@ export const makaTheme = { "color-scheme": "light", "--color-text-primary": "var(--color-on-light)", "--color-icon-primary": "var(--color-on-light)", - "--color-accent": "var(--color-on-light)" + "--color-accent": "var(--color-on-light)", + "--color-text-secondary": "var(--color-on-light)", + "--color-icon-secondary": "var(--color-on-light)" } }, icons: neutralIconRegistry, diff --git a/apps/desktop/src/renderer/astryx-theme/makaTheme.ts b/apps/desktop/src/renderer/astryx-theme/makaTheme.ts index 7fa01b1ebf..18fc34398e 100644 --- a/apps/desktop/src/renderer/astryx-theme/makaTheme.ts +++ b/apps/desktop/src/renderer/astryx-theme/makaTheme.ts @@ -174,8 +174,14 @@ export const makaTheme = defineTheme({ // sites existed and the five-selector bridge in astryx-mount.css reached // exactly five of them (issue #3446 F2); the other 57 were the drift. // Root is the right place: nothing in maka.css re-declares this pair below - // the root except the on-dark/on-light blocks and the four Banner status - // rules, both of which are deliberate inversions that must keep winning. + // the root except the on-dark/on-light blocks (see the surface note at the + // bottom of this file) and the four Banner status rules, both of which are + // deliberate inversions that must keep winning. + // + // Pointing an Astryx token at a product var carries an obligation: the var + // has to encode its own modes, because `color-scheme` — not a selector — is + // what an inverted surface flips. maka-tokens.css states that rule and + // ink-ladder-contract holds the tokens named here to it. // // --color-text-disabled is deliberately NOT here. Astryx's // light-dark(#a3a3a3, #525252) measures 2.52:1 / 2.29:1, under the AA floor @@ -215,4 +221,31 @@ export const makaTheme = defineTheme({ // its `1.75rem` resolves to at a 16px root: no pixel moves. '--radius-page': '28px', }, + // Solid inverted surfaces carry ONE ink tier — DESIGN.md §3, the Tinted + // Surface Rule, which already says the same thing about the 0.24 tints and + // is already spelled this way on `.astryx-banner.info`. A toast body or an + // overlay scrim is the same situation with the tint turned all the way up: + // the surface is not `--background`, so the AA numbers the two prose tiers + // hold do not apply to it, and the muted rung has nowhere to be muted + // against. On the error toast's #AA071E it measures 2.99:1 — the number + // Astryx's own secondary reaches there too, so this is a deliberate + // deviation, not a repair. + // + // Astryx's defaults already point text/icon PRIMARY at the on-colors here; + // naming secondary the same value is what collapses the tier. Everything + // else on these surfaces follows `color-scheme` on its own, now that the + // palette carries its modes in `light-dark()` values instead of a `.dark` + // selector (maka-tokens.css). + onDark: { + tokens: { + '--color-text-secondary': 'var(--color-on-dark)', + '--color-icon-secondary': 'var(--color-on-dark)', + }, + }, + onLight: { + tokens: { + '--color-text-secondary': 'var(--color-on-light)', + '--color-icon-secondary': 'var(--color-on-light)', + }, + }, }); diff --git a/apps/desktop/src/renderer/cached-theme-bootstrap.ts b/apps/desktop/src/renderer/cached-theme-bootstrap.ts index 10d9c473db..b66d4fb108 100644 --- a/apps/desktop/src/renderer/cached-theme-bootstrap.ts +++ b/apps/desktop/src/renderer/cached-theme-bootstrap.ts @@ -32,6 +32,11 @@ export function applyCachedThemeBeforeMount(): void { const shouldApplyDarkTheme = cachedThemePreference === 'dark' || (cachedThemePreference !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches); + // colorScheme is set on BOTH branches on purpose: the palette resolves its + // light-dark() pairs against it (DESIGN.md §8), so leaving it at the + // stylesheet's `light dark` would paint the first frame by OS preference + // rather than by the user's setting — the same flash this function exists to + // prevent, in the case where the two disagree. if (shouldApplyDarkTheme) { document.documentElement.classList.add('dark'); document.documentElement.style.colorScheme = 'dark'; diff --git a/apps/desktop/src/renderer/maka-tokens.css b/apps/desktop/src/renderer/maka-tokens.css index 03881e213d..595fb86440 100644 --- a/apps/desktop/src/renderer/maka-tokens.css +++ b/apps/desktop/src/renderer/maka-tokens.css @@ -75,8 +75,11 @@ a darker shell. Earlier warm-parchment / hue-80 "cream" experiments were superseded (de-yellowed to a cool-neutral hue, then flattened to plain white). --surface-canvas is what `.appFrame` paints behind everything; - --background is what cards paint. */ - --background: oklch(1.000 0 0); + --background is what cards paint. + + PR-GRAY-CARD-LIFT-0: dark mode follows the same neutral gray (hue 0, no + chroma) hierarchy. Lightness anchors keep existing contrast tuning. */ + --background: light-dark(oklch(1.000 0 0), oklch(0.205 0.004 286)); /* Derived from --background, not hardcoded (PALETTE-LEAK-0: palettes override --background and never this token, so a hardcoded zinc plate survived every palette switch). @@ -104,15 +107,34 @@ 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); + Nothing is forced to migrate here -- see DESIGN.md, Surface ladder. + + Dark inverts the direction, not the rule: higher is still lighter and the + reading surface still takes the brightest rung. The two rungs that read + the same recipe in both modes say it once; only the two that genuinely + differ carry a light-dark() pair. */ + --surface-sunken: light-dark( + oklch(from var(--background) calc(l - 0.055) c h), + 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); /* 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); + the recipe something to hang on: a semantic split, not a colour change. + + Visual System 2.0 (T4): dark's rung derives in oklch like the three above + it. It was the ladder's last `color-mix(in srgb, ...)`, which DESIGN.md §3 + named as the one known exception to the One Colorspace Rule and scheduled + for exactly that round. Two derivations in one family that read as the + same intent but resolve differently is the drift that rule exists to stop — + and the ladder is the family where it would matter most, since the rungs + are only meaningful relative to each other. Pinned to the measured value + the srgb mix produced (rgb 27,27,29 / OKLab L 0.223) so the unification + moves no pixels; the contract holds the number. */ + --surface-overlay: light-dark( + var(--surface-raised), + oklch(from var(--background) calc(l + 0.018) c h)); /* 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 @@ -133,9 +155,9 @@ Resolving these to `--surface-raised` would satisfy DESIGN.md §10's ban on literal surface colors and break all four in dark mode. That ban exists to stop OUR surfaces drifting off the ladder; none of these is one of our - surfaces. Mode-invariant on purpose — it deliberately has no `.dark` - override. If a fifth consumer appears that is really app chrome, it - belongs on the ladder instead. */ + surfaces. Mode-invariant on purpose — it deliberately carries one value + rather than a light-dark() pair. If a fifth consumer appears that is + really app chrome, it belongs on the ladder instead. */ --surface-paper: oklch(1 0 0); /* 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 @@ -144,8 +166,11 @@ warm hue-75 near-black made every derived gray read parchment- beige — fighting the glass/transparency language the shell is 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); + cool like the reference sidebar. + + Dark's 0.95: 0.92 measured 14.11:1, just under the 15:1 the reading tier + asks for. */ + --foreground: light-dark(oklch(0.17 0.005 286), oklch(0.95 0.004 286)); /* Prose has exactly two rungs — --foreground and --muted-foreground — and everything below them is size, weight and case. The two retired middle greys stay retired, enforced by ink-ladder-contract. See DESIGN.md §3. */ @@ -155,7 +180,7 @@ an independent green (connected/ok semantics); running/focus-ring/nav-active/selection follow --accent, while link text follows the solid tier below. */ - --accent: oklch(0.70 0.135 250); + --accent: light-dark(oklch(0.70 0.135 250), oklch(0.74 0.15 250)); /* The SOLID accent tier: a filled accent surface that carries TEXT, and accent-colored text/icons on a plain surface. --action (L0.85 chip) is too light to clear 1.4.3's 4.5:1 for text, so it cannot back a solid button @@ -172,8 +197,15 @@ tokyo-night 4.39:1 — under AA — with onedark 4.62:1 and nord 4.64:1 barely over. L0.48 puts all eleven at 5.15:1 or better (tokyo-night 5.15, onedark 5.37, nord 5.46, default 6.51). Dark stays at L0.76, which already ranges - 6.98-9.76:1 against the dark backgrounds. */ - --accent-solid: oklch(from var(--accent) 0.48 c h); + 6.98-9.76:1 against the dark backgrounds. + + Dark inverts the solid tier: the fill goes LIGHT and carries dark ink, + matching Astryx's own --color-on-accent (light-dark(#fff, #171717)). L0.76 + clears 4.5:1 against that ink for every palette (weakest is coral at + 7.16:1). */ + --accent-solid: light-dark( + oklch(from var(--accent) 0.48 c h), + oklch(from var(--accent) 0.76 c h)); --link: var(--accent-solid); --focus-ring: var(--accent); --status-running: var(--accent); @@ -191,14 +223,15 @@ "info" is a semantic slot, not a hue, and it paints with the accent — see the Astryx bridge at the bottom of this file. - All THREE are declared here, in one block, in both modes. Warning used to - sit thirty lines further down beside the -text variants, which is how it - came to be the only hue without a dark override: a regeneration that walks - "the status block" walks two of them and silently leaves the third - behind. Grouping is the fix, not a tidy-up. */ - --success: oklch(0.50 0.17 145); /* green — connected, ok */ - --destructive: oklch(0.50 0.24 28); /* red — errors, denied */ - --warning: oklch(0.50 0.18 55); /* orange — caution */ + All THREE are declared here, in one block, each carrying both modes. + Warning is the hue that proves why: it was the ONE status whose dark value + lived apart from its siblings, 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. + Its measured 5.44:1 is restored, and the structure that let it be missed is + gone — a hue cannot lose its dark side when the two sit in one value. */ + --success: light-dark(oklch(0.50 0.17 145), oklch(0.60 0.17 145)); /* green — connected, ok */ + --destructive: light-dark(oklch(0.50 0.24 28), oklch(0.70 0.19 22)); /* red — errors, denied */ + --warning: light-dark(oklch(0.50 0.18 55), oklch(0.66 0.18 55)); /* orange — caution */ /* Legacy brand affordance for a few emphasized surfaces. PR-THEME-APPLY-AND-DONE-POLISH-0 (WAWQAQ msg `dec85e5b`): was a static @@ -305,7 +338,13 @@ 68% ink measures #525153 / #a2a2a4, 7.9:1 / 7.0:1. Every one of the eleven palettes then clears AA in both modes — worst light is onedark 5.0:1, worst dark is onedark 6.1:1. Hierarchy below these two rungs comes from - size, weight and case — never a third grey. */ + size, weight and case — never a third grey. + + One mix serves both modes: it reads --foreground and --background, which + each carry their own pair, so the rung re-derives per mode — and per media + surface — without naming a percentage twice. The per-mode percentage this + used to need existed to keep a THIRD tier from colliding with muted; with + the middle rung gone there is nothing left to separate. */ --muted-foreground: color-mix(in oklch, var(--foreground) 68%, 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); @@ -332,9 +371,14 @@ re-tint the accent without ruining chat legibility. PR-GRAY-CARD-LIFT-0: bubble shifts to neutral gray (hue 0) matching the new gray-plate / white-card hierarchy. Slightly - darker than the white card so the bubble lifts. */ - --chat-user-bg: oklch(0.935 0 0); - --chat-user-foreground: var(--foreground); + darker than the white card so the bubble lifts. + Dark takes a slate bubble — distinct from --accent and readable against + the dark background. PR-DEFAULT-CANVAS-DEYELLOW-0: hue 250 to match the + cool-neutral canvas in dark mode too. */ + --chat-user-bg: light-dark(oklch(0.935 0 0), oklch(0.30 0.010 250)); + --chat-user-foreground: light-dark( + var(--foreground), + oklch(from var(--foreground) 0.96 min(c, 0.004) h)); --user-message-bubble: var(--chat-user-bg); --selection: oklch(from var(--accent) l c h / 0.2); @@ -380,10 +424,17 @@ because "shadow" invited call sites to reach for it when they wanted lift. Same pixels; the two leading transparent layers are kept so the shadow list's shape (and any transition against it) is unchanged. */ + /* Dark-mode shadow collapse: blur shadows are nearly invisible on a dark + shell but still cost compositing and, worse, the foreground-derived layers + turn WHITE-ish in dark mode and read as a glow. Elevation in dark mode is + carried by border rings (interface-design: "dark mode shadows collapse to a + single ring"); modal keeps one deep drop so overlays still separate from + the backdrop. So the ring is where the two modes meet, and only its COLOUR + differs — one recipe, one pair inside it. */ --ring-soft: rgba(0,0,0,0) 0 0 0 0, rgba(0,0,0,0) 0 0 0 0, - oklch(from var(--foreground) l c h / 0.06) 0 0 0 1px; + light-dark(oklch(from var(--foreground) l c h / 0.06), oklch(1 0 0 / 0.08)) 0 0 0 1px; /* === z-index scale (semantic) === */ --z-sticky: 20; @@ -907,94 +958,33 @@ } /* ============================================================================= - DARK MODE — toggle via `` + DARK MODE — `color-scheme`, set on beside `class="dark"` (theme.ts) + + Colours do NOT belong here. A colour that differs by mode carries both sides + in its own value, as `light-dark(light, dark)`, declared once above. + + The reason is `color-scheme`, not tidiness. Astryx inverts a surface by + setting `color-scheme: dark` on it (MediaTheme — every toast body, every + overlay scrim) and expects the tokens under it to follow. A selector cannot + follow: `.dark` matches , so a token declared here keeps the PAGE's + mode everywhere, including on a surface that just declared itself the other + one. That is how the error toast came to paint light-mode grey prose on its + red plate at 1.04:1. A `light-dark()` value resolves against whatever + color-scheme is in force where it is USED, so it follows the page and the + inverted surface both, for every token — including the ones nobody has + thought to check yet. + + What is left here: two alphas. `light-dark()` takes two colours, and a bare + number has nowhere else to express a mode. A shadow is not an exception — + only its colour differs, so the pair goes inside the recipe (--ring-soft). + + Enforced by ink-ladder-contract. ============================================================================= */ .dark { /* Designer audit P1-6: composer edge ring — see styles/composer.css. */ --composer-ring-alpha: 0.12; - /* PR-GRAY-CARD-LIFT-0: dark mode follows the same neutral gray - * (hue 0, no chroma) hierarchy. Lightness anchors keep existing - * contrast tuning. */ - --background: oklch(0.205 0.004 286); - /* PALETTE-LEAK-0: derived (see light-mode note). Same 0.025 step as light — - the hairline carries the boundary in both modes, so neither needs a wash - wide enough to do it alone. The 0.065 this carried before was never seen on - 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: 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); - /* Visual System 2.0 (T4): derived in oklch like the three rungs above it. - It was the ladder's last `color-mix(in srgb, ...)`, which DESIGN.md §3 - named as the one known exception to the One Colorspace Rule and scheduled - for exactly this round. Two derivations in one family that read as the - same intent but resolve differently is the drift that rule exists to stop — - and the ladder is the family where it would matter most, since the rungs - are only meaningful relative to each other. Pinned to the measured value - the srgb mix produced (rgb 27,27,29 / OKLab L 0.223) so the unification - moves no pixels; the contract holds the number. */ - --surface-overlay: oklch(from var(--background) calc(l + 0.018) c h); - --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); - /* No dark override for the muted rung: 68% ink is the one mix that puts both - modes on Astryx's --color-text-secondary (7.9:1 light, 7.0:1 dark on the - default palette). The per-mode percentage that used to live here existed - to keep a THIRD tier from colliding with muted; with the middle rung gone - there is nothing left to separate. */ - - --accent: oklch(0.74 0.15 250); - /* Dark mode keeps the same pale-blue CTA chip + deep-blue text as light - mode, so the "light, fresh button" reads consistently across themes. */ - /* Dark mode inverts the solid tier: the fill goes LIGHT and carries dark - ink, matching Astryx's own --color-on-accent (light-dark(#fff, #171717)). - L0.76 clears 4.5:1 against that ink for every palette (weakest is coral - at 7.16:1). See the light-mode note above for why --action cannot serve - this role. */ - --accent-solid: oklch(from var(--accent) 0.76 c h); - --link: var(--accent-solid); - --focus-ring: var(--accent); - --status-running: var(--accent); - /* All three together, same shape as the light block. Warning is the hue that - proves why: it was the ONE status without a dark override, 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. Its measured 5.44:1 is restored, and the structure - that let it be missed is gone. */ - --success: oklch(0.60 0.17 145); - --destructive: oklch(0.70 0.19 22); - --warning: oklch(0.66 0.18 55); - --opacity-overlay: 0.06; - - /* Dark-mode shadow collapse: blur shadows are nearly - invisible on a dark shell but still cost compositing and, worse, - the foreground-derived layers turn WHITE-ish in dark mode and read - as a glow. Elevation in dark mode is carried by border rings - (interface-design: "dark mode shadows collapse to a single ring"); - modal keeps one deep drop so overlays still separate from the - backdrop. */ - --ring-soft: - 0 0 0 1px oklch(1 0 0 / 0.08); - /* Slate user bubble for dark mode — distinct from --accent and readable - against the dark background. - PR-DEFAULT-CANVAS-DEYELLOW-0: hue 250 to match the cool-neutral - canvas in dark mode too. */ - --chat-user-bg: oklch(0.30 0.010 250); - --chat-user-foreground: oklch(from var(--foreground) 0.96 min(c, 0.004) h); - --user-message-bubble: var(--chat-user-bg); - - /* foreground-N variants re-derive automatically via color-mix(), so the - :root definitions above remain correct — no need to redefine here. */ - - /* Brand deep follows accent in dark mode too; hover is a brighter - variant for the darker surface. */ - --brand-deep: var(--accent); } /* ============================================================================= @@ -1027,7 +1017,8 @@ --------------------------------------------------------------- 4 alternative palettes a user can pick in Settings · 主题. Activated via `` etc. - Light + dark each have their own override block. `default` = + One block per palette, each colour carrying both modes — see the + DARK MODE note above for why a mode is never a selector here. `default` = no `data-maka-theme` attr (renders the original Maka palette). Each palette overrides only the 6 base colors + accent variants; @@ -1037,201 +1028,111 @@ /* --- Onedark (VS Code default-like) --- */ [data-maka-theme="onedark"] { - --background: oklch(0.95 0.005 250); - --foreground: oklch(0.30 0.02 250); - --accent: oklch(0.62 0.13 237); /* One Dark blue (#61AFEF family) */ - --success: oklch(0.62 0.16 145); - --destructive: oklch(0.62 0.20 22); - --user-message-bubble: oklch(0.92 0.012 250); - --chat-user-bg: var(--user-message-bubble); -} -[data-maka-theme="onedark"].dark { - --background: oklch(0.21 0.010 250); - --foreground: oklch(0.90 0.010 250); - --accent: oklch(0.70 0.13 237); - --success: oklch(0.65 0.16 145); - --destructive: oklch(0.70 0.18 22); - --user-message-bubble: oklch(0.30 0.012 250); + --background: light-dark(oklch(0.95 0.005 250), oklch(0.21 0.010 250)); + --foreground: light-dark(oklch(0.30 0.02 250), oklch(0.90 0.010 250)); + --accent: light-dark(oklch(0.62 0.13 237), oklch(0.70 0.13 237)); /* One Dark blue (#61AFEF family) */ + --success: light-dark(oklch(0.62 0.16 145), oklch(0.65 0.16 145)); + --destructive: light-dark(oklch(0.62 0.20 22), oklch(0.70 0.18 22)); + --user-message-bubble: light-dark(oklch(0.92 0.012 250), oklch(0.30 0.012 250)); --chat-user-bg: var(--user-message-bubble); } /* --- Catppuccin Mocha (warm pastels) --- */ [data-maka-theme="catppuccin-mocha"] { - --background: oklch(0.96 0.008 60); - --foreground: oklch(0.28 0.02 300); - --accent: oklch(0.65 0.18 330); /* mauve / pink */ - --success: oklch(0.62 0.14 145); - --destructive: oklch(0.62 0.20 25); - --user-message-bubble: oklch(0.94 0.012 60); - --chat-user-bg: var(--user-message-bubble); -} -[data-maka-theme="catppuccin-mocha"].dark { - --background: oklch(0.22 0.020 290); - --foreground: oklch(0.92 0.012 320); - --accent: oklch(0.78 0.16 330); - --success: oklch(0.72 0.14 145); - --destructive: oklch(0.72 0.18 22); - --user-message-bubble: oklch(0.30 0.020 290); + --background: light-dark(oklch(0.96 0.008 60), oklch(0.22 0.020 290)); + --foreground: light-dark(oklch(0.28 0.02 300), oklch(0.92 0.012 320)); + --accent: light-dark(oklch(0.65 0.18 330), oklch(0.78 0.16 330)); /* mauve / pink */ + --success: light-dark(oklch(0.62 0.14 145), oklch(0.72 0.14 145)); + --destructive: light-dark(oklch(0.62 0.20 25), oklch(0.72 0.18 22)); + --user-message-bubble: light-dark(oklch(0.94 0.012 60), oklch(0.30 0.020 290)); --chat-user-bg: var(--user-message-bubble); } /* --- Tokyo Night (bluish dark) --- */ [data-maka-theme="tokyo-night"] { - --background: oklch(0.97 0.005 240); - --foreground: oklch(0.25 0.015 240); - --accent: oklch(0.55 0.16 215); /* cool sky blue */ - --success: oklch(0.62 0.14 145); - --destructive: oklch(0.62 0.20 22); - --user-message-bubble: oklch(0.93 0.010 240); - --chat-user-bg: var(--user-message-bubble); -} -[data-maka-theme="tokyo-night"].dark { - --background: oklch(0.18 0.020 250); - --foreground: oklch(0.90 0.010 240); - --accent: oklch(0.70 0.18 215); /* brighter sky blue */ - --success: oklch(0.68 0.16 145); - --destructive: oklch(0.70 0.19 22); - --user-message-bubble: oklch(0.28 0.018 250); + --background: light-dark(oklch(0.97 0.005 240), oklch(0.18 0.020 250)); + --foreground: light-dark(oklch(0.25 0.015 240), oklch(0.90 0.010 240)); + --accent: light-dark(oklch(0.55 0.16 215), oklch(0.70 0.18 215)); /* cool sky blue, brighter in dark */ + --success: light-dark(oklch(0.62 0.14 145), oklch(0.68 0.16 145)); + --destructive: light-dark(oklch(0.62 0.20 22), oklch(0.70 0.19 22)); + --user-message-bubble: light-dark(oklch(0.93 0.010 240), oklch(0.28 0.018 250)); --chat-user-bg: var(--user-message-bubble); } /* --- Nord (cool nordic) --- */ [data-maka-theme="nord"] { - --background: oklch(0.97 0.005 220); - --foreground: oklch(0.28 0.015 220); - --accent: oklch(0.62 0.12 215); /* glacier blue */ - --success: oklch(0.68 0.12 145); - --destructive: oklch(0.62 0.16 22); - --user-message-bubble: oklch(0.93 0.008 220); - --chat-user-bg: var(--user-message-bubble); -} -[data-maka-theme="nord"].dark { - --background: oklch(0.24 0.012 220); - --foreground: oklch(0.92 0.008 220); - --accent: oklch(0.72 0.14 215); - --success: oklch(0.72 0.13 145); - --destructive: oklch(0.72 0.17 22); - --user-message-bubble: oklch(0.32 0.014 220); + --background: light-dark(oklch(0.97 0.005 220), oklch(0.24 0.012 220)); + --foreground: light-dark(oklch(0.28 0.015 220), oklch(0.92 0.008 220)); + --accent: light-dark(oklch(0.62 0.12 215), oklch(0.72 0.14 215)); /* glacier blue */ + --success: light-dark(oklch(0.68 0.12 145), oklch(0.72 0.13 145)); + --destructive: light-dark(oklch(0.62 0.16 22), oklch(0.72 0.17 22)); + --user-message-bubble: light-dark(oklch(0.93 0.008 220), oklch(0.32 0.014 220)); --chat-user-bg: var(--user-message-bubble); } /* --- coral (warm coral / pink accent) --- */ [data-maka-theme="coral"] { - --background: oklch(0.99 0.005 25); - --foreground: oklch(0.22 0.015 25); - --accent: oklch(0.68 0.18 25); /* coral / warm pink */ - --success: oklch(0.68 0.14 145); - --destructive: oklch(0.60 0.20 18); - --user-message-bubble: oklch(0.95 0.012 25); - --chat-user-bg: var(--user-message-bubble); -} -[data-maka-theme="coral"].dark { - --background: oklch(0.22 0.018 25); - --foreground: oklch(0.94 0.008 25); - --accent: oklch(0.76 0.18 25); - --success: oklch(0.72 0.13 145); - --destructive: oklch(0.70 0.20 18); - --user-message-bubble: oklch(0.30 0.020 25); + --background: light-dark(oklch(0.99 0.005 25), oklch(0.22 0.018 25)); + --foreground: light-dark(oklch(0.22 0.015 25), oklch(0.94 0.008 25)); + --accent: light-dark(oklch(0.68 0.18 25), oklch(0.76 0.18 25)); /* coral / warm pink */ + --success: light-dark(oklch(0.68 0.14 145), oklch(0.72 0.13 145)); + --destructive: light-dark(oklch(0.60 0.20 18), oklch(0.70 0.20 18)); + --user-message-bubble: light-dark(oklch(0.95 0.012 25), oklch(0.30 0.020 25)); --chat-user-bg: var(--user-message-bubble); } /* --- azure (cool product blue accent) --- */ [data-maka-theme="azure"] { - --background: oklch(0.98 0.005 250); - --foreground: oklch(0.20 0.015 250); - --accent: oklch(0.55 0.12 195); /* lake cyan */ - --success: oklch(0.66 0.15 150); - --destructive: oklch(0.62 0.18 22); - --user-message-bubble: oklch(0.94 0.010 250); - --chat-user-bg: var(--user-message-bubble); -} -[data-maka-theme="azure"].dark { - --background: oklch(0.20 0.015 250); - --foreground: oklch(0.93 0.010 250); - --accent: oklch(0.72 0.13 195); - --success: oklch(0.72 0.14 150); - --destructive: oklch(0.72 0.18 22); - --user-message-bubble: oklch(0.28 0.018 250); + --background: light-dark(oklch(0.98 0.005 250), oklch(0.20 0.015 250)); + --foreground: light-dark(oklch(0.20 0.015 250), oklch(0.93 0.010 250)); + --accent: light-dark(oklch(0.55 0.12 195), oklch(0.72 0.13 195)); /* lake cyan */ + --success: light-dark(oklch(0.66 0.15 150), oklch(0.72 0.14 150)); + --destructive: light-dark(oklch(0.62 0.18 22), oklch(0.72 0.18 22)); + --user-message-bubble: light-dark(oklch(0.94 0.010 250), oklch(0.28 0.018 250)); --chat-user-bg: var(--user-message-bubble); } /* --- forest (earthy moss-green accent on warm sage canvas) --- */ [data-maka-theme="forest"] { - --background: oklch(0.985 0.006 130); - --foreground: oklch(0.22 0.018 145); - --accent: oklch(0.50 0.12 152); /* deep moss green */ - --success: oklch(0.62 0.14 145); - --destructive: oklch(0.58 0.20 22); - --user-message-bubble: oklch(0.94 0.012 130); - --chat-user-bg: var(--user-message-bubble); -} -[data-maka-theme="forest"].dark { - --background: oklch(0.21 0.012 145); - --foreground: oklch(0.93 0.010 130); - --accent: oklch(0.68 0.14 152); - --success: oklch(0.70 0.14 145); - --destructive: oklch(0.70 0.20 22); - --user-message-bubble: oklch(0.30 0.014 145); + --background: light-dark(oklch(0.985 0.006 130), oklch(0.21 0.012 145)); + --foreground: light-dark(oklch(0.22 0.018 145), oklch(0.93 0.010 130)); + --accent: light-dark(oklch(0.50 0.12 152), oklch(0.68 0.14 152)); /* deep moss green */ + --success: light-dark(oklch(0.62 0.14 145), oklch(0.70 0.14 145)); + --destructive: light-dark(oklch(0.58 0.20 22), oklch(0.70 0.20 22)); + --user-message-bubble: light-dark(oklch(0.94 0.012 130), oklch(0.30 0.014 145)); --chat-user-bg: var(--user-message-bubble); } /* --- dusk (deep violet accent on a cool twilight canvas) --- */ [data-maka-theme="dusk"] { - --background: oklch(0.97 0.008 290); - --foreground: oklch(0.22 0.018 295); - --accent: oklch(0.55 0.18 305); /* deep violet */ - --success: oklch(0.64 0.14 145); - --destructive: oklch(0.60 0.20 18); - --user-message-bubble: oklch(0.93 0.013 290); - --chat-user-bg: var(--user-message-bubble); -} -[data-maka-theme="dusk"].dark { - --background: oklch(0.20 0.018 295); - --foreground: oklch(0.92 0.010 290); - --accent: oklch(0.72 0.18 305); - --success: oklch(0.70 0.14 145); - --destructive: oklch(0.70 0.20 18); - --user-message-bubble: oklch(0.30 0.020 295); + --background: light-dark(oklch(0.97 0.008 290), oklch(0.20 0.018 295)); + --foreground: light-dark(oklch(0.22 0.018 295), oklch(0.92 0.010 290)); + --accent: light-dark(oklch(0.55 0.18 305), oklch(0.72 0.18 305)); /* deep violet */ + --success: light-dark(oklch(0.64 0.14 145), oklch(0.70 0.14 145)); + --destructive: light-dark(oklch(0.60 0.20 18), oklch(0.70 0.20 18)); + --user-message-bubble: light-dark(oklch(0.93 0.013 290), oklch(0.30 0.020 295)); --chat-user-bg: var(--user-message-bubble); } /* --- sand (warm cream canvas + amber accent — the warm legacy) --- */ [data-maka-theme="sand"] { - --background: oklch(0.98 0.008 75); - --foreground: oklch(0.22 0.014 70); - --accent: oklch(0.62 0.14 55); /* amber / ochre */ - --success: oklch(0.62 0.14 145); - --destructive: oklch(0.60 0.20 22); - --user-message-bubble: oklch(0.94 0.012 75); - --chat-user-bg: var(--user-message-bubble); -} -[data-maka-theme="sand"].dark { - --background: oklch(0.22 0.014 70); - --foreground: oklch(0.93 0.010 75); - --accent: oklch(0.72 0.14 55); - --success: oklch(0.70 0.14 145); - --destructive: oklch(0.70 0.20 22); - --user-message-bubble: oklch(0.30 0.014 70); + --background: light-dark(oklch(0.98 0.008 75), oklch(0.22 0.014 70)); + --foreground: light-dark(oklch(0.22 0.014 70), oklch(0.93 0.010 75)); + --accent: light-dark(oklch(0.62 0.14 55), oklch(0.72 0.14 55)); /* amber / ochre */ + --success: light-dark(oklch(0.62 0.14 145), oklch(0.70 0.14 145)); + --destructive: light-dark(oklch(0.60 0.20 22), oklch(0.70 0.20 22)); + --user-message-bubble: light-dark(oklch(0.94 0.012 75), oklch(0.30 0.014 70)); --chat-user-bg: var(--user-message-bubble); } /* --- mono (pure grayscale, distraction-free) --- */ [data-maka-theme="mono"] { - --background: oklch(0.985 0 0); - --foreground: oklch(0.18 0 0); - --accent: oklch(0.30 0 0); /* near-black */ - --success: oklch(0.55 0 0); - --destructive: oklch(0.45 0.18 22); /* keep destructive readable */ - --user-message-bubble: oklch(0.93 0 0); - --chat-user-bg: var(--user-message-bubble); -} -[data-maka-theme="mono"].dark { - --background: oklch(0.18 0 0); - --foreground: oklch(0.95 0 0); - --accent: oklch(0.92 0 0); /* near-white */ - --success: oklch(0.70 0 0); - --destructive: oklch(0.65 0.18 22); - --user-message-bubble: oklch(0.28 0 0); + --background: light-dark(oklch(0.985 0 0), oklch(0.18 0 0)); + --foreground: light-dark(oklch(0.18 0 0), oklch(0.95 0 0)); + --accent: light-dark(oklch(0.30 0 0), oklch(0.92 0 0)); /* near-black / near-white */ + --success: light-dark(oklch(0.55 0 0), oklch(0.70 0 0)); + --destructive: light-dark(oklch(0.45 0.18 22), oklch(0.65 0.18 22)); /* keep destructive readable */ + --user-message-bubble: light-dark(oklch(0.93 0 0), oklch(0.28 0 0)); --chat-user-bg: var(--user-message-bubble); } diff --git a/apps/desktop/src/renderer/theme.ts b/apps/desktop/src/renderer/theme.ts index 438ad1b85a..bbfaaebe85 100644 --- a/apps/desktop/src/renderer/theme.ts +++ b/apps/desktop/src/renderer/theme.ts @@ -151,8 +151,17 @@ export function applyTheme(pref: ThemePreference): () => void { function setDarkClass(isDark: boolean): void { const root = document.documentElement; root.classList.toggle(DARK_CLASS, isDark); - // Lets native form controls and scrollbars pick up the right base colors per - // the Vercel Web Interface Guidelines dark-mode rule. + // This is what picks the mode, not just what tells native form controls and + // scrollbars about it: every palette colour is a `light-dark()` pair that + // resolves against color-scheme (maka-tokens.css, DESIGN.md §8). It must stay + // in lockstep with the class — which also carries the mode to Astryx — and it + // must keep being set before the first paint (cached-theme-bootstrap.ts). + // + // Inside the app, Astryx's re-declares color-scheme on its own + // wrapper from the class (astryx-theme-mode.ts), so the subtree turns over on + // that React commit rather than on this line. That is one repaint later and + // it is the whole switch, not half of it — which is the point: the palette + // and Astryx's own tokens can no longer disagree for a frame. root.style.colorScheme = isDark ? 'dark' : 'light'; syncTitleBarOverlay(root); } diff --git a/packages/ui/stories/palette-matrix.stories.tsx b/packages/ui/stories/palette-matrix.stories.tsx index ed2de0e777..ab59534738 100644 --- a/packages/ui/stories/palette-matrix.stories.tsx +++ b/packages/ui/stories/palette-matrix.stories.tsx @@ -36,7 +36,6 @@ * only, so without this story ten palettes have no oracle whatsoever. */ import type { Meta, StoryObj } from '@storybook/react-vite'; -import { useSyncExternalStore } from 'react'; import { THEME_PALETTES } from '../../../packages/core/src/settings.js'; const meta = { @@ -48,25 +47,6 @@ export default meta; type Story = StoryObj; -function subscribe(callback: () => void): () => void { - const el = document.documentElement; - const observer = new MutationObserver(callback); - observer.observe(el, { attributes: true, attributeFilter: ['class'] }); - return () => observer.disconnect(); -} - -function getSnapshot(): boolean { - return document.documentElement.classList.contains('dark'); -} - -function getServerSnapshot(): boolean { - return false; -} - -function useIsDark(): boolean { - return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); -} - const paletteTokens = [ ['background', '--background'], ['foreground', '--foreground'], @@ -78,7 +58,8 @@ const paletteTokens = [ export const AllPalettes: Story = { render: () => { - const isDark = useIsDark(); + // No mode plumbing here: a palette block carries both modes in its values + // and resolves against the color-scheme the toolbar sets on the root. return (
@@ -98,7 +79,6 @@ export const AllPalettes: Story = {
Date: Thu, 3 Sep 2026 11:39:38 +0800 Subject: [PATCH 2/3] fix(design-system): sample the titlebar color off what paints it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving the mode into the value made a token declaration stop being a colour: `getComputedStyle(root).getPropertyValue('--background')` now reads back `light-dark(oklch(1.000 0 0), oklch(0.205 0.004 286))`, a recipe that only resolves where it is used. The Windows titlebar sampler fed that string to a canvas, and both of its guards passed it: `CSS.supports('color', …)` accepts a light-dark() pair, and the rejected `fillStyle` assignment left the canvas at its default opaque black, whose alpha is 255. Measured in Chromium 151, light and dark both sampled `#000000` — so the native control strip went black in every palette, putting the light-mode symbol colour `#1c1d21` on it at 1.25:1 and, in dark mode, seaming a black strip against a `#171719` app background. That seam is the whole reason this code samples instead of hard-coding a pair. The colour comes off a painted element now — `getComputedStyle().backgroundColor` is a used value, so the engine has already picked the branch, the same read `readModalBackdropColor` already makes. That element is the root: `--background` moves from `body` to `html`, which paints identically (whichever of the two declares it propagates to the canvas) and leaves the strip's colour on the surface the strip actually sits over, rather than on a child the sampler would have to go find. The helper takes an element rather than a string, so an unresolved declaration has nowhere to enter, and `CSS.supports` goes with it. Its replacement is a transparent starting fillStyle: an assignment the canvas cannot parse now reads back at alpha 0 and takes the fallback, for any input, instead of sampling the black it defaulted to. Verified across palettes and both modes — default #ffffff/#171719, onedark #eceff2/#15191d. ink-ladder-contract gains the rule rather than the instance — no product source reads a custom property as a colour at all. There were none besides this one. Generated-by: Claude Code --- DESIGN.md | 2 +- .../__tests__/ink-ladder-contract.test.ts | 24 ++++++++++++++ apps/desktop/src/renderer/maka-tokens.css | 12 ++++++- apps/desktop/src/renderer/theme.ts | 32 +++++++++++-------- 4 files changed, 55 insertions(+), 15 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 9d1b185365..168d714711 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -177,7 +177,7 @@ Use the system UI stack with explicit platform CJK fallbacks; Geist Variable is The palette is cool-neutral and quiet; color is generated to spec, not picked by eye. -**The Mode Lives in the Value Rule.** A colour that differs between light and dark carries both sides in its own value — `light-dark(light, dark)`, declared once — and never in a `.dark` selector. This is not tidiness: Astryx inverts a surface by setting `color-scheme: dark` on it (every toast body, every overlay scrim), and a selector matched on `` cannot follow. A token declared under `.dark` keeps the page's mode on a surface that just declared itself the other one, which is how the error toast came to paint light-mode grey prose on its red plate at 1.04:1. A `light-dark()` value resolves against whatever `color-scheme` is in force where it is *used*, so it follows the page and the inverted surface both — including for tokens nobody has thought to check. Two alphas have nowhere to express a mode and stay on `.dark`; a shadow is not an exception, since only its colour differs and the pair goes inside the recipe. `color-scheme` is set on `` beside the class — before the first paint, and on every change — and Astryx's `` re-declares it on its own wrapper from that class, so the app's subtree turns over on that React commit: one repaint later than the root, and as a whole rather than half at a time. Contract-tested over every token `makaTheme.ts` aliases and everything those transitively read. +**The Mode Lives in the Value Rule.** A colour that differs between light and dark carries both sides in its own value — `light-dark(light, dark)`, declared once — and never in a `.dark` selector. This is not tidiness: Astryx inverts a surface by setting `color-scheme: dark` on it (every toast body, every overlay scrim), and a selector matched on `` cannot follow. A token declared under `.dark` keeps the page's mode on a surface that just declared itself the other one, which is how the error toast came to paint light-mode grey prose on its red plate at 1.04:1. A `light-dark()` value resolves against whatever `color-scheme` is in force where it is *used*, so it follows the page and the inverted surface both — including for tokens nobody has thought to check. Two alphas have nowhere to express a mode and stay on `.dark`; a shadow is not an exception, since only its colour differs and the pair goes inside the recipe. `color-scheme` is set on `` beside the class — before the first paint, and on every change — and Astryx's `` re-declares it on its own wrapper from that class, so the app's subtree turns over on that React commit: one repaint later than the root, and as a whole rather than half at a time. The corollary for JavaScript: a token now reads back as a recipe, not a colour, so nothing may take `getPropertyValue('--x')` and treat the result as one — code that needs a colour reads `getComputedStyle` off whatever paints it — which is why `--background` is declared on `html` rather than `body`, so the Windows titlebar has a painted root to sample. That failure is silent in both directions: `CSS.supports('color', …)` accepts a `light-dark()` pair, and a canvas ignores the `fillStyle` assignment and keeps its default opaque black. Contract-tested over every token `makaTheme.ts` aliases and everything those transitively read. - **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). Astryx's own semantic components are the exception — `Badge` and `StatusDot` carry fixed literals inherited from the neutral theme and follow neither the palette nor the families below. diff --git a/apps/desktop/src/main/__tests__/ink-ladder-contract.test.ts b/apps/desktop/src/main/__tests__/ink-ladder-contract.test.ts index 6d6c528489..0fcd270c04 100644 --- a/apps/desktop/src/main/__tests__/ink-ladder-contract.test.ts +++ b/apps/desktop/src/main/__tests__/ink-ladder-contract.test.ts @@ -193,4 +193,28 @@ describe('mode expression', () => { 'an inverted surface has one ink tier: secondary takes the same on-color as primary', ); }); + + it('never reads a palette token as a colour', async () => { + // A token's declared value stopped being a colour when the mode moved into + // it: `light-dark()` resolves at the use site, so the declaration is a + // recipe. Handing one to something that wants a colour fails silently — + // `CSS.supports('color', …)` says yes and a canvas fillStyle ignores the + // assignment, which is how the Windows titlebar sampled opaque black. + // Read `getComputedStyle(element)` off whatever paints the token instead. + const files = (await Promise.all(SOURCE_ROOTS.map(sourceFilesUnder))).flat(); + const offenders: string[] = []; + for (const file of files) { + if (!file.endsWith('.ts') && !file.endsWith('.tsx')) continue; + const source = withoutComments(await readFile(file, 'utf8')); + for (const match of source.matchAll(/getPropertyValue\(\s*['"`](--[a-z0-9-]+)/g)) { + offenders.push(`${relative(REPO_ROOT, file)} → ${match[1]}`); + } + } + + assert.deepEqual( + offenders, + [], + 'a custom property reads back as its declaration, not as a resolved value', + ); + }); }); diff --git a/apps/desktop/src/renderer/maka-tokens.css b/apps/desktop/src/renderer/maka-tokens.css index 595fb86440..df9fdec380 100644 --- a/apps/desktop/src/renderer/maka-tokens.css +++ b/apps/desktop/src/renderer/maka-tokens.css @@ -1296,6 +1296,17 @@ font-family: var(--font-family-code); } + html { + /* The application's ground, declared on the root rather than on `body`. + Either one propagates to the canvas, so the two paint identically — but + the root is the surface the native Windows titlebar strip has to match, + and theme.ts samples it from here. It has to sample a painted element: + a token's declared value is a `light-dark()` recipe, not a colour + (DESIGN.md §8). On `body` this left the root transparent, with nothing + for the sampler to read. */ + background: var(--background); + } + body { /* The document's default text role, and the one site where declaring it matters most: Astryx's reset puts `line-height: 1.5` on `:where(html)`, @@ -1309,7 +1320,6 @@ rules change nothing when removed, and they inherit their role from this line. */ font: var(--maka-text-body); - background: var(--background); color: var(--foreground); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; diff --git a/apps/desktop/src/renderer/theme.ts b/apps/desktop/src/renderer/theme.ts index bbfaaebe85..2a2629c2e2 100644 --- a/apps/desktop/src/renderer/theme.ts +++ b/apps/desktop/src/renderer/theme.ts @@ -211,13 +211,10 @@ export function applyThemePalette(palette: ThemePalette): void { function syncTitleBarOverlay(root: HTMLElement): void { // The native Windows overlay sits on top of the renderer's content surface. - // Sample the actual resolved --background color instead of approximating it + // Sample the actual painted --background color instead of approximating it // with one hard-coded light and dark pair; this also follows every palette. const isDark = root.classList.contains(DARK_CLASS); - const backgroundColor = cssColorToHex( - getComputedStyle(root).getPropertyValue('--background'), - isDark ? '#1c1d21' : '#ffffff', - ); + const backgroundColor = paintedBackgroundToHex(root, isDark ? '#1c1d21' : '#ffffff'); void window.maka?.appWindow ?.setTitleBarOverlayTheme?.({ isDark, @@ -231,9 +228,7 @@ function syncTitleBarOverlay(root: HTMLElement): void { /** * The color the titlebar strip appears under an open modal: the dialog * backdrop scrim composited over `--background`. The scrim is sampled from - * the open modal's own ::backdrop — the engine has already resolved its - * `var()` indirection and `light-dark()` branch, which neither a token read - * nor a canvas fillStyle can do — so the dim tracks theme and palette + * the open modal's own ::backdrop, so the dim tracks theme and palette * automatically. */ function dimmedTitlebarColor(backgroundHex: string, isDark: boolean): string { @@ -254,17 +249,28 @@ function readModalBackdropColor(): { r: number; g: number; b: number; a: number return parseCssRgbColor(getComputedStyle(dialog, '::backdrop').backgroundColor); } -function cssColorToHex(value: string, fallback: string): string { - const color = value.trim(); - if (!color || !CSS.supports('color', color)) return fallback; - +/** + * The opaque color an element is painted, as hex. Takes the element rather + * than a color string because a palette token's declared value is not a + * color: `--background` is a `light-dark()` pair that only becomes one where + * it is used (DESIGN.md §8), and a canvas cannot resolve that — nor anything + * else that needs an element's context. Reading `background-color` off the + * element that paints it (`html`, maka-tokens.css) hands the canvas an + * already-resolved color. + */ +function paintedBackgroundToHex(element: Element, fallback: string): string { const canvas = document.createElement('canvas'); canvas.width = 1; canvas.height = 1; const context = canvas.getContext('2d', { willReadFrequently: true }); if (!context) return fallback; - context.fillStyle = color; + // A fillStyle the canvas cannot parse is ignored, leaving the previous value + // in place — so start transparent. Anything unparseable then reads back at + // alpha 0 and takes the fallback, rather than sampling the opaque black that + // fillStyle defaults to. + context.fillStyle = 'rgba(0, 0, 0, 0)'; + context.fillStyle = getComputedStyle(element).backgroundColor; context.fillRect(0, 0, 1, 1); const [red, green, blue, alpha] = context.getImageData(0, 0, 1, 1).data; if (alpha !== 255) return fallback; From aa55e0a303adeec9d45fedfcc4b37c46e4e0fd19 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 3 Sep 2026 12:14:32 +0800 Subject: [PATCH 3/3] docs(design-system): state what actually forces one ink tier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The record on the ink collapse rested on an analogy — a solid inverted surface as the 0.24 tint turned all the way up. The analogy is not what forces the result and does not survive being reused: a tint over `--background` and a surface that has flipped its whole `color-scheme` are mechanically different, so a reader who follows it back the other way gets a conclusion the evidence does not support. What forces it is structural: `--color-on-dark` and `--color-on-light` are one flat value each, shared by every inverted surface, so a rung muted against a particular plate cannot be written — the choice is one tier or an unmuted grey that ignores the surface under it. The 2.99:1 measurement agrees with that rather than deciding it. DESIGN.md §3 and makaTheme.ts now say so, and §3 says outright that the two halves of the rule are not evidence for each other. `applyThemePalette`'s comment still told the next caller that a palette's light and dark variants switch on the `.dark` class. They switch on `color-scheme` now, and a palette carries both modes in each value. Generated-by: Claude Code --- DESIGN.md | 2 +- .../src/renderer/astryx-theme/makaTheme.ts | 16 ++++++++-------- apps/desktop/src/renderer/theme.ts | 6 ++++-- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 168d714711..3b6c7bfba3 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -111,7 +111,7 @@ Depth is a ladder, not a decoration. Every background resolves to one of four se Prose uses exactly two tiers — `--foreground` and `--muted-foreground` — which is Astryx's own shape. `--muted-foreground` is 68% ink in `oklch`, one mix for both modes, chosen so the default palette lands on Astryx's `--color-text-secondary` (`#525252` / `#a3a3a3`, 7.8:1 / 7.1:1) and so all eleven palettes clear WCAG AA in both modes **against `--background`** (and `--surface-raised`, which the two prose tiers are measured on) — the worst is onedark light at 5.0:1. Any new tier must hold that floor on every palette, which is exactly what the retired middle rung could not do. - **The Two-Tier Reading Rule.** Prose is primary or muted. Hierarchy below that comes from size, weight and case — never a third grey. Neutral washes are surfaces, not extra text tiers. `--foreground-secondary` and `--foreground-dimmed` are retired and must not return, as a definition or as a call site (contract-tested). Both retirements are the same lesson: dimmed and secondary were both written as "80% ink" in different colorspaces and resolved 10.51:1 against 12.01:1, and secondary then forced muted down to 54% ink, where it failed AA in light mode on nine of the eleven palettes. -- **The Tinted Surface Rule.** A tinted surface (`--color-*-muted`) is a different reading surface from `--background`, and the AA numbers in this section are measured against `--background`. On a 0.24 tint neither `--muted-foreground` nor `--accent-solid` holds the floor in dark mode on any palette — muted lands under 2.9:1 everywhere, and the solid accent under 3.75:1. Text on a tint is `--foreground`; hierarchy there comes from weight, not from a second ink tier. **A solid inverted surface is the same rule with the tint turned all the way up** — a toast body, an overlay scrim, anything Astryx wraps in `MediaTheme`. There the muted rung has nothing to be muted against: on the error toast's `#AA071E` it measures 2.99:1, which is where Astryx's own secondary lands too. So the tier collapses there as well, via `onDark` / `onLight` in `makaTheme.ts` pointing text and icon secondary at the same on-color as primary. This is a deliberate deviation from upstream, not a repair of one. +- **The Tinted Surface Rule.** A tinted surface (`--color-*-muted`) is a different reading surface from `--background`, and the AA numbers in this section are measured against `--background`. On a 0.24 tint neither `--muted-foreground` nor `--accent-solid` holds the floor in dark mode on any palette — muted lands under 2.9:1 everywhere, and the solid accent under 3.75:1. Text on a tint is `--foreground`; hierarchy there comes from weight, not from a second ink tier. **A solid inverted surface takes one tier too, and for a structural reason rather than by analogy to the tint** — a toast body, an overlay scrim, anything Astryx wraps in `MediaTheme`. `--color-on-dark` and `--color-on-light` are one flat value each, shared by every inverted surface: a rung muted *against this plate* is not expressible with the tokens that exist, so the alternative to collapsing is not a second tier but an unmuted grey that ignores the surface it sits on. The measurement agrees rather than decides — on the error toast's `#AA071E` the muted rung reaches 2.99:1, where Astryx's own secondary lands too. The tier collapses via `onDark` / `onLight` in `makaTheme.ts` pointing text and icon secondary at the same on-color as primary: a deliberate deviation from upstream, not a repair of one. **The two halves of this rule are not evidence for each other.** A 0.24 tint over `--background` and a surface that has flipped its whole `color-scheme` are mechanically different situations that happen to reach the same answer; do not carry a conclusion from one to the other. - **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, and the ladder is where it bites hardest because its rungs only mean anything relative to each other. - **Astryx text/icon primary and secondary ARE this ladder.** `makaTheme.ts` tokens point `--color-text-primary` / `--color-icon-primary` at `--foreground` and `--color-text-secondary` / `--color-icon-secondary` at `--muted-foreground`, at the theme root. No bridge, no third value: `Text color="secondary"` and `color: var(--muted-foreground)` are the same grey everywhere in the app, except the deliberate inversions — the on-dark/on-light blocks and the four status banners, which re-declare the pair on the element itself. Pointing an Astryx token at a product var carries an obligation the next such alias inherits: the var must encode its own modes in its value (§8), or it stops following the surface it is painted on. - **`--color-text-disabled` is a deliberate exemption, not an oversight.** Astryx's `light-dark(#a3a3a3, #525252)` measures 2.52:1 / 2.29:1 — under the AA floor the two prose tiers hold. It stays: a disabled control read at prose contrast stops reading as disabled, and the versions of this token that clear 4.5:1 looked wrong beside the enabled rows they sit in. It is not prose and it is not a third tier; it is the one place the floor is traded for the state being legible *as a state*. Do not "fix" it, and do not cite it as precedent for another sub-AA value — an audit that finds it has found the exemption, not a bug. diff --git a/apps/desktop/src/renderer/astryx-theme/makaTheme.ts b/apps/desktop/src/renderer/astryx-theme/makaTheme.ts index 18fc34398e..69a9d2a06e 100644 --- a/apps/desktop/src/renderer/astryx-theme/makaTheme.ts +++ b/apps/desktop/src/renderer/astryx-theme/makaTheme.ts @@ -222,14 +222,14 @@ export const makaTheme = defineTheme({ '--radius-page': '28px', }, // Solid inverted surfaces carry ONE ink tier — DESIGN.md §3, the Tinted - // Surface Rule, which already says the same thing about the 0.24 tints and - // is already spelled this way on `.astryx-banner.info`. A toast body or an - // overlay scrim is the same situation with the tint turned all the way up: - // the surface is not `--background`, so the AA numbers the two prose tiers - // hold do not apply to it, and the muted rung has nowhere to be muted - // against. On the error toast's #AA071E it measures 2.99:1 — the number - // Astryx's own secondary reaches there too, so this is a deliberate - // deviation, not a repair. + // Surface Rule. Not by analogy with the 0.24 tints: the reason here is that + // --color-on-dark and --color-on-light are one flat value each, shared by + // every inverted surface, so a rung muted against THIS plate cannot be + // written at all. The choice is one tier or an unmuted grey that ignores the + // surface under it. The measurement agrees rather than decides — on the error + // toast's #AA071E the muted rung reaches 2.99:1, the number Astryx's own + // secondary reaches there too — so this is a deliberate deviation, not a + // repair. // // Astryx's defaults already point text/icon PRIMARY at the on-colors here; // naming secondary the same value is what collapses the tier. Everything diff --git a/apps/desktop/src/renderer/theme.ts b/apps/desktop/src/renderer/theme.ts index 2a2629c2e2..46894cf440 100644 --- a/apps/desktop/src/renderer/theme.ts +++ b/apps/desktop/src/renderer/theme.ts @@ -192,8 +192,10 @@ export function setTitlebarModalDimmed(dimmed: boolean): void { * live in `maka-tokens.css`. `default` removes the attribute so the * original Maka palette renders. * - * Light/dark variants of each palette switch automatically with the - * existing `.dark` class — no separate IPC needed. + * A palette carries both of its modes inside each value, so the light/dark + * variants follow `color-scheme` (set on beside the class by + * setDarkClass) with no separate IPC and no second block per palette. Flipping + * the class alone no longer switches anything. */ export function applyThemePalette(palette: ThemePalette): void { const root = document.documentElement;