From f74fba51ee7abf67dfede1198101112153ecbd28 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 30 Aug 2026 17:02:52 +0000 Subject: [PATCH 1/3] feat(ui): put Redrob Eval on the Redrob brand tokens The palette was this app's own: Tailwind greys for the page and the lines, a navy titlebar, a teal accent left over from when the primary button was teal, and a second copy of every colour in the dark block. None of it was a brand value, so the same screen in Eval and in Console were two shades of the same idea. The stylesheet now carries the same three bands Console does. The brand primitives are transcribed verbatim from Console's `globals.css`, all sixty-eight of them. On top of that sit Console's eighty semantic roles at Console's exact values in both themes, plus the handful of roles Console has no equivalent of: the titlebar and its inks, the terminal, the two ends of the run bar, a status strip's edge, and a control boundary under the cursor. Each of those is built from primitives only and commented where it is declared. The four thousand three hundred lines of rules below are not rewritten. The names they already spell are rebound as aliases onto the roles, one line each, which is how the Office and Work ports did it: `--panel` is `var(--card)` and `--line` is `var(--border)`, and the dark twins those names used to carry are deleted, because the role each one points at is already re-pointed for dark. Four names went instead of moving: --muted was secondary text here and a surface in Console, and the two cannot both hold the name. Renamed to `--muted-foreground`, which is what it always was. --brand-solid was `--brand` with the theme lifted off it, so that a white label stayed readable. On dark the ink is Redrob Black now, so the fill does not need to stay saturated and the two collapse. --brand-ink was a third step of brand text between `--brand-deep` and the fill. Under the brand scale it is `--brand-deep`. --accent-* four teal tokens, every caller a selected state. A selected state is brand blue. Two smaller things fall out of it. The four literal copies of the Pretendard stack and the eight of the monospace stack are gone: both are declared once, in `@theme inline`, and named through `var()`. And the titlebar is one colour in both themes rather than two navies, because the dark theme's requirement fixes it: a bar darker than a Gray 9 page measures 1.19:1 against it, which is to say it is not visible. Not taken: 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. Console and Office made the same call. Radius and spacing are unchanged. `--line` on `--panel` is 1.18:1 in dark, under the 1.25:1 this repo asks of a surface join. That is Console's value: the brand grayscale has three steps at the dark end, the raised panel is a mix of two of them, and a Gray 8 border drawn on it lands there. Recorded per pairing in `CONSOLE_FLOOR` rather than by lowering the floor, so the exemption cannot quietly grow. Co-authored-by: janghoonlee --- apps/web/src/app/globals.css | 1079 +++++++++++------ apps/web/src/components/DeployTerminal.tsx | 12 +- .../src/components/EvolutionParetoChart.tsx | 2 +- .../src/components/EvolutionProgressChart.tsx | 4 +- scripts/check-theme-contrast.mts | 14 +- scripts/test/theme-contrast.test.mts | 6 +- scripts/theme-contrast.mts | 70 +- 7 files changed, 793 insertions(+), 394 deletions(-) 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/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/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. From 9811927b4134a41640758aeabeabce3d7ce279ff Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 30 Aug 2026 17:03:29 +0000 Subject: [PATCH 2/3] feat(ui): load the Pretendard build the other Redrob products load Same CDN and same pin as before, but the full variable file rather than the dynamic subset. The subset carries the whole weight axis and splits the face into slices the browser fetches only when a page renders glyphs from them, which for an English screen is Latin and some punctuation where the full file is mostly Hangul it will never draw. Console, Office and Code all load the subset; this was the one product on the full file. The URL moves to a named constant, so the version is written in exactly one place and `--font-sans` in `globals.css` names only the family it registers. The `preconnect` matters more than usual here: the stylesheet and the slices it names are two round trips to a third party before any text is in its real face. Co-authored-by: janghoonlee --- apps/web/src/app/layout.tsx | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) 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. */} + + From 841e4142ab18349818d57df4bedefb705bfe75d7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 30 Aug 2026 17:03:43 +0000 Subject: [PATCH 3/3] test(ui): fail the build when a token drifts from Console's value Two repositories cannot import from each other, so the palettes agree only for as long as somebody checks. This file is the joint: every primitive and every semantic role Console declares is transcribed here, and each test compares Eval's copy against it. If either side changes a colour alone, it fails and names the token. Fourteen assertions, and the ones that earn their place: - every primitive at Console's value, and no primitive this app invented - no primitive re-pointed for a theme, because Blue 6 is Blue 6 in both - every role where Console points it, in light and in dark - the dark block re-points exactly the seventy-two roles Console re-points, plus the four roles Console does not have. A role missing from it would fall through to a light value on a dark page - no literal colour outside the primitive band, so a role is decided once - every local name a plain alias with no dark twin, which is the property that makes the band safe to keep - no rule and no component naming a primitive, so nothing reaches past the roles for a colour that cannot follow the theme - no hardcoded colour under `apps/web/src`, with one exception that has to prove itself: xterm paints to a canvas and cannot resolve `var()`, so `DeployTerminal` needs literals for the case where `getComputedStyle` returns nothing, and both are asserted equal to the primitives their tokens hold - Pretendard first in the stack, the stack written once, the version pinned once - the monospace stack named only by the eight code surfaces, listed, so adding a ninth is a decision somebody writes down - the reason the type scale was not taken, so a later change cannot take half of it without noticing Checked by mutation: drifting a primitive, hardcoding a colour in a component and reintroducing a dark twin each fail, and each fails in the test that should catch it rather than somewhere downstream. Co-authored-by: janghoonlee --- scripts/test/design-tokens.test.mts | 567 ++++++++++++++++++++++++++++ 1 file changed, 567 insertions(+) create mode 100644 scripts/test/design-tokens.test.mts 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)); +}