Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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.
- **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.

Expand Down Expand Up @@ -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 `<html>` 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 `<html>` beside the class — before the first paint, and on every change — and Astryx's `<Theme>` 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.
- **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. `<Banner status="info">` 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. `<Banner status="info">` 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.
Expand Down
21 changes: 19 additions & 2 deletions apps/desktop/e2e/link-color-contract.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,30 @@ async function renderedLinkColors(page: Page, dark: boolean) {
const renderedLink = document.querySelector<HTMLElement>('.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<void>((resolve) => requestAnimationFrame(() => resolve()));
root.style.colorScheme = isDark ? 'dark' : 'light';

// Astryx's <Theme> 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<void>((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;
Expand Down
Loading