diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 3cb896f..a3c6ae2 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -1,232 +1,573 @@ @import "tailwindcss"; /* ============================================================ - Theme tokens - - Two palettes, one set of names. Nothing below this block should name a - colour directly: a literal in a rule is a colour that cannot follow the - theme, and it will be the one that glows white in a dark room. - - `[data-theme]` is written onto by an inline script before first paint - (see `ThemeScript`), always resolved to `light` or `dark` — never `system`. - Resolving in the script rather than in CSS is what lets the dark palette be - written once here instead of duplicated into a `prefers-color-scheme` block, - and the app is React-only, so there is no meaningful no-JS case to keep the - duplicate for. - - Tints and borders are mixed from their base colour and the current surface, - so they follow both the accent and the theme without a second table to keep - in sync. Anything that has to stay dark in both themes -- the titlebar, the - terminal -- is named for the surface it sits on rather than the theme. + Redrob design tokens + + Three bands, and a colour is decided in exactly one of them. + + 1. PRIMITIVES `--rr-*`. The brand's confirmed HEX values, transcribed verbatim from + Redrob Console's `apps/web/src/app/globals.css`, which is the source of truth for + every Redrob product. They carry no meaning and no theme: Blue 6 is Blue 6 in + light and in dark, so none of them is repeated in the dark block. No rule below + the token layer may name this band. + + 2. SEMANTIC ROLES bare role names, re-pointed per theme. Console's names at + Console's values, so `--card` is the same colour in Eval as it is in Console and a + role learned in one product is the same role in the other. A handful of roles + Console does not name are declared here too, each commented where it is declared, + and each built from primitives only. Every value in this band is a `var(--rr-*)` + or a mix of two; a literal here would be a second place a colour is decided. + + 3. LOCAL NAMES this app's existing vocabulary, as aliases onto band 2. It has no + values of its own. It exists because 4,300 lines of rules below already name + `--panel`, `--line` and `--brand`, and rebinding those names costs one line each + where rewriting the call sites costs three hundred. New rules should name band 2. + + `[data-theme]` is written onto by an inline script before first paint (see + `ThemeScript`), always resolved to `light` or `dark`, never `system`. Resolving in the + script rather than in CSS is what lets the dark palette be written once here instead of + duplicated into a `prefers-color-scheme` block. + + ── What this port deliberately does not take ───────────────────────────────────────── + + The type size, leading and tracking scale. Its steps differ from the ones in use at + every level, so adopting it re-flows every screen at once, which wants reviewing + against the live layouts rather than arriving with a colour change. This is the same + decision Console and the Office port made, and it is load bearing: the sizes in the + rules below are unchanged on purpose, and a later change that takes the scale has to + take it everywhere at once. Radius and spacing are likewise unchanged; both are already + on a 4px grid. + + ── Where a name could not be shared ───────────────────────────────────────────────── + + Console's `--muted` is a surface (Gray 2) and this app's `--muted` was its secondary + text colour, so the two could not both hold the name. The text role is renamed to + Console's `--muted-foreground`, which is what it always was, and `--muted` now means + what it means in Console. ============================================================ */ +/* ── 1. PRIMITIVES ─────────────────────────────────────────── + Theme-independent. Declared once, on :root, and not repeated in `[data-theme='dark']`: + only the semantic aliases below are re-pointed there. */ +:root { + /* Primary */ + --rr-blue: #2b52ff; /* Redrob Blue, the same value as Blue 6 */ + --rr-black: #0a0b0c; /* Redrob Black */ + --rr-white: #ffffff; /* Redrob White */ + + /* Blue tint, 1 lightest to 10 darkest */ + --rr-blue-1: #eff4ff; + --rr-blue-2: #d9e6ff; + --rr-blue-3: #bad2ff; + --rr-blue-4: #8aafff; + --rr-blue-5: #507fff; + --rr-blue-6: #2b52ff; + --rr-blue-7: #1733d5; + --rr-blue-8: #09209c; + --rr-blue-9: #061460; + --rr-blue-10: #030c34; + + /* Grayscale, 1 lightest to 9 darkest */ + --rr-gray-1: #f8f9fb; + --rr-gray-2: #eff1f4; + --rr-gray-3: #dfe2e8; + --rr-gray-4: #cbcfd7; + --rr-gray-5: #aab0bb; + --rr-gray-6: #7c8390; + --rr-gray-7: #576071; + --rr-gray-8: #292e37; + --rr-gray-9: #141719; + + /* Primary gradient */ + --rr-gradient-primary: linear-gradient(135deg, #f8f9fb 0%, #507fff 100%); + + /* Accent spectrum, 9 hues x 5 levels. The brand document scopes it to contents, + marketing and graphics: category distinction and point colour, not product chrome. + 1 lightest to 5 darkest. */ + --rr-teal-1: #dcfffe; + --rr-teal-2: #b9fffd; + --rr-teal-3: #6ff4f0; + --rr-teal-4: #00b5c2; + --rr-teal-5: #006a7a; + --rr-sky-1: #e4f0ff; + --rr-sky-2: #bad9ff; + --rr-sky-3: #2f8dff; + --rr-sky-4: #0e51b6; + --rr-sky-5: #002a68; + --rr-violet-1: #f2e9ff; + --rr-violet-2: #d4b3ff; + --rr-violet-3: #8944ff; + --rr-violet-4: #4500ac; + --rr-violet-5: #140042; + --rr-pink-1: #ffe3fc; + --rr-pink-2: #ffb2f6; + --rr-pink-3: #ff39ba; + --rr-pink-4: #8a0061; + --rr-pink-5: #380037; + --rr-red-1: #ffe8e1; + --rr-red-2: #ffc2ba; + --rr-red-3: #ff5452; + --rr-red-4: #a31310; + --rr-red-5: #560100; + --rr-orange-1: #ffedda; + --rr-orange-2: #ffd5ab; + --rr-orange-3: #ff9c1b; + --rr-orange-4: #ae5100; + --rr-orange-5: #5c2d00; + --rr-yellow-1: #fff7cc; + --rr-yellow-2: #ffed94; + --rr-yellow-3: #ffda1e; + --rr-yellow-4: #d2a100; + --rr-yellow-5: #734f00; + --rr-lime-1: #f2ffc3; + --rr-lime-2: #e5ff81; + --rr-lime-3: #cffd21; + --rr-lime-4: #89ad00; + --rr-lime-5: #2f5f00; + --rr-green-1: #d6ffe1; + --rr-green-2: #a6ffbf; + --rr-green-3: #29e474; + --rr-green-4: #00864a; + --rr-green-5: #004829; +} + +/* ── 2. SEMANTIC ROLES, light ──────────────────────────────── + Light is the default palette; dark is applied by `[data-theme='dark']` on . + Console's reasoning for each value, including its four departures from the brand + proposal, is written out in Console's own header and is not restated here so there is + one place it is written. */ :root { color-scheme: light; + /* Page and panels. Gray 1 as the page and White as the surface is fixed by the brand + document, and it inverts what this app used to do (grey page, white panels was close, + but the page was Tailwind grey and the panel had no step of its own): a card now sits + above the page rather than beside it. */ + --background: var(--rr-gray-1); + --background-secondary: var(--rr-gray-2); + --card: var(--rr-white); + --card-foreground: var(--rr-gray-9); + --popover: var(--rr-white); + --popover-foreground: var(--rr-gray-9); + + /* Text. Four steps, and only the first two are for body copy: Gray 6 on Gray 1 is + 3.62:1, which clears 3:1 for large text, icons and non-text UI and does not clear + 4.5:1 for a sentence. */ + --foreground: var(--rr-gray-9); + --muted-foreground: var(--rr-gray-7); + --subtle-foreground: var(--rr-gray-6); + /* Disabled text is exempt from the contrast minimum, which is the only reason Gray 5 + is here. */ + --disabled-foreground: var(--rr-gray-5); + + /* Interactive surfaces. `accent` is the role a hover or an open menu item paints on the + page; `accent-active` is the pressed step, and it is also what a hover on a card takes + here, because on dark `accent` and `card` are the same mix. The brand accent colour is + `primary`. */ + --accent: var(--rr-gray-2); + --accent-foreground: var(--rr-gray-9); + --accent-active: var(--rr-gray-3); + --secondary: var(--rr-gray-2); + --secondary-foreground: var(--rr-gray-9); + --muted: var(--rr-gray-2); + + /* Brand. Blue 6 with White reads 5.6:1, so a primary button carries body-size text. */ + --primary: var(--rr-blue-6); + --primary-foreground: var(--rr-white); + --primary-hover: var(--rr-blue-7); + --primary-muted: var(--rr-blue-3); + --primary-soft: var(--rr-blue-1); + /* Brand-coloured text, a link or an eyebrow label, rather than a fill. Light needs no + step away from the accent; dark does, which is why the role exists. */ + --primary-ink: var(--rr-blue-6); + + --border-subtle: var(--rr-gray-2); + --border: var(--rr-gray-3); + --border-strong: var(--rr-gray-4); + /* The boundary of a control. WCAG 1.4.11 asks 3:1 of it, and every step up to Gray 5 is + under 3:1 on a White surface, so control boundaries take Gray 6 (3.8:1 on the surface, + 3.6:1 on the page) and `--border-strong` keeps Gray 4 for decorative emphasis. */ + --input: var(--rr-gray-6); + /* A control boundary under the cursor. Console has no hover step for `--input`; a ghost + button here does, and it has to read as stronger than the resting edge, so it takes + one step further from the surface in each theme: Gray 7 on light, Gray 5 on dark. */ + --input-hover: var(--rr-gray-7); + + /* The focus ring is the brand blue rather than a grey: on-brand, and the stronger + indicator at 5.6:1 against the page. Drawn at full opacity. */ + --ring: var(--rr-blue-6); + + /* Status. The brand document defines no product status colours, so these come from the + accent spectrum: level 4 on light, level 3 on dark. Four roles each, because a status + appears as all four and one value cannot be all four: the solid fill, the ink on that + fill, the tinted strip behind a message, and the readable text on page or strip. */ + --success: var(--rr-green-4); + --success-foreground: var(--rr-white); + --success-soft: var(--rr-green-1); + --success-ink: var(--rr-green-5); + --warning: var(--rr-orange-4); + --warning-foreground: var(--rr-white); + --warning-soft: var(--rr-orange-1); + --warning-ink: var(--rr-orange-4); + --destructive: var(--rr-red-4); + --destructive-foreground: var(--rr-white); + --destructive-soft: var(--rr-red-1); + --destructive-ink: var(--rr-red-4); + /* The edge of a status strip. Console draws its strips without one, so it names no step + for it; a banner and a chip here are outlined. Level 2 on light and level 4 on dark, + one step in from the `-soft` fill, so the edge is a step of the same hue rather than + an invented alpha of the fill. */ + --success-line: var(--rr-green-2); + --warning-line: var(--rr-orange-2); + --destructive-line: var(--rr-red-2); + + --overlay: color-mix(in srgb, var(--rr-black) 45%, transparent); + + /* A tooltip stays dark in both palettes, so it reads as an overlay rather than as + another panel. */ + --tooltip: var(--rr-gray-9); + --tooltip-foreground: var(--rr-gray-1); + + /* The sidebar is a surface, like a card, separated from the page by Gray 1 behind it. */ + --sidebar: var(--rr-white); + --sidebar-foreground: var(--rr-gray-9); + --sidebar-primary: var(--rr-blue-6); + --sidebar-primary-foreground: var(--rr-white); + --sidebar-accent: var(--rr-gray-2); + --sidebar-accent-foreground: var(--rr-gray-9); + --sidebar-border: var(--rr-gray-3); + --sidebar-ring: var(--rr-blue-6); + + /* Graphics. The spectrum's own use: category distinction and point colour in charts and + illustration, never product chrome. Held to 3:1 against the surface they are drawn on, + because a chart series is needed to understand the chart. Level 4 on light, except + teal, yellow and lime, whose mid steps cannot read on white; level 3 on dark. */ + --spectrum-teal: var(--rr-teal-5); + --spectrum-sky: var(--rr-sky-4); + --spectrum-violet: var(--rr-violet-4); + --spectrum-pink: var(--rr-pink-4); + --spectrum-red: var(--rr-red-4); + --spectrum-orange: var(--rr-orange-4); + --spectrum-yellow: var(--rr-yellow-5); + --spectrum-lime: var(--rr-lime-5); + --spectrum-green: var(--rr-green-4); + + /* Chart series, drawn from the spectrum and ordered so neighbouring hues are not + neighbouring indices. */ + --chart-1: var(--spectrum-sky); + --chart-2: var(--spectrum-pink); + --chart-3: var(--spectrum-lime); + --chart-4: var(--spectrum-orange); + --chart-5: var(--spectrum-violet); + --chart-6: var(--spectrum-teal); + --chart-7: var(--spectrum-yellow); + --chart-8: var(--spectrum-green); + /* Tied to the ink so it darkens the panel on light and lightens it on dark, rather than + being one fixed near-white that only worked on one. Console paints the band under a + chart cursor with it; this app draws its gridlines with it, which is the same job. */ + --chart-cursor: color-mix(in srgb, var(--rr-gray-8) 8%, transparent); + + /* Syntax colours, picked against the code surface rather than the page: a code block + sits on `--muted`, Gray 2 on light and Gray 8 on dark, which is the tighter of the + two constraints. Every entry clears 4.5:1 there. */ + --code-comment: var(--rr-gray-7); + --code-string: var(--rr-green-5); + --code-number: var(--rr-orange-4); + --code-keyword: var(--rr-violet-4); + --code-literal: var(--rr-pink-4); + --code-property: var(--rr-sky-4); + --code-variable: var(--rr-teal-5); + --code-operator: var(--rr-gray-7); + --code-punctuation: var(--rr-gray-7); + + /* Elevation. Three steps, tinted with Gray 8 rather than pure black on light. */ + --shadow-soft: 0 1px 2px color-mix(in srgb, var(--rr-gray-8) 5%, transparent); + --shadow-card: + 0 1px 2px color-mix(in srgb, var(--rr-gray-8) 5%, transparent), + 0 4px 16px color-mix(in srgb, var(--rr-gray-8) 7%, transparent); + --shadow-elevated: + 0 2px 4px color-mix(in srgb, var(--rr-gray-8) 6%, transparent), + 0 12px 32px color-mix(in srgb, var(--rr-gray-8) 12%, transparent); + + /* ── Roles that do not change with the theme ────────────── + Two surfaces here are dark in both palettes, so their roles are named for the surface + they sit on rather than for the theme, and they are not re-pointed below. */ + + /* The titlebar. It was navy in light and a lighter navy in dark, neither of which is a + brand value; it is now Gray 8 in both. One value rather than two because the dark + theme's requirement fixes it: the bar has to separate from the page it sits on, and a + bar darker than a Gray 9 page measures 1.19:1, which is to say it is not visible. Gray + 8 is 1.55:1 against the dark page and 12.9:1 against the light one, so the same value + works in both and the dark twin the old palette needed is gone. */ + --titlebar: var(--rr-gray-8); + --titlebar-ink: var(--rr-gray-3); + --titlebar-ink-strong: var(--rr-gray-1); + --titlebar-ink-max: var(--rr-white); + --titlebar-muted: var(--rr-gray-5); + --titlebar-muted-strong: var(--rr-gray-4); + --titlebar-hover-ink: var(--rr-gray-2); + --titlebar-accent: var(--rr-blue-4); + --titlebar-accent-hover: var(--rr-blue-3); + /* The generator's live state, at 0.7rem. Good takes level 3 at 8.1:1 on the bar; bad + steps to level 2, because Red 3 there is 4.3:1 and this is small text. The asymmetry + is a property of the two hues at the same level, not a choice between them. */ + --titlebar-ok: var(--rr-green-3); + --titlebar-bad: var(--rr-red-2); + /* Washes of White and of the brand over the bar. Translucent rather than flattened, + because a hairline and a hover both have to work over whatever the bar is carrying. */ + --titlebar-hairline: color-mix(in srgb, var(--rr-white) 10%, transparent); + --titlebar-hover: color-mix(in srgb, var(--rr-white) 8%, transparent); + --titlebar-divider: color-mix(in srgb, var(--rr-white) 14%, transparent); + --titlebar-active: color-mix(in srgb, var(--rr-blue-6) 30%, transparent); + --titlebar-btn-line: color-mix(in srgb, var(--rr-white) 22%, transparent); + --titlebar-btn-hover: color-mix(in srgb, var(--rr-white) 10%, transparent); + --titlebar-btn-hover-line: color-mix(in srgb, var(--rr-white) 35%, transparent); + + /* The terminal. A terminal is dark in both themes, which is what a terminal looks like, + so Redrob Black is the surface and there is nothing to re-resolve when the theme + changes. `DeployTerminal` reads these two through `getComputedStyle`, because xterm + paints to a canvas and a canvas does not resolve `var()`. */ + --terminal-bg: var(--rr-black); + --terminal-cursor: var(--rr-blue-4); + --terminal-scroll: var(--rr-gray-7); + + /* Progress, teal to orange as a run advances. Drawn on the titlebar as often as on a + panel, and the titlebar is dark in both themes, so this takes the bright spectrum + levels in both rather than the light-theme steps: Teal 5 on the bar would be invisible. + Nothing sits on top of it, so it has no ink token. */ + --progress-start: var(--rr-teal-4); + --progress-end: var(--rr-orange-3); +} + +/* ── 2b. SEMANTIC ROLES, dark ──────────────────────────────── + Only aliases are re-pointed; no new primitive is invented here. + + Gray 9 is the dark background per the brand document, which leaves Redrob Black as the + deeper recessed layer beneath it. The scale has nothing between Gray 9 and Gray 8, so a + raised surface would jump twelve steps of lightness at once; the answer, Console's, is + to mix them for the panel surface. Two extra dark steps in the brand scale would remove + the need for it, and would also give the three surfaces below three distinct values + instead of two: on dark, `--secondary`, `--muted` and `--accent-active` are all Gray 8, + because that is every step the scale has left. */ +[data-theme='dark'] { + color-scheme: dark; + + --background: var(--rr-gray-9); + --background-secondary: var(--rr-black); + --card: color-mix(in srgb, var(--rr-gray-8) 45%, var(--rr-gray-9)); + --card-foreground: var(--rr-gray-1); + --popover: color-mix(in srgb, var(--rr-gray-8) 45%, var(--rr-gray-9)); + --popover-foreground: var(--rr-gray-1); + + --foreground: var(--rr-gray-1); + --muted-foreground: var(--rr-gray-5); + /* Gray 6 clears 4.5:1 on Gray 9, unlike on light, but the token keeps one meaning on + both sides so its use stays the same: large text, icons and non-text UI. */ + --subtle-foreground: var(--rr-gray-6); + --disabled-foreground: var(--rr-gray-7); + + --accent: color-mix(in srgb, var(--rr-gray-8) 45%, var(--rr-gray-9)); + --accent-foreground: var(--rr-gray-1); + --accent-active: var(--rr-gray-8); + --secondary: var(--rr-gray-8); + --secondary-foreground: var(--rr-gray-1); + --muted: var(--rr-gray-8); + + /* Blue 6 does not carry enough contrast against Gray 9, so dark steps one tint lighter + and pairs it with Redrob Black ink: Blue 5 is 5.5:1 with Black and only 3.6:1 with + White. Which is also why hover lightens here rather than deepening: the old palette + could not lighten a fill without dropping its white label below 4.5:1, and with black + ink Blue 4 reads 9.1:1. */ + --primary: var(--rr-blue-5); + --primary-foreground: var(--rr-black); + --primary-hover: var(--rr-blue-4); + --primary-muted: var(--rr-blue-9); + --primary-soft: var(--rr-blue-10); + /* Blue 5 is 5.1:1 on the page but 4.4:1 on a raised panel, and a link inside a card is + small text, so brand-coloured text steps once more to Blue 4 while `--primary` stays + Blue 5 for fills, borders and the focus ring, where the bar is 3:1. */ + --primary-ink: var(--rr-blue-4); + + --border-subtle: color-mix(in srgb, var(--rr-gray-8) 55%, var(--rr-gray-9)); + --border: var(--rr-gray-8); + --border-strong: var(--rr-gray-7); + /* Gray 7 is 2.5:1 against a raised panel, so a control boundary takes Gray 6 here for + the same reason it does on light. It is the one step that is the same value in both + themes. */ + --input: var(--rr-gray-6); + --input-hover: var(--rr-gray-5); + --ring: var(--rr-blue-5); + + /* Level 3 on dark, and Redrob Black as the ink on a fill: Red 3 with White is 3.2:1. */ + --success: var(--rr-green-3); + --success-foreground: var(--rr-black); + --success-soft: var(--rr-green-5); + --success-ink: var(--rr-green-3); + --warning: var(--rr-orange-3); + --warning-foreground: var(--rr-black); + --warning-soft: var(--rr-orange-5); + --warning-ink: var(--rr-orange-3); + --destructive: var(--rr-red-3); + --destructive-foreground: var(--rr-black); + --destructive-soft: var(--rr-red-5); + --destructive-ink: var(--rr-red-3); + --success-line: var(--rr-green-4); + --warning-line: var(--rr-orange-4); + --destructive-line: var(--rr-red-4); + + --overlay: color-mix(in srgb, var(--rr-black) 65%, transparent); + + --tooltip: var(--rr-black); + --tooltip-foreground: var(--rr-gray-1); + + --sidebar: var(--rr-black); + --sidebar-foreground: var(--rr-gray-1); + --sidebar-primary: var(--rr-blue-5); + --sidebar-primary-foreground: var(--rr-black); + --sidebar-accent: var(--rr-gray-8); + --sidebar-accent-foreground: var(--rr-gray-1); + --sidebar-border: var(--rr-gray-8); + --sidebar-ring: var(--rr-blue-5); + + --spectrum-teal: var(--rr-teal-3); + --spectrum-sky: var(--rr-sky-3); + --spectrum-violet: var(--rr-violet-3); + --spectrum-pink: var(--rr-pink-3); + --spectrum-red: var(--rr-red-3); + --spectrum-orange: var(--rr-orange-3); + --spectrum-yellow: var(--rr-yellow-3); + --spectrum-lime: var(--rr-lime-3); + --spectrum-green: var(--rr-green-3); + --chart-cursor: color-mix(in srgb, var(--rr-gray-1) 8%, transparent); + + /* Violet, pink and sky at level 3 are under 4.5:1 on the Gray 8 a code block sits on, + so those three step to level 2 here. The rest clear it at level 3. */ + --code-comment: var(--rr-gray-5); + --code-string: var(--rr-green-3); + --code-number: var(--rr-orange-3); + --code-keyword: var(--rr-violet-2); + --code-literal: var(--rr-pink-2); + --code-property: var(--rr-sky-2); + --code-variable: var(--rr-teal-3); + --code-operator: var(--rr-gray-5); + --code-punctuation: var(--rr-gray-5); + + /* Re-mixed, because on dark a shadow has to be darker than the surface to read at all. */ + --shadow-soft: 0 1px 2px color-mix(in srgb, var(--rr-black) 30%, transparent); + --shadow-card: + 0 1px 2px color-mix(in srgb, var(--rr-black) 25%, transparent), + 0 4px 16px color-mix(in srgb, var(--rr-black) 35%, transparent); + --shadow-elevated: + 0 2px 4px color-mix(in srgb, var(--rr-black) 30%, transparent), + 0 12px 32px color-mix(in srgb, var(--rr-black) 50%, transparent); +} + +/* ── 3. LOCAL NAMES ────────────────────────────────────────── + Aliases onto band 2, declared once and never re-pointed per theme: the role each one + names is already re-pointed above, so a dark twin here would be a second place the dark + palette is decided. Deleting those twins is most of what this change removes. + + No colour is decided in this band. Every value is a `var()` of a role, or a mix of one + with `transparent` where the old value was translucent and has to stay that way: those + sit over table rows and cards as often as over a plain panel, and flattening them would + paint over what is behind. */ +:root { /* Surfaces */ - --bg: #f3f4f6; - --panel: #ffffff; - --surface-sunken: #f8fafc; - --surface-hover: #f1f5f9; + --bg: var(--background); + --panel: var(--card); + --surface-sunken: var(--muted); + /* `--accent-active` rather than `--accent`, because these hovers happen on a card and on + dark `--accent` and `--card` are the same mix, which would leave the hover invisible. */ + --surface-hover: var(--accent-active); /* Lines and text */ - --line: #e5e7eb; - --line-strong: #d1d5db; - --ink: #111827; - --muted: #6b7280; - --miss: #9ca3af; - /* Text on a saturated fill. Not `--panel`: a white button label stays white - in dark mode, where the panel does not. */ - --on-solid: #ffffff; - - /* Redrob brand — mark blue #2B52FF */ - --brand: #2b52ff; - --brand-deep: #1e3acc; - --brand-ink: #16299b; - --brand-tint: #eef2ff; - /* The fill under a white label, kept separate from `--brand` because the two want - opposite things in dark mode: text must lighten to stay readable on a dark panel, - while a fill must stay saturated or the white label on top of it disappears. */ - --brand-solid: #2b52ff; - --brand-solid-hover: #1e3acc; - --danger-solid: #b91c1c; + --line: var(--border); + --line-strong: var(--border-strong); + --ink: var(--foreground); + --miss: var(--disabled-foreground); + /* The ink on a saturated fill. White on light and Redrob Black on dark, which is what + `--destructive-foreground` carries too, so this one alias serves both fills. */ + --on-solid: var(--primary-foreground); + + /* Brand. `--brand` is the fill, the border and the ring; `--brand-deep` is the same + colour as text, which needs its own step on dark. `--brand-solid` and `--brand-ink` + are gone: under the brand tokens the first was `--brand` and the second was + `--brand-deep`, and two names for one value is the thing that drifts. */ + --brand: var(--primary); + --brand-hover: var(--primary-hover); + --brand-deep: var(--primary-ink); + --brand-tint: var(--primary-soft); + --brand-tint-soft: color-mix(in srgb, var(--primary) 8%, transparent); /* Status */ - --ok: #047857; - --danger: #b91c1c; - --warn: #a16207; - - /* Progress. Teal to orange as a run advances — the only place teal is used, now that - the primary button is brand blue. Nothing sits on top of it, so it has no ink token. */ - --run: #14b8a6; - --progress-end: #f97316; - - /* Data series */ - --feasible: #1d4ed8; - --series-seed: #c2410c; - --series-neutral: #94a3b8; - --chart-axis: #94a3b8; - --chart-tick: #64748b; - --chart-label: #334155; - --chart-faint: #cbd5e1; - --chart-grid: rgba(15, 23, 42, 0.08); - --accent-deep: #0f766e; - - /* Ghost controls on a light surface */ - --ghost-line: rgba(15, 23, 42, 0.18); - --ghost-line-hover: rgba(15, 23, 42, 0.28); + --ok: var(--success-ink); + --ok-tint: var(--success-soft); + --ok-line: var(--success-line); + --ok-tint-soft: color-mix(in srgb, var(--success) 10%, transparent); + --warn: var(--warning-ink); + --warn-tint: var(--warning-soft); + --warn-line: var(--warning-line); + --warn-line-soft: color-mix(in srgb, var(--warning) 25%, transparent); + --danger: var(--destructive-ink); + --danger-solid: var(--destructive); + --danger-tint: var(--destructive-soft); + --danger-line: var(--destructive-line); + /* The row tint that marks a router row. A point colour rather than a status, so it comes + from the spectrum. */ + --router-tint: color-mix(in srgb, var(--spectrum-orange) 6%, transparent); + + /* Ghost control boundary */ + --ghost-line: var(--input); + --ghost-line-hover: var(--input-hover); + + /* Data series and chart chrome. The teal `--accent-*` family is gone with the teal + primary that named it: every caller was a selected state, and a selected state is + brand blue. */ + --feasible: var(--spectrum-sky); + --series-seed: var(--spectrum-orange); + --series-neutral: var(--subtle-foreground); + --chart-tick: var(--muted-foreground); + --chart-label: var(--foreground); + --chart-faint: var(--border-strong); + --chart-grid: var(--chart-cursor); /* Overlays */ - --scrim: rgba(14, 22, 51, 0.5); - --shadow-modal: 0 20px 60px rgba(14, 22, 51, 0.25); - - --background: var(--bg); - --foreground: var(--ink); -} - -[data-theme='dark'] { - color-scheme: dark; - - /* Navy rather than neutral grey, so the page reads as the same product as - the titlebar instead of a greyscale inversion of it. */ - --bg: #0a0e1a; - --panel: #141a2b; - --surface-sunken: #0f1423; - --surface-hover: #1e2540; - - --line: #262d45; - --line-strong: #39425f; - --ink: #e8eaf2; - --muted: #99a2bd; - --miss: #6b7490; - --on-solid: #ffffff; - - /* Lifted: #2b52ff on a dark panel is close to unreadable for body text, and - these carry link and label text as well as fills. */ - --brand: #7d97ff; - --brand-deep: #9cb0ff; - --brand-ink: #c3cfff; - --brand-tint: #1b2547; - /* Not lifted with `--brand`: these carry white text, so they stay saturated. Both - clear 4.5:1 against white and 3:1 against the panel they sit on. */ - --brand-solid: #3a5eff; - /* Deepens on hover rather than lightening, matching light mode. Lightening is the - more usual instinct on a dark theme, but it drops the white label below 4.5:1. */ - --brand-solid-hover: #2b52ff; - --danger-solid: #dc2626; - - --ok: #34d399; - --danger: #f87171; - --warn: #fbbf24; - - --run: #14b8a6; - --progress-end: #fb923c; - - --feasible: #60a5fa; - --series-seed: #fb923c; - --series-neutral: #7c86a3; - --chart-axis: #6b7490; - --chart-tick: #8b95b3; - --chart-label: #b6bdd4; - --chart-faint: #606b90; - --chart-grid: rgba(232, 234, 242, 0.08); - --accent-deep: #2dd4bf; - - --ghost-line: rgba(232, 234, 242, 0.2); - --ghost-line-hover: rgba(232, 234, 242, 0.34); - - --scrim: rgba(3, 6, 15, 0.66); - --shadow-modal: 0 20px 60px rgba(0, 0, 0, 0.6); -} - -/* Status tints, spelled out per theme rather than mixed from the base colour. - Mixing was tried and is tidier, but a mix of `--warn` into white lands on beige, - not on the cream the light theme has always used: the elegant mechanism quietly - restyles the existing product. Two explicit rows cost more lines and change nothing - that was not asked for. - - The translucent ones stay translucent. They sit over table rows and cards as often - as over a plain panel, and flattening them to opaque would lose the surface - underneath. */ -:root { - --ok-tint: #ecfdf5; - --ok-line: #a7f3d0; - --warn-tint: #fffbeb; - --warn-line: #fde68a; - --danger-tint: #fef2f2; - --danger-line: #fecaca; - --brand-tint-soft: rgba(37, 99, 235, 0.06); - --accent-tint: rgba(15, 118, 110, 0.08); - --accent-tint-strong: rgba(15, 118, 110, 0.1); - --accent-line: rgba(15, 118, 110, 0.45); - --router-tint: rgba(194, 65, 12, 0.04); - /* Translucent rather than a tint of the panel: both sit on surfaces that are not the - panel, so flattening them would paint over what is behind. */ - --warn-line-soft: rgba(161, 98, 7, 0.25); - --ok-tint-soft: rgba(4, 120, 87, 0.1); -} - -[data-theme='dark'] { - /* Dark enough to read as a tint of the panel rather than a block of colour, with - the hue still identifiable as green, amber or red at a glance. */ - --ok-tint: #0e2a22; - --ok-line: #1f5545; - --warn-tint: #2b2312; - --warn-line: #5c4a1a; - --danger-tint: #2e1618; - --danger-line: #6b2b2e; - --brand-tint-soft: rgba(125, 151, 255, 0.1); - --accent-tint: rgba(45, 212, 191, 0.1); - --accent-tint-strong: rgba(45, 212, 191, 0.16); - --accent-line: rgba(45, 212, 191, 0.45); - --router-tint: rgba(251, 146, 60, 0.07); - --warn-line-soft: rgba(251, 191, 36, 0.3); - --ok-tint-soft: rgba(52, 211, 153, 0.14); -} - -/* The titlebar is dark in both themes, so its tokens are named for the surface - they sit on rather than for the theme, and they do not change. */ -:root { - --titlebar: #0e1633; - --titlebar-ink: #e5e7eb; - --titlebar-ink-strong: #f8fafc; - --titlebar-ink-max: #ffffff; - --titlebar-muted: #94a3b8; - --titlebar-muted-strong: #cbd5e1; - --titlebar-hover-ink: #e2e8f0; - --titlebar-accent: #6f8bff; - --titlebar-accent-hover: #9db1ff; - --titlebar-ok: #a5b4fc; - --titlebar-bad: #fca5a5; - --titlebar-hairline: rgba(255, 255, 255, 0.06); - --titlebar-hover: rgba(255, 255, 255, 0.06); - --titlebar-divider: rgba(255, 255, 255, 0.12); - --titlebar-active: rgba(43, 82, 255, 0.28); - --titlebar-btn-line: rgba(255, 255, 255, 0.22); - --titlebar-btn-hover: rgba(255, 255, 255, 0.1); - --titlebar-btn-hover-line: rgba(255, 255, 255, 0.35); - - --terminal-bg: #0b0f14; - --terminal-cursor: #6f8bff; - --terminal-scroll: #3a4658; -} - -[data-theme='dark'] { - /* In light mode the titlebar is "the dark one" and needs no help separating itself. - In dark mode nothing is the dark one, so it separates by hue and by being slightly - lighter than the page instead: a darker bar measured 1.02:1 against the page - behind it, which is to say it was not visible at all. The hairline is opened up - for the same reason -- at 6% on a near-black page there was no edge to see. */ - --titlebar: #172a5c; - --titlebar-hairline: rgba(255, 255, 255, 0.12); - /* The bar it sits on is lighter here, so the logo lifts with it. */ - --titlebar-accent: #8aa2ff; + --scrim: var(--overlay); + --shadow-modal: var(--shadow-elevated); } @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); - --font-sans: "Pretendard Variable", Pretendard, -apple-system, BlinkMacSystemFont, system-ui, sans-serif; + + /* Type. Pretendard is the single Redrob product family and the only approved product + typeface: it carries Latin and Hangul in one face, so Korean is not a fallback here, + it is the same font. Geist, Inter and Fraunces are not part of product typography. + + `Pretendard Variable` is the family the pinned variable dynamic subset registers; the + root layout loads it from jsDelivr, which is the one place the version is named. Bare + `Pretendard` covers an OS install or the static build, for engines that ignore + variable fonts, and the system stack is a last resort while the webfont is in flight + rather than an alternative: its metrics are close enough that the swap barely moves + the text. + + Declared here rather than in band 2 because Tailwind emits `@theme` keys as `:root` + custom properties, so this is both the utility and the `var(--font-sans)` the rules + below name, and there is one declaration of the stack instead of the five literal + copies this file used to carry. */ + --font-sans: + "Pretendard Variable", Pretendard, -apple-system, BlinkMacSystemFont, system-ui, + "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + /* A deliberate alias, not a placeholder for a future serif. There is no second display + face; brand moments use the same family at a heavier weight and tighter tracking. */ + --font-display: var(--font-sans); + /* Code only: token values, terminal output, JSON, model ids. Never UI labels or body + copy. The brand's monospace is JetBrains Mono, which this product does not load and + will not vendor, so the stack names it for machines that have it and falls through to + the system face, which is what actually renders here. Nothing is fetched for it. */ + --font-mono: + "JetBrains Mono Variable", "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", + Menlo, Consolas, "Liberation Mono", monospace; } * { @@ -238,12 +579,12 @@ body { margin: 0; min-height: 100%; color: var(--ink); - font-family: "Pretendard Variable", Pretendard, -apple-system, BlinkMacSystemFont, system-ui, sans-serif; + font-family: var(--font-sans); background: var(--bg); } code { - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-family: var(--font-mono); font-size: 0.9em; } @@ -315,7 +656,7 @@ code { align-items: center; gap: 0.45rem; flex: none; - font-family: "Pretendard Variable", Pretendard, -apple-system, BlinkMacSystemFont, system-ui, sans-serif; + font-family: var(--font-sans); font-weight: 650; font-size: 0.88rem; letter-spacing: -0.02em; @@ -378,7 +719,7 @@ code { .app-progress-bar { height: 100%; - background: linear-gradient(90deg, var(--run), var(--progress-end)); + background: linear-gradient(90deg, var(--progress-start), var(--progress-end)); transition: width 0.2s ease; } @@ -469,12 +810,12 @@ code { } .app-run-btn { - background: var(--brand-solid); + background: var(--brand); color: var(--on-solid); } .app-run-btn:hover:not(:disabled) { - background: var(--brand-solid-hover); + background: var(--brand-hover); } .app-run-btn:disabled { @@ -589,7 +930,7 @@ body.is-pane-resizing * { flex-direction: column; gap: 0.3rem; font-size: 0.8rem; - color: var(--muted); + color: var(--muted-foreground); min-width: 0; max-width: 100%; } @@ -654,7 +995,7 @@ body.is-pane-resizing * { .empty { margin: 0; font-size: 0.72rem; - color: var(--muted); + color: var(--muted-foreground); line-height: 1.4; } @@ -676,7 +1017,7 @@ body.is-pane-resizing * { .sample-presets button.on { border-color: var(--brand); - background: var(--accent-tint); + background: var(--brand-tint); color: var(--brand-deep); font-weight: 600; } @@ -764,7 +1105,7 @@ body.is-pane-resizing * { font-size: 0.66rem; text-transform: uppercase; letter-spacing: 0.04em; - color: var(--muted); + color: var(--muted-foreground); background: var(--surface-sunken); position: sticky; top: 0; @@ -783,12 +1124,12 @@ body.is-pane-resizing * { .sub { display: block; font-size: 0.68rem; - color: var(--muted); + color: var(--muted-foreground); font-weight: 400; } .mono { - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-family: var(--font-mono); font-size: 0.7rem; } @@ -821,7 +1162,7 @@ body.is-pane-resizing * { .or-models-title { margin: 0; - font-family: "Pretendard Variable", Pretendard, -apple-system, BlinkMacSystemFont, system-ui, sans-serif; + font-family: var(--font-sans); font-size: 0.95rem; font-weight: 700; } @@ -829,7 +1170,7 @@ body.is-pane-resizing * { .or-models-sub { margin: 0.15rem 0 0; font-size: 0.7rem; - color: var(--muted); + color: var(--muted-foreground); } .or-models-tools { @@ -842,7 +1183,7 @@ body.is-pane-resizing * { align-self: center; font-size: 0.7rem; font-weight: 600; - color: var(--muted); + color: var(--muted-foreground); font-variant-numeric: tabular-nums; white-space: nowrap; } @@ -872,7 +1213,7 @@ body.is-pane-resizing * { .or-source-chip { border: 1px solid var(--line); background: var(--panel); - color: var(--muted); + color: var(--muted-foreground); font-size: 0.68rem; font-weight: 600; padding: 0.2rem 0.5rem; @@ -905,7 +1246,7 @@ body.is-pane-resizing * { font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; - color: var(--muted); + color: var(--muted-foreground); display: flex; align-items: center; gap: 0.35rem; @@ -940,8 +1281,8 @@ body.is-pane-resizing * { } .or-model-card.on { - border-color: var(--accent-line); - background: var(--accent-tint); + border-color: var(--brand); + background: var(--brand-tint); } .or-model-card.browse-only { @@ -988,22 +1329,22 @@ body.is-pane-resizing * { .or-model-slug { font-size: 0.66rem; - color: var(--muted); + color: var(--muted-foreground); word-break: break-all; } .or-select-mark { font-size: 0.66rem; font-weight: 600; - color: var(--muted); + color: var(--muted-foreground); border: 1px solid var(--line); padding: 0.12rem 0.35rem; } .or-select-mark.checked { color: var(--brand-deep); - border-color: var(--accent-line); - background: var(--accent-tint-strong); + border-color: var(--brand); + background: var(--brand-tint); } .or-browse-mark { @@ -1019,7 +1360,7 @@ body.is-pane-resizing * { font-size: 0.62rem; font-weight: 600; color: var(--ok); - border: 1px solid color-mix(in srgb, var(--ok) 45%, transparent); + border: 1px solid var(--ok-line); padding: 0.12rem 0.3rem; } @@ -1027,7 +1368,7 @@ body.is-pane-resizing * { margin: 0.35rem 0 0; font-size: 0.7rem; line-height: 1.35; - color: var(--muted); + color: var(--muted-foreground); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; @@ -1040,7 +1381,7 @@ body.is-pane-resizing * { flex-wrap: wrap; gap: 0.3rem 0.55rem; font-size: 0.66rem; - color: var(--muted); + color: var(--muted-foreground); } @media (max-width: 1100px) { @@ -1155,7 +1496,7 @@ body.is-pane-resizing * { } .run-progress-head span { - color: var(--muted); + color: var(--muted-foreground); white-space: nowrap; } @@ -1169,7 +1510,7 @@ body.is-pane-resizing * { .run-progress-bar { height: 100%; - background: var(--run); + background: var(--progress-start); transition: width 0.2s ease; } @@ -1185,7 +1526,7 @@ body.is-pane-resizing * { .run-progress-detail { margin: 0.3rem 0 0; font-size: 0.7rem; - color: var(--muted); + color: var(--muted-foreground); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -1218,7 +1559,7 @@ body.is-pane-resizing * { display: block; margin-top: 0.1rem; font-size: 0.7rem; - color: var(--muted); + color: var(--muted-foreground); } .getting-started-dismiss { @@ -1228,7 +1569,7 @@ body.is-pane-resizing * { padding: 0 0.45rem; font-size: 0.7rem; cursor: pointer; - color: var(--muted); + color: var(--muted-foreground); flex: none; } @@ -1268,12 +1609,12 @@ body.is-pane-resizing * { justify-content: center; font-size: 0.65rem; font-weight: 700; - color: var(--muted); + color: var(--muted-foreground); background: var(--panel); } .getting-started-steps li[data-done='1'] .getting-started-n { - border-color: var(--ok); + border-color: var(--ok-line); background: var(--ok-tint-soft); color: var(--ok); } @@ -1287,7 +1628,7 @@ body.is-pane-resizing * { .getting-started-steps p { margin: 0.1rem 0 0; font-size: 0.7rem; - color: var(--muted); + color: var(--muted-foreground); line-height: 1.35; } @@ -1308,13 +1649,13 @@ body.is-pane-resizing * { } .getting-started-primary { - background: var(--brand-solid); + background: var(--brand); border-color: var(--brand); color: var(--on-solid); } .getting-started-primary:hover { - background: var(--brand-solid-hover); + background: var(--brand-hover); } .getting-started-secondary { @@ -1330,7 +1671,7 @@ body.is-pane-resizing * { .getting-started-foot { margin: 0; font-size: 0.68rem; - color: var(--muted); + color: var(--muted-foreground); line-height: 1.4; } @@ -1365,7 +1706,7 @@ body.is-pane-resizing * { .evolve-glance-kicker { margin: 0; font-size: 0.7rem; - color: var(--muted); + color: var(--muted-foreground); font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; @@ -1384,7 +1725,7 @@ body.is-pane-resizing * { margin-left: 0.35rem; font-size: 0.85rem; font-weight: 600; - color: var(--muted); + color: var(--muted-foreground); letter-spacing: 0; } @@ -1395,7 +1736,7 @@ body.is-pane-resizing * { padding: 0.25rem 0.5rem; border: 1px solid var(--line); background: var(--panel); - color: var(--muted); + color: var(--muted-foreground); } .evolve-glance-status[data-tone='ok'] { @@ -1426,7 +1767,7 @@ body.is-pane-resizing * { .evolve-glance-stats span { display: block; font-size: 0.65rem; - color: var(--muted); + color: var(--muted-foreground); font-weight: 600; text-transform: uppercase; letter-spacing: 0.03em; @@ -1454,7 +1795,7 @@ body.is-pane-resizing * { .evolve-glance-caption { margin: 0; font-size: 0.72rem; - color: var(--muted); + color: var(--muted-foreground); } .evolve-runway { @@ -1509,7 +1850,7 @@ body.is-pane-resizing * { .evolve-runway-n { font-size: 0.62rem; - color: var(--muted); + color: var(--muted-foreground); } .evolve-view-switch { @@ -1530,7 +1871,7 @@ body.is-pane-resizing * { .evolve-view-switch button.on { border-color: var(--brand); - background: var(--accent-tint); + background: var(--brand-tint); color: var(--brand-deep); font-weight: 600; } @@ -1558,7 +1899,7 @@ body.is-pane-resizing * { .evolve-chart-read p { margin: 0; font-size: 0.72rem; - color: var(--muted); + color: var(--muted-foreground); line-height: 1.4; } @@ -1584,7 +1925,7 @@ body.is-pane-resizing * { flex-wrap: wrap; gap: 0.45rem 0.85rem; font-size: 0.7rem; - color: var(--muted); + color: var(--muted-foreground); } .evolve-chart-legend li { @@ -1637,7 +1978,7 @@ body.is-pane-resizing * { background: var(--panel); padding: 0.45rem 0.55rem; font-size: 0.72rem; - color: var(--muted); + color: var(--muted-foreground); max-width: 220px; line-height: 1.35; } @@ -1648,7 +1989,7 @@ body.is-pane-resizing * { } .evolve-chart-tip-id { - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-family: var(--font-mono); font-size: 0.65rem; margin: 0.1rem 0 0.25rem; word-break: break-all; @@ -1731,7 +2072,7 @@ body.is-pane-resizing * { .deploy-notice { margin: 0; font-size: 12px; - color: var(--brand-ink); + color: var(--brand-deep); background: var(--brand-tint); border-radius: 0; padding: 6px 10px; @@ -1742,7 +2083,7 @@ body.is-pane-resizing * { margin: 0 0 6px; } .deploy-lede { - color: var(--muted); + color: var(--muted-foreground); font-size: 13px; margin: 0 0 14px; } @@ -1759,18 +2100,18 @@ body.is-pane-resizing * { font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; - color: var(--muted); + color: var(--muted-foreground); } .deploy-callout { margin: 0; padding: 8px 10px; font-size: 12px; line-height: 1.45; - border: 1px solid color-mix(in srgb, var(--warn) 45%, var(--line)); + border: 1px solid var(--warn-line); background: color-mix(in srgb, var(--warn) 7%, transparent); } .deploy-callout.is-error { - border-color: color-mix(in srgb, var(--danger) 45%, var(--line)); + border-color: var(--danger-line); background: color-mix(in srgb, var(--danger) 7%, transparent); } .deploy-callout a { @@ -1816,18 +2157,18 @@ body.is-pane-resizing * { place-items: center; font-size: 11px; font-weight: 700; - color: var(--muted); + color: var(--muted-foreground); border: 1px solid var(--line); } .deploy-step.is-done .deploy-step-num { color: var(--ok); - border-color: color-mix(in srgb, var(--ok) 45%, var(--line)); - background: color-mix(in srgb, var(--ok) 10%, transparent); + border-color: var(--ok-line); + background: var(--ok-tint-soft); } .deploy-step.is-running .deploy-step-num { - color: var(--brand); - border-color: color-mix(in srgb, var(--brand) 55%, var(--line)); - background: color-mix(in srgb, var(--brand) 12%, transparent); + color: var(--brand-deep); + border-color: var(--brand); + background: var(--brand-tint-soft); animation: deploy-step-pulse 1.4s ease-in-out infinite; } @keyframes deploy-step-pulse { @@ -1855,7 +2196,7 @@ body.is-pane-resizing * { margin: 0; font-size: 12px; line-height: 1.45; - color: var(--muted); + color: var(--muted-foreground); } .deploy-step-actions { display: flex; @@ -1863,16 +2204,16 @@ body.is-pane-resizing * { gap: 6px; } .deploy-op-btn.is-primary { - background: var(--brand-solid); - border-color: var(--brand-solid); + background: var(--brand); + border-color: var(--brand); color: var(--on-solid); } .deploy-op-btn.is-primary:hover:not(:disabled) { - background: var(--brand-solid-hover); + background: var(--brand-hover); } .deploy-op-btn.is-danger { color: var(--danger); - border-color: color-mix(in srgb, var(--danger) 35%, var(--line)); + border-color: var(--danger-line); } .deploy-section-head { display: flex; @@ -1902,7 +2243,7 @@ body.is-pane-resizing * { } .deploy-host-facts dt { font-size: 11px; - color: var(--muted); + color: var(--muted-foreground); } .deploy-host-facts dd { margin: 0; @@ -1928,13 +2269,13 @@ body.is-pane-resizing * { } .deploy-slot-status { font-weight: 400; - color: var(--muted); + color: var(--muted-foreground); font-size: 12px; } .deploy-slot-meta { margin: 0 0 10px; font-size: 11px; - color: var(--muted); + color: var(--muted-foreground); } .deploy-slot-paths { display: grid; @@ -1943,7 +2284,7 @@ body.is-pane-resizing * { } .deploy-slot-paths dt { font-size: 11px; - color: var(--muted); + color: var(--muted-foreground); } .deploy-slot-paths dd { margin: 0; @@ -1970,7 +2311,7 @@ body.is-pane-resizing * { gap: 8px; } .deploy-facts dt { - color: var(--muted); + color: var(--muted-foreground); } .deploy-facts dd { margin: 0; @@ -1978,7 +2319,7 @@ body.is-pane-resizing * { } .deploy-hint { margin: 0 0 12px; - color: var(--muted); + color: var(--muted-foreground); font-size: 12px; line-height: 1.5; } @@ -1994,7 +2335,7 @@ body.is-pane-resizing * { flex-direction: column; gap: 4px; font-size: 12px; - color: var(--muted); + color: var(--muted-foreground); } .deploy-field select { padding: 6px 8px; @@ -2024,7 +2365,7 @@ body.is-pane-resizing * { border: 1px solid var(--line); border-radius: 0; font-size: 13px; - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-family: var(--font-mono); background: var(--panel); } .deploy-hf-input:focus { @@ -2060,7 +2401,7 @@ body.is-pane-resizing * { cursor: not-allowed; } .deploy-empty { - color: var(--muted); + color: var(--muted-foreground); font-size: 13px; } .deploy-error { @@ -2091,7 +2432,7 @@ body.is-pane-resizing * { .deploy-terminal-hint { flex: 1; font-size: 11px; - color: var(--muted); + color: var(--muted-foreground); } .deploy-terminal-surface { height: 320px; @@ -2155,7 +2496,7 @@ body.is-pane-resizing * { border-radius: 999px; border: 1px solid var(--line-strong); background: var(--panel); - color: var(--muted); + color: var(--muted-foreground); cursor: pointer; max-width: 100%; overflow: hidden; @@ -2171,7 +2512,7 @@ body.is-pane-resizing * { .arena-chip.on { background: var(--brand-tint); border-color: var(--brand); - color: var(--brand-ink); + color: var(--brand-deep); font-weight: 600; } @@ -2221,7 +2562,7 @@ body.is-pane-resizing * { .arena-rank { font-size: 13px; font-weight: 700; - color: var(--muted); + color: var(--muted-foreground); min-width: 1ch; } @@ -2238,7 +2579,7 @@ body.is-pane-resizing * { .arena-name code { font-size: 10px; - color: var(--muted); + color: var(--muted-foreground); overflow: hidden; text-overflow: ellipsis; } @@ -2259,7 +2600,7 @@ body.is-pane-resizing * { font-size: 9px; text-transform: uppercase; letter-spacing: 0.04em; - color: var(--muted); + color: var(--muted-foreground); } .arena-row-stats { @@ -2267,7 +2608,7 @@ body.is-pane-resizing * { flex-wrap: wrap; gap: 4px 10px; font-size: 11px; - color: var(--muted); + color: var(--muted-foreground); font-variant-numeric: tabular-nums; } @@ -2279,7 +2620,7 @@ body.is-pane-resizing * { .arena-dims span { font-size: 10px; - color: var(--muted); + color: var(--muted-foreground); background: var(--surface-sunken); border: 1px solid var(--line); border-radius: 999px; @@ -2299,7 +2640,7 @@ body.is-pane-resizing * { .arena-sub { font-size: 10px; - color: var(--muted); + color: var(--muted-foreground); } .arena-error { @@ -2310,7 +2651,7 @@ body.is-pane-resizing * { font-size: 10px; text-transform: uppercase; letter-spacing: 0.03em; - color: var(--muted); + color: var(--muted-foreground); } .arena-status.is-done { @@ -2322,13 +2663,13 @@ body.is-pane-resizing * { } .arena-status.is-running { - color: var(--brand); + color: var(--brand-deep); } .arena-prompt summary { font-size: 12px; cursor: pointer; - color: var(--muted); + color: var(--muted-foreground); padding: 3px 0; } @@ -2437,7 +2778,7 @@ body.is-pane-resizing * { .settings-nav-sub { font-size: 11px; - color: var(--muted); + color: var(--muted-foreground); } .settings-nav-dot { @@ -2464,7 +2805,7 @@ body.is-pane-resizing * { } .settings-pane-head p { - color: var(--muted); + color: var(--muted-foreground); font-size: 12px; line-height: 1.6; margin: 0; @@ -2486,27 +2827,27 @@ body.is-pane-resizing * { } .settings-h1 { - font-family: "Pretendard Variable", Pretendard, -apple-system, BlinkMacSystemFont, system-ui, sans-serif; + font-family: var(--font-sans); font-size: 20px; font-weight: 800; letter-spacing: -0.03em; margin: 0 0 6px; } .settings-lede { - color: var(--muted); + color: var(--muted-foreground); font-size: 12px; line-height: 1.6; margin: 0; } .settings-note { - color: var(--muted); + color: var(--muted-foreground); font-size: 12px; margin: 8px 0 0; } .settings-note code, .settings-lede code { background: var(--brand-tint); - color: var(--brand-ink); + color: var(--brand-deep); padding: 1px 5px; border-radius: 4px; font-size: 0.92em; @@ -2546,12 +2887,12 @@ body.is-pane-resizing * { padding: 10px; margin: -10px; border-radius: 8px; - box-shadow: 0 0 0 2px color-mix(in srgb, var(--brand, var(--brand)) 35%, transparent); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--ring) 35%, transparent); } .settings-hint-link { margin-top: 4px; font-size: 12px; - color: var(--brand, var(--brand)); + color: var(--brand-deep); } @media (max-width: 720px) { .settings-row { @@ -2577,28 +2918,28 @@ body.is-pane-resizing * { background: var(--miss); } .settings-dot.on { - background: var(--ok); + background: var(--success); } .settings-default-tag { font-size: 10px; font-weight: 600; letter-spacing: 0.02em; text-transform: uppercase; - color: var(--muted); + color: var(--muted-foreground); background: var(--surface-hover); border-radius: 4px; padding: 1px 5px; } .settings-input.is-default { - color: var(--muted); + color: var(--muted-foreground); } .settings-key { font-size: 11px; - color: var(--muted); + color: var(--muted-foreground); } .settings-hint { font-size: 11px; - color: var(--muted); + color: var(--muted-foreground); line-height: 1.5; } .settings-control { @@ -2678,7 +3019,7 @@ body.is-pane-resizing * { border: 1px solid var(--line); border-radius: 7px; font-size: 13px; - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-family: var(--font-mono); background: var(--panel); } .settings-input:focus { @@ -2763,7 +3104,7 @@ body.is-pane-resizing * { } .picker-path { font-size: 11px; - color: var(--muted); + color: var(--muted-foreground); overflow-wrap: anywhere; } .picker-list { @@ -2823,7 +3164,7 @@ body.is-pane-resizing * { gap: 0.3rem; border: 0; background: transparent; - color: var(--muted); + color: var(--muted-foreground); font-size: 0.72rem; font-weight: 600; padding: 0.2rem 0.45rem; @@ -2951,7 +3292,7 @@ body.is-pane-resizing * { .cmp-reports-lede { margin: 0.2rem 0 0; font-size: 0.8rem; - color: var(--muted); + color: var(--muted-foreground); } .cmp-reports-actions { @@ -3001,7 +3342,7 @@ body.is-pane-resizing * { padding: 0.12rem 0.4rem; border-radius: 999px; background: var(--surface-hover); - color: var(--muted); + color: var(--muted-foreground); } .cmp-reports-item-label { @@ -3060,7 +3401,7 @@ body.is-pane-resizing * { .cmp-modality span { font-size: 0.68rem; - color: var(--muted); + color: var(--muted-foreground); } .cmp-modality.on { @@ -3088,12 +3429,12 @@ body.is-pane-resizing * { padding: 0.28rem 0.8rem; font-size: 0.72rem; font-weight: 600; - color: var(--muted); + color: var(--muted-foreground); cursor: pointer; } .cmp-seg button.on { - background: var(--brand-solid); + background: var(--brand); color: var(--on-solid); } @@ -3114,7 +3455,7 @@ body.is-pane-resizing * { .cmp-models-count { font-size: 0.7rem; - color: var(--muted); + color: var(--muted-foreground); font-variant-numeric: tabular-nums; } @@ -3188,7 +3529,7 @@ body.is-pane-resizing * { align-items: center; gap: 0.6rem; font-size: 0.7rem; - color: var(--muted); + color: var(--muted-foreground); font-variant-numeric: tabular-nums; } @@ -3208,7 +3549,7 @@ body.is-pane-resizing * { .cmp-caveat { font-size: 0.62rem; - color: var(--muted); + color: var(--muted-foreground); max-width: 28rem; overflow: hidden; text-overflow: ellipsis; @@ -3234,7 +3575,7 @@ body.is-pane-resizing * { display: flex; justify-content: space-between; font-size: 0.64rem; - color: var(--muted); + color: var(--muted-foreground); margin-bottom: 0.25rem; } @@ -3330,7 +3671,7 @@ body.is-pane-resizing * { width: 1.4rem; height: 1.4rem; border-radius: 999px; - background: var(--brand-solid); + background: var(--brand); color: var(--on-solid); font-size: 0.72rem; font-weight: 700; @@ -3339,7 +3680,7 @@ body.is-pane-resizing * { .vote-card-reveal { font-size: 0.68rem; font-weight: 600; - color: var(--muted); + color: var(--muted-foreground); } .vote-card-body { @@ -3372,7 +3713,7 @@ body.is-pane-resizing * { .vote-card-pick { border: 1px solid var(--brand); - background: var(--brand-solid); + background: var(--brand); color: var(--on-solid); border-radius: 8px; padding: 0.4rem 0.75rem; @@ -3400,7 +3741,7 @@ body.is-pane-resizing * { .vote-card-out { border: 1px solid var(--line); background: transparent; - color: var(--muted); + color: var(--muted-foreground); border-radius: 8px; padding: 0.4rem 0.75rem; font-size: 0.75rem; @@ -3417,7 +3758,7 @@ body.is-pane-resizing * { .vote-card-badge { font-size: 0.68rem; font-weight: 700; - color: var(--brand); + color: var(--brand-deep); } .cmp-pref-nav { @@ -3436,7 +3777,7 @@ body.is-pane-resizing * { .cmp-pref-mode { border: 1px solid var(--line); background: var(--surface-sunken); - color: var(--fg); + color: var(--ink); border-radius: 999px; padding: 0.3rem 0.7rem; font-size: 0.73rem; @@ -3446,7 +3787,7 @@ body.is-pane-resizing * { .cmp-pref-mode.on { border-color: var(--brand); - background: var(--brand-solid); + background: var(--brand); color: var(--on-solid); } @@ -3476,7 +3817,7 @@ body.is-pane-resizing * { .cmp-pref-order li::before { counter-increment: pref-order; content: counter(pref-order) '.'; - color: var(--muted); + color: var(--muted-foreground); font-variant-numeric: tabular-nums; } @@ -3494,7 +3835,7 @@ body.is-pane-resizing * { } .cmp-pref-order-note { - color: var(--muted); + color: var(--muted-foreground); font-size: 0.7rem; } @@ -3536,7 +3877,7 @@ body.is-pane-resizing * { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.04em; - color: var(--muted); + color: var(--muted-foreground); } .cmp-route-stats dd { @@ -3548,7 +3889,7 @@ body.is-pane-resizing * { .cmp-route-stats span { font-size: 0.7rem; - color: var(--muted); + color: var(--muted-foreground); line-height: 1.4; } @@ -3572,7 +3913,7 @@ body.is-pane-resizing * { } .gen-notice-detail { - color: var(--muted); + color: var(--muted-foreground); } .gen-notice-error { @@ -3606,7 +3947,7 @@ body.is-pane-resizing * { .gen-desc { margin: 0; font-size: 0.78rem; - color: var(--muted); + color: var(--muted-foreground); line-height: 1.5; } @@ -3655,13 +3996,13 @@ body.is-pane-resizing * { .gen-template-meta { font-size: 0.68rem; - color: var(--muted); + color: var(--muted-foreground); } .gen-template-id { font-size: 0.66rem; - color: var(--muted); - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + color: var(--muted-foreground); + font-family: var(--font-mono); } .gen-detail-head { @@ -3682,7 +4023,7 @@ body.is-pane-resizing * { .gen-subtitle { margin: 0.15rem 0 0; font-size: 0.7rem; - color: var(--muted); + color: var(--muted-foreground); } /* ---- Generate: getting the output out of the page ---- */ @@ -3729,7 +4070,7 @@ body.is-pane-resizing * { margin: 0; font-size: 0.7rem; line-height: 1.55; - color: var(--muted); + color: var(--muted-foreground); } .gen-locale-row { @@ -3798,7 +4139,7 @@ body.is-pane-resizing * { .gen-locale.on { border-color: var(--brand); background: var(--brand-tint); - color: var(--brand-ink); + color: var(--brand-deep); } .gen-count { @@ -3806,7 +4147,7 @@ body.is-pane-resizing * { align-items: center; gap: 0.35rem; font-size: 0.72rem; - color: var(--muted); + color: var(--muted-foreground); } .gen-count input { @@ -3876,7 +4217,7 @@ body.is-pane-resizing * { .gen-seed { font-size: 0.65rem; - color: var(--muted); + color: var(--muted-foreground); font-variant-numeric: tabular-nums; } @@ -3992,21 +4333,21 @@ body.is-pane-resizing * { } .theme-switch-option:focus-visible { - outline: 2px solid var(--brand); + outline: 2px solid var(--ring); outline-offset: 2px; } .theme-switch-option.on { border-color: var(--brand); background: var(--brand-tint); - color: var(--brand-ink); + color: var(--brand-deep); font-weight: 600; } .theme-switch-note { margin: 0; font-size: 0.7rem; - color: var(--muted); + color: var(--muted-foreground); } /* ---- Compare · tool routing ---- */ @@ -4033,13 +4374,13 @@ body.is-pane-resizing * { gap: 0.5rem; font-size: 0.72rem; line-height: 1.4; - color: var(--muted); + color: var(--muted-foreground); } .cmp-legend-key { flex: none; min-width: 4.5rem; - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-family: var(--font-mono); font-size: 0.68rem; font-weight: 700; color: var(--brand-deep); @@ -4077,7 +4418,7 @@ body.is-pane-resizing * { align-items: center; gap: 0.4rem; font-size: 0.72rem; - color: var(--muted); + color: var(--muted-foreground); } .cmp-endpoint-dot { @@ -4085,11 +4426,11 @@ body.is-pane-resizing * { width: 7px; height: 7px; border-radius: 50%; - background: var(--muted); + background: var(--muted-foreground); } .cmp-endpoint-dot.ok { - background: var(--ok); + background: var(--success); } .cmp-endpoint-dot.warn { @@ -4117,7 +4458,7 @@ body.is-pane-resizing * { font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; - color: var(--muted); + color: var(--muted-foreground); } /* Secondary detail folded away, so the card leads with the result. */ @@ -4146,7 +4487,7 @@ body.is-pane-resizing * { content: '▸'; display: inline-block; width: 1em; - color: var(--muted); + color: var(--muted-foreground); } .cmp-details[open] > summary::before { @@ -4155,7 +4496,7 @@ body.is-pane-resizing * { /* A cell that stands in for numbers that were never measured. */ .cmp-cell-muted { - color: var(--muted); + color: var(--muted-foreground); font-style: italic; } @@ -4230,7 +4571,7 @@ body.is-pane-resizing * { .cmp-prompt-stack h4 { margin: 0 0 0.35rem; - color: var(--muted); + color: var(--muted-foreground); font-size: 0.66rem; text-transform: uppercase; } @@ -4277,7 +4618,7 @@ body.is-pane-resizing * { .cmp-prompt-grid h4 { margin: 0 0 0.35rem; - color: var(--muted); + color: var(--muted-foreground); font-size: 0.66rem; text-transform: uppercase; } @@ -4338,10 +4679,10 @@ body.is-pane-resizing * { border: 1px solid var(--line); border-radius: 8px; background: var(--surface-sunken); - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-family: var(--font-mono); font-size: 0.68rem; line-height: 1.5; - color: var(--muted); + color: var(--muted-foreground); font-variant-numeric: tabular-nums; } @@ -4376,7 +4717,7 @@ body.is-pane-resizing * { .az-filters select { border: 1px solid var(--line); background: var(--surface-sunken); - color: var(--fg); + color: var(--ink); border-radius: 8px; padding: 0.3rem 0.55rem; font-size: 0.75rem; @@ -4406,9 +4747,9 @@ body.is-pane-resizing * { } .az-status-done { color: var(--ok); } .az-status-failed { color: var(--danger); } -.az-status-running { color: var(--brand); } +.az-status-running { color: var(--brand-deep); } .az-status-cancelled, -.az-status-queued { color: var(--muted); } +.az-status-queued { color: var(--muted-foreground); } /* Tabs have no bottom edge and top-only corners, so they are meant to sit on the panel they open. The page gap under them is cancelled, not inherited. */ @@ -4422,7 +4763,7 @@ body.is-pane-resizing * { border: 1px solid var(--line); border-bottom: none; background: var(--surface-sunken); - color: var(--fg); + color: var(--ink); border-radius: 8px 8px 0 0; padding: 0.4rem 0.9rem; font-size: 0.78rem; @@ -4430,7 +4771,7 @@ body.is-pane-resizing * { cursor: pointer; } .az-tab.on { - background: var(--brand-solid); + background: var(--brand); color: var(--on-solid); border-color: var(--brand); } @@ -4444,7 +4785,7 @@ body.is-pane-resizing * { .az-chip { border: 1px solid var(--line); background: var(--surface-sunken); - color: var(--fg); + color: var(--ink); border-radius: 999px; padding: 0.25rem 0.6rem; font-size: 0.72rem; @@ -4452,7 +4793,7 @@ body.is-pane-resizing * { } .az-chip.on { border-color: var(--brand); - background: var(--brand-solid); + background: var(--brand); color: var(--on-solid); } .az-chip-recoverable { @@ -4469,17 +4810,17 @@ body.is-pane-resizing * { } .az-annot, .az-was { - color: var(--muted); + color: var(--muted-foreground); font-size: 0.68rem; margin-left: 0.3rem; } .az-lang { - color: var(--muted); + color: var(--muted-foreground); font-size: 0.66rem; margin-left: 0.35rem; } .az-detail-cell { - color: var(--muted); + color: var(--muted-foreground); max-width: 30rem; overflow: hidden; text-overflow: ellipsis; @@ -4494,7 +4835,7 @@ body.is-pane-resizing * { align-items: start; } .az-kv-key { - color: var(--muted); + color: var(--muted-foreground); text-transform: uppercase; font-size: 0.64rem; padding-top: 0.15rem; @@ -4528,7 +4869,7 @@ body.is-pane-resizing * { .az-reclassify select { border: 1px solid var(--line); background: var(--surface-sunken); - color: var(--fg); + color: var(--ink); border-radius: 8px; padding: 0.3rem 0.55rem; font-size: 0.75rem; @@ -4541,7 +4882,7 @@ body.is-pane-resizing * { margin: 0; } .az-facts dt { - color: var(--muted); + color: var(--muted-foreground); text-transform: uppercase; font-size: 0.62rem; } @@ -4551,5 +4892,5 @@ body.is-pane-resizing * { } .az-warn { - color: var(--muted); + color: var(--muted-foreground); } diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 7ba6ca2..2fafac2 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -7,6 +7,18 @@ import { ThemeProvider } from '@/components/ThemeProvider'; import { LOCALE_SCRIPT } from '@/lib/i18n/locale'; import { THEME_SCRIPT } from '@/lib/theme'; +/** + * Pretendard, from the same CDN and at the same pin as every other Redrob product, so the + * type cannot shift under one of them without a commit saying so. This is the one place the + * version is named; `--font-sans` in `globals.css` names the family it registers. + * + * The variable dynamic subset, specifically. It carries the whole weight axis the design + * uses and splits the face into slices the browser fetches only when a page actually renders + * glyphs from them, where the full file is mostly Hangul that an English screen never draws. + */ +const PRETENDARD_CSS = + 'https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css'; + export const metadata: Metadata = { title: 'redrob-eval', description: @@ -29,10 +41,11 @@ export default function RootLayout({ that, because effects run after paint. */} - + {/* Opening the connection early matters more than usual here: the stylesheet and the + font slices it names are two round trips to a third party before any text is in + its real face. */} + + diff --git a/apps/web/src/components/DeployTerminal.tsx b/apps/web/src/components/DeployTerminal.tsx index ec15d7a..ebdb8d5 100644 --- a/apps/web/src/components/DeployTerminal.tsx +++ b/apps/web/src/components/DeployTerminal.tsx @@ -68,20 +68,22 @@ export const DeployTerminal = forwardRef< // xterm paints to a canvas, so it cannot read `var()` the way the rest of the UI // does; the values are resolved once here instead. The surface behind it uses the - // same two tokens, so reading them is what keeps the canvas and its frame from + // same tokens, so reading them is what keeps the canvas and its frame from // drifting apart. A terminal stays dark in both themes -- that is what a terminal - // looks like -- so there is nothing to re-resolve when the theme changes. + // looks like -- so there is nothing to re-resolve when the theme changes. The + // fallbacks are the brand values the tokens hold (Redrob Black and Blue 4), so a + // browser that somehow returns nothing still paints the brand rather than a guess. const styles = getComputedStyle(document.documentElement); const token = (name: string, fallback: string) => styles.getPropertyValue(name).trim() || fallback; const term = new Terminal({ convertEol: true, - fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', + fontFamily: token('--font-mono', 'ui-monospace, monospace'), fontSize: 13, theme: { - background: token('--terminal-bg', '#0b0f14'), - cursor: token('--terminal-cursor', '#6f8bff'), + background: token('--terminal-bg', '#0a0b0c'), + cursor: token('--terminal-cursor', '#8aafff'), }, cursorBlink: true, scrollback: 20000, diff --git a/apps/web/src/components/EvolutionParetoChart.tsx b/apps/web/src/components/EvolutionParetoChart.tsx index 8c2ef93..e386792 100644 --- a/apps/web/src/components/EvolutionParetoChart.tsx +++ b/apps/web/src/components/EvolutionParetoChart.tsx @@ -288,7 +288,7 @@ export function EvolutionParetoChart({ {infeasible.map((entry) => ( diff --git a/apps/web/src/components/EvolutionProgressChart.tsx b/apps/web/src/components/EvolutionProgressChart.tsx index a92695a..5fc07a9 100644 --- a/apps/web/src/components/EvolutionProgressChart.tsx +++ b/apps/web/src/components/EvolutionProgressChart.tsx @@ -207,7 +207,7 @@ export function EvolutionProgressChart({ type="number" domain={[1, Math.max(rows.length, 1)]} allowDecimals={false} - tick={{ fill: 'var(--chart-axis)', fontSize: 11 }} + tick={{ fill: 'var(--chart-tick)', fontSize: 11 }} axisLine={{ stroke: 'var(--line)' }} tickLine={false} /> @@ -215,7 +215,7 @@ export function EvolutionProgressChart({ type="number" domain={yDomain} width={36} - tick={{ fill: 'var(--chart-axis)', fontSize: 11 }} + tick={{ fill: 'var(--chart-tick)', fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `${Math.round(v)}%`} diff --git a/scripts/check-theme-contrast.mts b/scripts/check-theme-contrast.mts index 92d9dc5..861205f 100644 --- a/scripts/check-theme-contrast.mts +++ b/scripts/check-theme-contrast.mts @@ -11,7 +11,7 @@ import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { MINIMUM, PAIRS, ratioFor, readBlocks } from './theme-contrast.mts'; +import { CONSOLE_FLOOR, MINIMUM, PAIRS, floorFor, ratioFor, readBlocks } from './theme-contrast.mts'; const here = dirname(fileURLToPath(import.meta.url)); const css = readFileSync(join(here, '..', 'apps', 'web', 'src', 'app', 'globals.css'), 'utf8'); @@ -24,7 +24,7 @@ const TOLERANCE = 0.02; const rows = PAIRS.map(([fg, bg, kind, backdrop]) => { const lightRatio = ratioFor(fg, bg, light, backdrop); const darkRatio = ratioFor(fg, bg, dark, backdrop); - const floor = Math.min(MINIMUM[kind], lightRatio); + const floor = floorFor(fg, bg, kind, lightRatio); return { fg, bg, kind, lightRatio, darkRatio, floor, ok: darkRatio >= floor - TOLERANCE }; }); @@ -46,6 +46,16 @@ if (belowWcag.length) { ); } +const fromConsole = rows.filter((r) => CONSOLE_FLOOR[`${r.fg} on ${r.bg}`] !== undefined); +if (fromConsole.length) { + console.log( + "\nFloor is Console's value, not this file's (see CONSOLE_FLOOR):\n" + + fromConsole + .map((r) => ` ${r.fg} on ${r.bg} (${CONSOLE_FLOOR[`${r.fg} on ${r.bg}`]}:1)`) + .join('\n'), + ); +} + const shortfalls = rows.filter((r) => !r.ok); console.log( shortfalls.length === 0 diff --git a/scripts/test/design-tokens.test.mts b/scripts/test/design-tokens.test.mts new file mode 100644 index 0000000..0a89b4f --- /dev/null +++ b/scripts/test/design-tokens.test.mts @@ -0,0 +1,567 @@ +/** + * One product, one palette. + * + * Redrob Eval and Redrob Console are two front doors to the same product, and a person who + * learns a colour in one is entitled to find it unchanged in the other. Console keeps the + * tokens in `apps/web/src/app/globals.css`; Eval keeps the same bands at the top of its own + * `apps/web/src/app/globals.css`, primitives first, then semantic roles, then the local + * names the 4,300 lines of rules below already spell. + * + * Two repositories cannot import from each other, so this file is the joint. Every value + * Console declares is written out below, transcribed from its stylesheet, and each test + * checks Eval's copy against it. If either side changes a colour alone, this fails and + * names the token. + * + * What it does not check: that the two products look alike. They do not, and should not; + * Eval is a three-pane workbench and Console is a marketing site with a dashboard behind + * it. What it checks is that where they paint the same role they reach for the same value, + * that a role is decided in exactly one place, and that no surface reaches past the tokens + * for a colour of its own. + * + * The contrast floors are a separate concern and live in `theme-contrast.test.mts`. + */ + +import { strict as assert } from 'node:assert'; +import { readFileSync, readdirSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { test } from 'node:test'; + +const ROOT = process.cwd(); +const SRC = join(ROOT, 'apps', 'web', 'src'); +const CSS_PATH = join(SRC, 'app', 'globals.css'); +const LAYOUT_PATH = join(SRC, 'app', 'layout.tsx'); + +const css = readFileSync(CSS_PATH, 'utf8'); +const layout = readFileSync(LAYOUT_PATH, 'utf8'); + +/** Strip comments, so a value quoted in prose is never mistaken for a declaration. */ +function stripComments(source: string): string { + return source.replace(/\/\*[\s\S]*?\*\//g, ''); +} + +/** + * The declarations of every rule with this selector, read to the matching closing brace and + * merged. Merged because the token layer states `:root` more than once: primitives, then + * roles, then the local names, so each band can carry its own heading. + */ +function declarations(source: string, selector: string): Record { + const text = stripComments(source); + const found: Record = {}; + const re = new RegExp(`^${selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\{`, 'gm'); + let matched = false; + for (const match of text.matchAll(re)) { + matched = true; + let depth = 1; + let index = match.index + match[0].length; + const opening = index; + while (depth > 0) { + if (text[index] === '{') depth += 1; + if (text[index] === '}') depth -= 1; + index += 1; + } + for (const decl of text.slice(opening, index - 1).matchAll(/(--[\w-]+)\s*:\s*([^;]+);/g)) { + found[decl[1]] = decl[2].split(/\s+/).join(' '); + } + } + assert.ok(matched, `no rule for ${selector}`); + return found; +} + +/** The token layer: everything down to the close of the `@theme inline` block. */ +const TOKEN_LAYER = css.slice(0, css.indexOf('\n}\n', css.indexOf('@theme inline {')) + 3); +/** The rules. Nothing here may decide a colour, and nothing here may name a primitive. */ +const RULES = css.slice(TOKEN_LAYER.length); + +const light = declarations(TOKEN_LAYER, ':root'); +const dark = declarations(TOKEN_LAYER, "[data-theme='dark']"); +const theme = declarations(TOKEN_LAYER, '@theme inline'); + +/** A declaration that has to be there, so a missing token fails by name, not as `undefined`. */ +function must(found: Record, name: string, where: string): string { + const value = found[name]; + assert.equal(typeof value, 'string', `${name} is not declared for ${where}`); + return value as string; +} + +/** + * The brand's confirmed HEX values, transcribed from Console. Theme-independent by + * construction: Blue 6 is Blue 6 under either theme, which is why none of them is repeated + * in the dark block. + */ +const CONSOLE_PRIMITIVES: Record = { + '--rr-blue': '#2b52ff', + '--rr-black': '#0a0b0c', + '--rr-white': '#ffffff', + '--rr-blue-1': '#eff4ff', + '--rr-blue-2': '#d9e6ff', + '--rr-blue-3': '#bad2ff', + '--rr-blue-4': '#8aafff', + '--rr-blue-5': '#507fff', + '--rr-blue-6': '#2b52ff', + '--rr-blue-7': '#1733d5', + '--rr-blue-8': '#09209c', + '--rr-blue-9': '#061460', + '--rr-blue-10': '#030c34', + '--rr-gray-1': '#f8f9fb', + '--rr-gray-2': '#eff1f4', + '--rr-gray-3': '#dfe2e8', + '--rr-gray-4': '#cbcfd7', + '--rr-gray-5': '#aab0bb', + '--rr-gray-6': '#7c8390', + '--rr-gray-7': '#576071', + '--rr-gray-8': '#292e37', + '--rr-gray-9': '#141719', + '--rr-gradient-primary': 'linear-gradient(135deg, #f8f9fb 0%, #507fff 100%)', + '--rr-teal-1': '#dcfffe', + '--rr-teal-2': '#b9fffd', + '--rr-teal-3': '#6ff4f0', + '--rr-teal-4': '#00b5c2', + '--rr-teal-5': '#006a7a', + '--rr-sky-1': '#e4f0ff', + '--rr-sky-2': '#bad9ff', + '--rr-sky-3': '#2f8dff', + '--rr-sky-4': '#0e51b6', + '--rr-sky-5': '#002a68', + '--rr-violet-1': '#f2e9ff', + '--rr-violet-2': '#d4b3ff', + '--rr-violet-3': '#8944ff', + '--rr-violet-4': '#4500ac', + '--rr-violet-5': '#140042', + '--rr-pink-1': '#ffe3fc', + '--rr-pink-2': '#ffb2f6', + '--rr-pink-3': '#ff39ba', + '--rr-pink-4': '#8a0061', + '--rr-pink-5': '#380037', + '--rr-red-1': '#ffe8e1', + '--rr-red-2': '#ffc2ba', + '--rr-red-3': '#ff5452', + '--rr-red-4': '#a31310', + '--rr-red-5': '#560100', + '--rr-orange-1': '#ffedda', + '--rr-orange-2': '#ffd5ab', + '--rr-orange-3': '#ff9c1b', + '--rr-orange-4': '#ae5100', + '--rr-orange-5': '#5c2d00', + '--rr-yellow-1': '#fff7cc', + '--rr-yellow-2': '#ffed94', + '--rr-yellow-3': '#ffda1e', + '--rr-yellow-4': '#d2a100', + '--rr-yellow-5': '#734f00', + '--rr-lime-1': '#f2ffc3', + '--rr-lime-2': '#e5ff81', + '--rr-lime-3': '#cffd21', + '--rr-lime-4': '#89ad00', + '--rr-lime-5': '#2f5f00', + '--rr-green-1': '#d6ffe1', + '--rr-green-2': '#a6ffbf', + '--rr-green-3': '#29e474', + '--rr-green-4': '#00864a', + '--rr-green-5': '#004829', +}; + +/** + * Console's semantic roles as `[light, dark]`. Every value is a primitive or a mix of two, + * because a literal here would be a place a colour is decided that is not the brand. + * + * Roles Console re-points for dark are written twice; roles it declares once carry the same + * string in both slots, which is the assertion that Eval does not re-point them either. + */ +const CONSOLE_ROLES: Record = { + '--background': ['var(--rr-gray-1)', 'var(--rr-gray-9)'], + '--background-secondary': ['var(--rr-gray-2)', 'var(--rr-black)'], + '--card': ['var(--rr-white)', 'color-mix(in srgb, var(--rr-gray-8) 45%, var(--rr-gray-9))'], + '--card-foreground': ['var(--rr-gray-9)', 'var(--rr-gray-1)'], + '--popover': ['var(--rr-white)', 'color-mix(in srgb, var(--rr-gray-8) 45%, var(--rr-gray-9))'], + '--popover-foreground': ['var(--rr-gray-9)', 'var(--rr-gray-1)'], + '--foreground': ['var(--rr-gray-9)', 'var(--rr-gray-1)'], + '--muted-foreground': ['var(--rr-gray-7)', 'var(--rr-gray-5)'], + '--subtle-foreground': ['var(--rr-gray-6)', 'var(--rr-gray-6)'], + '--disabled-foreground': ['var(--rr-gray-5)', 'var(--rr-gray-7)'], + '--accent': ['var(--rr-gray-2)', 'color-mix(in srgb, var(--rr-gray-8) 45%, var(--rr-gray-9))'], + '--accent-foreground': ['var(--rr-gray-9)', 'var(--rr-gray-1)'], + '--accent-active': ['var(--rr-gray-3)', 'var(--rr-gray-8)'], + '--secondary': ['var(--rr-gray-2)', 'var(--rr-gray-8)'], + '--secondary-foreground': ['var(--rr-gray-9)', 'var(--rr-gray-1)'], + '--muted': ['var(--rr-gray-2)', 'var(--rr-gray-8)'], + '--primary': ['var(--rr-blue-6)', 'var(--rr-blue-5)'], + '--primary-foreground': ['var(--rr-white)', 'var(--rr-black)'], + '--primary-hover': ['var(--rr-blue-7)', 'var(--rr-blue-4)'], + '--primary-muted': ['var(--rr-blue-3)', 'var(--rr-blue-9)'], + '--primary-soft': ['var(--rr-blue-1)', 'var(--rr-blue-10)'], + '--primary-ink': ['var(--rr-blue-6)', 'var(--rr-blue-4)'], + '--border-subtle': [ + 'var(--rr-gray-2)', + 'color-mix(in srgb, var(--rr-gray-8) 55%, var(--rr-gray-9))', + ], + '--border': ['var(--rr-gray-3)', 'var(--rr-gray-8)'], + '--border-strong': ['var(--rr-gray-4)', 'var(--rr-gray-7)'], + '--input': ['var(--rr-gray-6)', 'var(--rr-gray-6)'], + '--ring': ['var(--rr-blue-6)', 'var(--rr-blue-5)'], + '--success': ['var(--rr-green-4)', 'var(--rr-green-3)'], + '--success-foreground': ['var(--rr-white)', 'var(--rr-black)'], + '--success-soft': ['var(--rr-green-1)', 'var(--rr-green-5)'], + '--success-ink': ['var(--rr-green-5)', 'var(--rr-green-3)'], + '--warning': ['var(--rr-orange-4)', 'var(--rr-orange-3)'], + '--warning-foreground': ['var(--rr-white)', 'var(--rr-black)'], + '--warning-soft': ['var(--rr-orange-1)', 'var(--rr-orange-5)'], + '--warning-ink': ['var(--rr-orange-4)', 'var(--rr-orange-3)'], + '--destructive': ['var(--rr-red-4)', 'var(--rr-red-3)'], + '--destructive-foreground': ['var(--rr-white)', 'var(--rr-black)'], + '--destructive-soft': ['var(--rr-red-1)', 'var(--rr-red-5)'], + '--destructive-ink': ['var(--rr-red-4)', 'var(--rr-red-3)'], + '--overlay': [ + 'color-mix(in srgb, var(--rr-black) 45%, transparent)', + 'color-mix(in srgb, var(--rr-black) 65%, transparent)', + ], + '--tooltip': ['var(--rr-gray-9)', 'var(--rr-black)'], + '--tooltip-foreground': ['var(--rr-gray-1)', 'var(--rr-gray-1)'], + '--sidebar': ['var(--rr-white)', 'var(--rr-black)'], + '--sidebar-foreground': ['var(--rr-gray-9)', 'var(--rr-gray-1)'], + '--sidebar-primary': ['var(--rr-blue-6)', 'var(--rr-blue-5)'], + '--sidebar-primary-foreground': ['var(--rr-white)', 'var(--rr-black)'], + '--sidebar-accent': ['var(--rr-gray-2)', 'var(--rr-gray-8)'], + '--sidebar-accent-foreground': ['var(--rr-gray-9)', 'var(--rr-gray-1)'], + '--sidebar-border': ['var(--rr-gray-3)', 'var(--rr-gray-8)'], + '--sidebar-ring': ['var(--rr-blue-6)', 'var(--rr-blue-5)'], + '--spectrum-teal': ['var(--rr-teal-5)', 'var(--rr-teal-3)'], + '--spectrum-sky': ['var(--rr-sky-4)', 'var(--rr-sky-3)'], + '--spectrum-violet': ['var(--rr-violet-4)', 'var(--rr-violet-3)'], + '--spectrum-pink': ['var(--rr-pink-4)', 'var(--rr-pink-3)'], + '--spectrum-red': ['var(--rr-red-4)', 'var(--rr-red-3)'], + '--spectrum-orange': ['var(--rr-orange-4)', 'var(--rr-orange-3)'], + '--spectrum-yellow': ['var(--rr-yellow-5)', 'var(--rr-yellow-3)'], + '--spectrum-lime': ['var(--rr-lime-5)', 'var(--rr-lime-3)'], + '--spectrum-green': ['var(--rr-green-4)', 'var(--rr-green-3)'], + '--chart-1': ['var(--spectrum-sky)', 'var(--spectrum-sky)'], + '--chart-2': ['var(--spectrum-pink)', 'var(--spectrum-pink)'], + '--chart-3': ['var(--spectrum-lime)', 'var(--spectrum-lime)'], + '--chart-4': ['var(--spectrum-orange)', 'var(--spectrum-orange)'], + '--chart-5': ['var(--spectrum-violet)', 'var(--spectrum-violet)'], + '--chart-6': ['var(--spectrum-teal)', 'var(--spectrum-teal)'], + '--chart-7': ['var(--spectrum-yellow)', 'var(--spectrum-yellow)'], + '--chart-8': ['var(--spectrum-green)', 'var(--spectrum-green)'], + '--chart-cursor': [ + 'color-mix(in srgb, var(--rr-gray-8) 8%, transparent)', + 'color-mix(in srgb, var(--rr-gray-1) 8%, transparent)', + ], + '--code-comment': ['var(--rr-gray-7)', 'var(--rr-gray-5)'], + '--code-string': ['var(--rr-green-5)', 'var(--rr-green-3)'], + '--code-number': ['var(--rr-orange-4)', 'var(--rr-orange-3)'], + '--code-keyword': ['var(--rr-violet-4)', 'var(--rr-violet-2)'], + '--code-literal': ['var(--rr-pink-4)', 'var(--rr-pink-2)'], + '--code-property': ['var(--rr-sky-4)', 'var(--rr-sky-2)'], + '--code-variable': ['var(--rr-teal-5)', 'var(--rr-teal-3)'], + '--code-operator': ['var(--rr-gray-7)', 'var(--rr-gray-5)'], + '--code-punctuation': ['var(--rr-gray-7)', 'var(--rr-gray-5)'], + '--shadow-soft': [ + '0 1px 2px color-mix(in srgb, var(--rr-gray-8) 5%, transparent)', + '0 1px 2px color-mix(in srgb, var(--rr-black) 30%, transparent)', + ], + '--shadow-card': [ + '0 1px 2px color-mix(in srgb, var(--rr-gray-8) 5%, transparent), ' + + '0 4px 16px color-mix(in srgb, var(--rr-gray-8) 7%, transparent)', + '0 1px 2px color-mix(in srgb, var(--rr-black) 25%, transparent), ' + + '0 4px 16px color-mix(in srgb, var(--rr-black) 35%, transparent)', + ], + '--shadow-elevated': [ + '0 2px 4px color-mix(in srgb, var(--rr-gray-8) 6%, transparent), ' + + '0 12px 32px color-mix(in srgb, var(--rr-gray-8) 12%, transparent)', + '0 2px 4px color-mix(in srgb, var(--rr-black) 30%, transparent), ' + + '0 12px 32px color-mix(in srgb, var(--rr-black) 50%, transparent)', + ], +}; + +/** Console's type tokens. Same families, or the two products are not the same product. */ +const CONSOLE_TYPE: Record = { + '--font-sans': + '"Pretendard Variable", Pretendard, -apple-system, BlinkMacSystemFont, system-ui, ' + + '"Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', + '--font-display': 'var(--font-sans)', + '--font-mono': + '"JetBrains Mono Variable", "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", ' + + 'Menlo, Consolas, "Liberation Mono", monospace', +}; + +/** + * Roles this app needs and Console does not name. Each is declared from primitives only and + * commented where it is declared; the list is here so a role cannot appear in the stylesheet + * without a line in this file admitting it is ours and not Console's. + * + * --input-hover a control boundary under the cursor + * --success-line --warning-line + * --destructive-line the edge of a status strip, which Console's + * strips do not have + * --progress-start --progress-end the two ends of the run bar, which is drawn on + * a surface that is dark in both themes + * --titlebar and its fourteen inks a bar Console has no equivalent of + * --terminal-bg --terminal-cursor + * --terminal-scroll a terminal, likewise + */ +const NOT_IN_CONSOLE = [ + '--input-hover', + '--success-line', + '--warning-line', + '--destructive-line', + '--progress-start', + '--progress-end', + '--titlebar', + '--titlebar-ink', + '--titlebar-ink-strong', + '--titlebar-ink-max', + '--titlebar-muted', + '--titlebar-muted-strong', + '--titlebar-hover-ink', + '--titlebar-accent', + '--titlebar-accent-hover', + '--titlebar-ok', + '--titlebar-bad', + '--titlebar-hairline', + '--titlebar-hover', + '--titlebar-divider', + '--titlebar-active', + '--titlebar-btn-line', + '--titlebar-btn-hover', + '--titlebar-btn-hover-line', + '--terminal-bg', + '--terminal-cursor', + '--terminal-scroll', +]; + +/** + * Band 3. The vocabulary the rules below already spell, as aliases onto band 2, and the one + * place to look to answer "what is `--panel`". Listed here because the point of the band is + * that it holds no values, and a test is the only thing that keeps it that way. + */ +const LOCAL_NAMES = [ + '--bg', + '--panel', + '--surface-sunken', + '--surface-hover', + '--line', + '--line-strong', + '--ink', + '--miss', + '--on-solid', + '--brand', + '--brand-hover', + '--brand-deep', + '--brand-tint', + '--brand-tint-soft', + '--ok', + '--ok-tint', + '--ok-line', + '--ok-tint-soft', + '--warn', + '--warn-tint', + '--warn-line', + '--warn-line-soft', + '--danger', + '--danger-solid', + '--danger-tint', + '--danger-line', + '--router-tint', + '--ghost-line', + '--ghost-line-hover', + '--feasible', + '--series-seed', + '--series-neutral', + '--chart-tick', + '--chart-label', + '--chart-faint', + '--chart-grid', + '--scrim', + '--shadow-modal', +]; + +test("carries every brand primitive at Console's value", () => { + for (const [name, value] of Object.entries(CONSOLE_PRIMITIVES)) { + assert.equal(must(light, name, 'light'), value, `${name} drifted from Console`); + } + // Nothing extra: a primitive this app invented would be a brand colour nobody approved. + const declared = Object.keys(light).filter((name) => name.startsWith('--rr-')); + assert.deepEqual(declared.sort(), Object.keys(CONSOLE_PRIMITIVES).sort()); +}); + +test('leaves the primitives theme-independent', () => { + // Blue 6 is Blue 6 in light and in dark. Re-pointing one for a theme would mean the brand + // has two blues, and every role that reads it would move without saying so. + assert.deepEqual( + Object.keys(dark).filter((name) => name.startsWith('--rr-')), + [], + ); +}); + +test('points every semantic role where Console points it, in both themes', () => { + for (const [role, [expectLight, expectDark]] of Object.entries(CONSOLE_ROLES)) { + assert.equal(must(light, role, 'light'), expectLight, `${role}, light`); + // A role Console leaves alone in dark is one this app leaves alone too, so its dark value + // is the light one falling through. + assert.equal(dark[role] ?? light[role], expectDark, `${role}, dark`); + } +}); + +test('re-points in dark exactly the roles Console re-points', () => { + /** + * Console restates a handful of roles in its dark block at the value they already hold, and + * each of those restatements carries a contrast note explaining why the step does not move + * (`--input` and `--subtle-foreground` are the same grey in both themes on purpose). So the + * assertion is set equality, not "no redundant twin": a role missing from this block would + * fall through to a light value on a dark page, and a role added to it would be a value + * decided here that Console decides elsewhere. + * + * The four extras are the roles Console does not have, and all four need a dark step: a + * status strip's edge and a control boundary's hover both move with the theme. + */ + const consoleDarkKeys = Object.entries(CONSOLE_ROLES) + .filter(([role]) => role in dark) + .map(([role]) => role); + assert.equal(consoleDarkKeys.length, 72, "Console re-points 72 roles for dark"); + assert.deepEqual(Object.keys(dark).sort(), [ + ...consoleDarkKeys, + '--destructive-line', + '--input-hover', + '--success-line', + '--warning-line', + ].sort()); +}); + +test('decides no colour outside the primitive band', () => { + // Everything after the primitives is `var()` or a mix of `var()`s. A literal below that + // point is a second place a colour is decided, and the two will drift. + const primitives = stripComments(TOKEN_LAYER); + const opening = primitives.indexOf('{', primitives.indexOf(':root')); + let depth = 1; + let index = opening + 1; + while (depth > 0) { + if (primitives[index] === '{') depth += 1; + if (primitives[index] === '}') depth -= 1; + index += 1; + } + const literals = primitives.slice(index).match(/#[0-9a-fA-F]{3,8}\b|\brgba?\(/g) ?? []; + assert.deepEqual(literals, [], `literal colours in the semantic and local bands: ${literals}`); +}); + +test('accounts for every role Console has, and every role it does not', () => { + const roles = Object.keys(light).filter((name) => !name.startsWith('--rr-')); + const expected = [...Object.keys(CONSOLE_ROLES), ...NOT_IN_CONSOLE, ...LOCAL_NAMES].sort(); + // Three ways in and no fourth: a role Console decides, a role this app decides and admits + // to, or a local name that decides nothing. Anything else fails here by name. + assert.deepEqual(roles.sort(), expected); + // Console's full role set, so a role added there cannot be quietly skipped here. + assert.equal(Object.keys(CONSOLE_ROLES).length, 80); + assert.equal(new Set(expected).size, expected.length, 'a name is listed twice'); +}); + +test('keeps the local vocabulary as aliases and nothing more', () => { + for (const name of LOCAL_NAMES) { + const value = must(light, name, 'light'); + assert.match( + value, + /^(var\(--[\w-]+\)|color-mix\(in srgb, var\(--[\w-]+\) [\d.]+%, transparent\))$/, + `${name} is not a plain alias, so it is a second place a colour is decided`, + ); + // The role it names is already re-pointed for dark, so a twin here would be a second + // place the dark palette is decided. Deleting those twins is most of what this change is. + assert.ok(!(name in dark), `${name} has a dark twin, which the semantic layer makes dead`); + } +}); + +test('lets no rule name a brand primitive', () => { + // The rules speak band 2 and band 3. Reaching past them to `--rr-blue-6` is how a screen + // ends up with a colour that cannot follow the theme. + const named = [...new Set(RULES.match(/var\(--rr-[\w-]+\)/g) ?? [])]; + assert.deepEqual(named, [], `rules naming a primitive: ${named.join(', ')}`); + for (const file of sources()) { + const named = [...new Set(readFileSync(file, 'utf8').match(/--rr-[\w-]+/g) ?? [])]; + assert.deepEqual(named, [], `${file} names a primitive: ${named.join(', ')}`); + } +}); + +test('hardcodes no colour in any component', () => { + /** + * `DeployTerminal` is the one exception and the reason it is one: xterm paints to a canvas, + * which cannot resolve `var()`, so the component reads the two terminal tokens through + * `getComputedStyle` and needs a literal for the case where that returns nothing. Both + * literals have to be the brand value the token holds, or the fallback path paints + * off-brand and nobody notices until it fires. + */ + const allowed = new Map([ + [join(SRC, 'components', 'DeployTerminal.tsx'), ['#0a0b0c', '#8aafff']], + ]); + for (const file of sources()) { + const found = readFileSync(file, 'utf8').match(/#[0-9a-fA-F]{3}(?:[0-9a-fA-F]{3})?\b/g) ?? []; + assert.deepEqual(found, allowed.get(file) ?? [], `hardcoded colours in ${file}`); + } + assert.equal(must(light, '--terminal-bg', 'light'), 'var(--rr-black)'); + assert.equal(CONSOLE_PRIMITIVES['--rr-black'], '#0a0b0c'); + assert.equal(must(light, '--terminal-cursor', 'light'), 'var(--rr-blue-4)'); + assert.equal(CONSOLE_PRIMITIVES['--rr-blue-4'], '#8aafff'); +}); + +test('names Pretendard first, and the same stack Console names', () => { + for (const [name, value] of Object.entries(CONSOLE_TYPE)) { + assert.equal(must(theme, name, '@theme inline'), value, `${name} drifted from Console`); + } + // Pretendard carries Latin and Hangul in one face, so Korean is not a fallback here: it is + // the same font, and it has to be asked for first or the system face wins the race. + assert.ok(must(theme, '--font-sans', '@theme inline').startsWith('"Pretendard Variable", Pretendard,')); + // One declaration of the stack. It used to be copied literally into five rules, which is + // five places a family could be changed and four of them would be missed. + assert.equal(css.split('Pretendard Variable').length - 1, 2, 'the stack is written twice'); +}); + +test('pins Pretendard once, to the build the other products load', () => { + const pins = layout.match(/pretendard@[\w.]+/g) ?? []; + assert.deepEqual(pins, ['pretendard@v1.3.9'], 'the version is named more than once, or not at all'); + // The variable dynamic subset: the whole weight axis, split into slices the browser fetches + // only for glyphs a page actually renders. Console loads the same file. + assert.match(layout, /variable\/pretendardvariable-dynamic-subset\.min\.css/); + assert.match(layout, /rel="preconnect" href="https:\/\/cdn\.jsdelivr\.net"/); +}); + +test('keeps the monospace stack a code font', () => { + /** + * Code surfaces only: token values, ids, machine paths, streamed log output. Never a UI + * label and never body copy. Listed rather than counted, so adding a mono surface is a + * decision somebody writes down. + */ + const CODE_SURFACES = [ + 'code', + '.mono', + '.evolve-chart-tip-id', + '.deploy-hf-input', + '.settings-input', + '.gen-template-id', + '.cmp-legend-key', + '.cmp-log', + ]; + const users: string[] = []; + for (const rule of stripComments(RULES).matchAll(/([^{}]+)\{([^{}]*)\}/g)) { + if (rule[2].includes('var(--font-mono)')) users.push(rule[1].split(/\s+/).join(' ').trim()); + } + assert.deepEqual(users.sort(), [...CODE_SURFACES].sort()); +}); + +test("does not take Console's type size scale, and says so", () => { + // The requirement is not that the scale is absent; it is that the reason is written down, + // because a later change that takes it has to take it everywhere at once or every screen + // re-flows a little. + assert.match(TOKEN_LAYER, /type size, leading and tracking scale/); + const scale = Object.keys(light).filter((name) => /^--(text|font-size|leading|tracking)-/.test(name)); + assert.deepEqual(scale, [], 'half a type scale is worse than none'); +}); + +test('writes no em dash in the token layer', () => { + // Never an em dash in this product, and the file the brand lives in is no exception. + assert.equal(TOKEN_LAYER.includes('\u2014'), false); +}); + +/** Every source file under `apps/web/src`, so a new panel is covered the day it is written. */ +function sources(): string[] { + const walk = (directory: string): string[] => + readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = resolve(directory, entry.name); + return entry.isDirectory() ? walk(path) : [path]; + }); + return walk(SRC).filter((path) => /\.(tsx?|mts)$/.test(path)); +} diff --git a/scripts/test/theme-contrast.test.mts b/scripts/test/theme-contrast.test.mts index a16f84b..2f70dc9 100644 --- a/scripts/test/theme-contrast.test.mts +++ b/scripts/test/theme-contrast.test.mts @@ -19,6 +19,7 @@ import { test } from 'node:test'; import { MINIMUM, PAIRS, + floorFor, ratioFor, readBlocks, type Kind, @@ -62,13 +63,14 @@ test('the dark palette overrides the light one rather than replacing it', () => * failed -- hairline borders, muted grey on the page background -- and the only way to * pass would be to restyle the light theme, which is not what adding a dark mode is for. * So each pairing is judged against the stricter of its WCAG floor and what light - * manages. + * manages, and for the pairings in `CONSOLE_FLOOR` the WCAG floor is replaced by the + * value Redrob Console ships, for the reason given there. */ for (const [fg, bg, kind, backdrop] of PAIRS) { test(`dark: ${fg} on ${bg}`, () => { const lightRatio = ratioFor(fg, bg, light, backdrop); const darkRatio = ratioFor(fg, bg, dark, backdrop); - const floor = Math.min(MINIMUM[kind as Kind], lightRatio); + const floor = floorFor(fg, bg, kind as Kind, lightRatio); assert.ok( darkRatio >= floor - TOLERANCE, `${fg} on ${bg} is ${darkRatio.toFixed(2)}:1 in dark, below the ` + diff --git a/scripts/theme-contrast.mts b/scripts/theme-contrast.mts index 92446ff..d344e10 100644 --- a/scripts/theme-contrast.mts +++ b/scripts/theme-contrast.mts @@ -54,6 +54,10 @@ export function resolve(value: string, tokens: Tokens, depth = 0): Rgba { if (v.startsWith('#')) return parseHex(v); + // The one CSS colour keyword the token layer uses. It is the second operand of every + // `color-mix` that produces a translucent wash, so without it those tokens cannot be read. + if (v === 'transparent') return [0, 0, 0, 0]; + const rgba = v.match(/^rgba?\(([^)]+)\)$/); if (rgba) { const parts = rgba[1].split(',').map((p) => parseFloat(p.trim())); @@ -72,8 +76,14 @@ export function resolve(value: string, tokens: Tokens, depth = 0): Rgba { const a = resolve(mix[1], tokens, depth + 1); const b = resolve(mix[3], tokens, depth + 1); const p = parseFloat(mix[2]) / 100; - const mixed = [0, 1, 2].map((i) => a[i] * p + b[i] * (1 - p)); - return [mixed[0], mixed[1], mixed[2], 1]; + // `color-mix` in a rectangular space interpolates premultiplied, which is what makes + // `color-mix(in srgb, white 10%, transparent)` a 10% white rather than a 10% grey. With + // two opaque operands the premultiplication cancels and this is the plain weighted mean. + const alpha = a[3] * p + b[3] * (1 - p); + const rgb = [0, 1, 2].map((i) => + alpha === 0 ? 0 : (a[i] * a[3] * p + b[i] * b[3] * (1 - p)) / alpha, + ); + return [rgb[0], rgb[1], rgb[2], alpha]; } throw new Error(`cannot resolve ${v}`); @@ -116,31 +126,58 @@ export function ratioFor(fg: string, bg: string, tokens: Tokens, backdrop?: stri */ export const MINIMUM: Record = { text: 4.5, large: 3, ui: 3, surface: 1.25 }; +/** + * Pairings whose floor is Redrob Console's value rather than this file's. + * + * The brand's grayscale has three steps at the dark end and no more: Gray 9 is the page, + * Gray 8 is the next one up, and the raised panel is a mix of the two because there is + * nothing between them. That leaves a Gray 8 border drawn on a Gray 8/Gray 9 panel at + * 1.18:1, and Console ships exactly that, because the alternative is to pick a border + * colour the brand does not contain. + * + * Recorded rather than fixed, and recorded per pairing rather than by lowering the floor, + * so the exemption cannot quietly grow: `design-tokens.test.mts` asserts that the tokens + * either side of it still hold Console's values, which is what makes this number Console's + * and not ours. Two more dark steps in the brand scale would retire it. + * + * Keyed `fg on bg`, valued with the floor to use instead of `MINIMUM[kind]`. + */ +export const CONSOLE_FLOOR: Record = { + '--line on --panel': 1.18, +}; + +/** The floor a pairing has to clear in dark: the stricter of WCAG and what light manages. */ +export function floorFor(fg: string, bg: string, kind: Kind, lightRatio: number): number { + return Math.min(CONSOLE_FLOOR[`${fg} on ${bg}`] ?? MINIMUM[kind], lightRatio); +} + export const PAIRS: Pair[] = [ ['--ink', '--bg', 'text'], ['--ink', '--panel', 'text'], ['--ink', '--surface-sunken', 'text'], ['--ink', '--surface-hover', 'text'], - ['--muted', '--panel', 'text'], - ['--muted', '--bg', 'text'], - ['--muted', '--surface-sunken', 'text'], - ['--brand', '--panel', 'text'], - ['--brand', '--bg', 'text'], + ['--muted-foreground', '--panel', 'text'], + ['--muted-foreground', '--bg', 'text'], + ['--muted-foreground', '--surface-sunken', 'text'], + // `--brand` is the fill, the border and the ring; `--brand-deep` is the same colour as + // text, and it is the one held to 4.5:1. Blue 5 on a raised dark panel is 4.4:1, which is + // exactly why the two are separate roles. ['--brand-deep', '--panel', 'text'], - ['--brand-ink', '--brand-tint', 'text'], + ['--brand-deep', '--bg', 'text'], + ['--brand-deep', '--surface-sunken', 'text'], + ['--brand-deep', '--brand-tint', 'text'], ['--ok', '--panel', 'text'], ['--ok', '--ok-tint', 'text'], ['--warn', '--panel', 'text'], ['--warn', '--warn-tint', 'text'], ['--danger', '--panel', 'text'], ['--danger', '--danger-tint', 'text'], - ['--on-solid', '--brand-solid', 'text'], - ['--on-solid', '--brand-solid-hover', 'text'], + ['--on-solid', '--brand', 'text'], + ['--on-solid', '--brand-hover', 'text'], ['--on-solid', '--danger-solid', 'text'], ['--ink', '--warn-tint', 'text'], ['--ink', '--danger-tint', 'text'], ['--ink', '--ok-tint', 'text'], - ['--ink', '--accent-tint', 'text'], ['--ink', '--brand-tint', 'text'], ['--titlebar-ink', '--titlebar', 'text'], @@ -151,9 +188,12 @@ export const PAIRS: Pair[] = [ ['--titlebar-accent', '--titlebar', 'text'], ['--titlebar-ok', '--titlebar', 'text'], ['--titlebar-bad', '--titlebar', 'text'], + // The progress bar is drawn on the titlebar, which is dark in both themes, so its two + // ends take the bright spectrum levels in both rather than the light-theme steps. + ['--progress-start', '--titlebar', 'ui'], + ['--progress-end', '--titlebar', 'ui'], ['--chart-tick', '--panel', 'ui'], - ['--chart-axis', '--panel', 'ui'], ['--chart-label', '--panel', 'text'], ['--chart-faint', '--panel', 'ui'], ['--series-neutral', '--panel', 'ui'], @@ -162,9 +202,13 @@ export const PAIRS: Pair[] = [ ['--brand', '--panel', 'ui'], ['--miss', '--panel', 'ui'], + // The visual boundary of a control, which WCAG 1.4.11 governs and a decorative border + // does not. It is the reason `--input` is a step away from `--border`. + ['--ghost-line', '--panel', 'ui'], + ['--ghost-line', '--bg', 'ui'], + ['--line', '--panel', 'surface'], ['--line-strong', '--panel', 'surface'], - ['--brand-solid', '--panel', 'ui'], ['--panel', '--bg', 'surface'], ['--titlebar', '--bg', 'surface'], // The edge under the titlebar, composited onto the bar it is drawn on.