diff --git a/crates/palette/src/tokens.rs b/crates/palette/src/tokens.rs index c07f55a66..758370216 100644 --- a/crates/palette/src/tokens.rs +++ b/crates/palette/src/tokens.rs @@ -222,13 +222,14 @@ pub const MATRIX_TEXT_SOFT_RGB: (u8, u8, u8) = (221, 255, 221); // #DDFFDD pub const MATRIX_TEXT_DIM_RGB: (u8, u8, u8) = (0, 108, 0); // #006C00, lifted for 3:1 pub const MATRIX_BORDER_RGB: (u8, u8, u8) = (0, 204, 0); // #00CC00 -// Shoreline — the product-client palette, shared with the GPUI desktop. +// Shoreline — the TUI's charcoal theme. // // Warm charcoal ground and warm paper sheet, one restrained blue, and the // whale's ivory ink on both sides. This is the charcoal alternative to the // terminal's navy "Underwater" default in 0.10.0. The // 0.10.0 action pair uses glacial blue on charcoal and deep ocean blue on -// paper. Shared tokens keep the terminal, desktop, and web in one system. +// paper. The GPUI desktop does not paint these values: its theme is the +// separate `GPUI_*` / `GPUI_LIGHT_*` set below. // // Every pair audited by `contrast::theme_contrast_violations` clears its // floor: body roles clear 4.5:1 on all four surfaces, hint/dim and the @@ -299,6 +300,36 @@ pub const SHORELINE_LIGHT_DIFF_ADDED_BG_RGB: (u8, u8, u8) = (226, 242, 230); // pub const SHORELINE_LIGHT_DIFF_DELETED_FG_RGB: (u8, u8, u8) = (168, 40, 80); // #A82850 pub const SHORELINE_LIGHT_DIFF_DELETED_BG_RGB: (u8, u8, u8) = (251, 230, 236); // #FBE6EC +// GPUI — the desktop client's theme, mirrored from `set_theme` in +// codewhale-app/src/workspace/mod.rs (the GPUI app has no dependency on this +// crate, so these are kept in step by hand). The website's role tokens are +// generated from this set; Shoreline above stays the TUI's theme. Each const +// names the `gpui_kit` theme field it mirrors. Derived roles are not +// duplicated here: hover is `PRIMARY` at 0.9 opacity and selection is +// `PRIMARY` at 0.28, exactly as `set_theme` derives them. +pub const GPUI_BG_RGB: (u8, u8, u8) = (32, 33, 35); // #202123 background +pub const GPUI_PANEL_RGB: (u8, u8, u8) = (42, 43, 46); // #2A2B2E muted — raised inputs, popovers +pub const GPUI_SIDEBAR_RGB: (u8, u8, u8) = (25, 26, 28); // #191A1C sidebar — recessed navigation +pub const GPUI_TEXT_RGB: (u8, u8, u8) = (239, 238, 235); // #EFEEEB foreground +pub const GPUI_TEXT_MUTED_RGB: (u8, u8, u8) = (177, 177, 173); // #B1B1AD muted_foreground +pub const GPUI_BORDER_RGB: (u8, u8, u8) = (59, 60, 63); // #3B3C3F border +pub const GPUI_PRIMARY_RGB: (u8, u8, u8) = (144, 185, 255); // #90B9FF primary — whale blue +pub const GPUI_ON_PRIMARY_RGB: (u8, u8, u8) = (21, 36, 62); // #15243E primary_foreground +pub const GPUI_ACCENT_RGB: (u8, u8, u8) = (48, 49, 52); // #303134 accent — hover +pub const GPUI_LIST_ACTIVE_RGB: (u8, u8, u8) = (55, 57, 61); // #37393D list_active — selected row + +// GPUI Light — the same `set_theme` on warm paper. +pub const GPUI_LIGHT_BG_RGB: (u8, u8, u8) = (250, 248, 245); // #FAF8F5 background +pub const GPUI_LIGHT_PANEL_RGB: (u8, u8, u8) = (255, 255, 255); // #FFFFFF muted — raised inputs, popovers +pub const GPUI_LIGHT_SIDEBAR_RGB: (u8, u8, u8) = (240, 237, 232); // #F0EDE8 sidebar +pub const GPUI_LIGHT_TEXT_RGB: (u8, u8, u8) = (40, 41, 43); // #28292B foreground +pub const GPUI_LIGHT_TEXT_MUTED_RGB: (u8, u8, u8) = (95, 96, 93); // #5F605D muted_foreground +pub const GPUI_LIGHT_BORDER_RGB: (u8, u8, u8) = (217, 213, 207); // #D9D5CF border +pub const GPUI_LIGHT_PRIMARY_RGB: (u8, u8, u8) = (36, 91, 199); // #245BC7 primary +pub const GPUI_LIGHT_ON_PRIMARY_RGB: (u8, u8, u8) = (251, 245, 238); // #FBF5EE primary_foreground +pub const GPUI_LIGHT_ACCENT_RGB: (u8, u8, u8) = (232, 229, 224); // #E8E5E0 accent — hover +pub const GPUI_LIGHT_LIST_ACTIVE_RGB: (u8, u8, u8) = (223, 220, 214); // #DFDCD6 list_active + // Semantic colors pub const BORDER_COLOR_RGB: (u8, u8, u8) = WHALE_BORDER_RGB; diff --git a/scripts/export-design-tokens.py b/scripts/export-design-tokens.py index 3db71c840..b9c43dbe7 100755 --- a/scripts/export-design-tokens.py +++ b/scripts/export-design-tokens.py @@ -2,11 +2,13 @@ """Export the Codewhale palettes to the other Codewhale clients. `crates/palette/src/tokens.rs` is the single source for the product colors. -This script parses its `WHALE_*_RGB`, `LIGHT_*_RGB`, `SHORELINE_*_RGB`, and -`SHORELINE_LIGHT_*_RGB` consts (aliases included) and writes the same values -as CSS custom properties so the web app stops hand-copying hexes. The -Shoreline set is the Shoreline redesign's dark + light pair; `--whale-*` and -`--light-*` stay until the components that use them migrate. +This script parses its `WHALE_*_RGB`, `LIGHT_*_RGB`, `SHORELINE_*_RGB`, +`SHORELINE_LIGHT_*_RGB`, `GPUI_*_RGB`, and `GPUI_LIGHT_*_RGB` consts (aliases +included) and writes the same values as CSS custom properties so the web app +stops hand-copying hexes. The GPUI pair mirrors the desktop client's +`set_theme` and backs the website's role tokens; Shoreline is the TUI's +charcoal theme; `--whale-*` and `--light-*` stay until the components that +use them migrate. Target: /web/app/tokens.css. This script writes nothing outside this repository. @@ -28,8 +30,8 @@ TOKENS_RS = REPO / "crates/palette/src/tokens.rs" SOURCE_LABEL = "crates/palette/src/tokens.rs" CONST_RE = re.compile( - r"^pub const ((?:SHORELINE_LIGHT|SHORELINE|WHALE|LIGHT)_[A-Z0-9_]+)_RGB: \(u8, u8, u8\) = " - r"(?:\((\d+), (\d+), (\d+)\)|((?:SHORELINE_LIGHT|SHORELINE|WHALE|LIGHT)_[A-Z0-9_]+)_RGB);", + r"^pub const ((?:GPUI_LIGHT|GPUI|SHORELINE_LIGHT|SHORELINE|WHALE|LIGHT)_[A-Z0-9_]+)_RGB: \(u8, u8, u8\) = " + r"(?:\((\d+), (\d+), (\d+)\)|((?:GPUI_LIGHT|GPUI|SHORELINE_LIGHT|SHORELINE|WHALE|LIGHT)_[A-Z0-9_]+)_RGB);", re.MULTILINE, ) @@ -59,8 +61,14 @@ def css_name(name: str) -> str: `LIGHT_*` consts export as `--light-*` so the website's paper surface can reference the same light-mode ink and border values the TUI ships. The Shoreline dark pair exports as `--shoreline-*` and its light pair as - `--shoreline-light-*`, matching the theme the TUI and GPUI clients open - on.""" + `--shoreline-light-*`, the TUI's charcoal theme. The GPUI desktop's + `set_theme` pair exports as `--gpui-dark-*` and `--gpui-light-*`; the + `dark` infix keeps the generated names clear of the hand-kept `--gpui-*` + aliases in web/app/styles/tokens-roles.css.""" + if name.startswith("GPUI_LIGHT_"): + return "--gpui-light-" + name.removeprefix("GPUI_LIGHT_").lower().replace("_", "-") + if name.startswith("GPUI_"): + return "--gpui-dark-" + name.removeprefix("GPUI_").lower().replace("_", "-") if name.startswith("SHORELINE_LIGHT_"): return "--shoreline-light-" + name.removeprefix("SHORELINE_LIGHT_").lower().replace( "_", "-" diff --git a/web/app/styles/tokens-roles.css b/web/app/styles/tokens-roles.css index 2b360fc87..23f0a77d4 100644 --- a/web/app/styles/tokens-roles.css +++ b/web/app/styles/tokens-roles.css @@ -1,52 +1,48 @@ -/* ---------- root tokens — GPUI theme ---------- */ +/* ---------- site tokens — the folio ---------- */ /* - * The site is a folio read in the product client's light: warm paper at the - * surface, warm charcoal at depth. Above the waterline the field is the GPUI - * light theme's paper and the ink is its plum-charcoal foreground. Below the - * waterline (`.ocean-column`, the footer, every terminal plate) the same - * names resolve to the dark charcoal tokens, so a component written once - * reads correctly on either side. + * The site is still a folio read in the product client's light: warm paper + * at the surface, warm charcoal at depth. Above the waterline the field is + * the GPUI light theme; below it (`.ocean-column`, the footer, every + * terminal plate) the same names resolve to the GPUI dark theme, so a + * component written once reads correctly on either side. (The site-wide + * theme contract that moves these onto the role layer above is a separate + * slice.) * * The token NAMES keep their historical shape (`--paper`, `--ink`, * `--indigo`) so every component rule stays diffable; the VALUES resolve - * through the `--gpui-*` block below, which now references the generated - * Shoreline tokens in ./tokens.css (crates/palette/src/tokens.rs is the - * single source — the same constants the GPUI client and the TUI open on). - * Only values with no Shoreline slot stay literal: the hover tones, the - * accent washes, and the AA-adapted state hues. `--whale-*`/`--light-*` - * remain available to anything that genuinely wants the terminal palette. + * through the `--gpui-*` aliases below, which reference the generated GPUI + * tokens. Only the warm state hues (tan, moss, rust and their AA-adapted + * inks) stay literal: `set_theme` has no slot for them. */ :root { - /* --- The GPUI source palette (generated Shoreline slots) --- */ - --gpui-paper: var(--shoreline-light-surface); /* light background — warm paper */ - --gpui-paper-raised: var(--shoreline-light-elevated); /* light card/popover */ - --gpui-paper-muted: var(--shoreline-light-panel); /* light muted surface */ - --gpui-ink: var(--shoreline-light-text-body); /* light foreground */ - --gpui-ink-soft: var(--shoreline-light-text-soft); - --gpui-ink-mute: var(--shoreline-light-text-muted); /* light muted_foreground */ - --gpui-edge: var(--shoreline-light-border); /* light border */ - --gpui-primary: var(--shoreline-light-action); /* light primary */ - --gpui-primary-hover: #00536d; /* light button_primary_hover — no Shoreline slot */ - --gpui-accent: #dcebee; /* light accent — no Shoreline slot */ - --gpui-selection: var(--shoreline-light-selection); /* light selection */ + /* --- GPUI light (above the waterline) --- */ + --gpui-paper: var(--gpui-light-bg); /* background — warm paper */ + --gpui-paper-raised: var(--gpui-light-panel); /* muted — card/popover */ + --gpui-paper-muted: var(--gpui-light-sidebar); /* sidebar — recessed */ + --gpui-ink: var(--gpui-light-text); /* foreground */ + --gpui-ink-soft: var(--gpui-light-text-muted); /* muted_foreground */ + --gpui-ink-mute: var(--gpui-light-text-muted); /* muted_foreground */ + --gpui-edge: var(--gpui-light-border); /* border */ + --gpui-primary: var(--gpui-light-primary); /* primary */ + --gpui-primary-hover: rgb(var(--gpui-light-primary-rgb) / 0.9); /* button_primary_hover */ - --gpui-stage: var(--shoreline-surface); /* dark background — warm charcoal */ - --gpui-stage-deep: var(--shoreline-chrome); /* deepest dark */ - --gpui-stage-muted: var(--shoreline-panel); /* dark muted surface */ - --gpui-stage-raised: var(--shoreline-elevated); /* raised dark plate */ - --gpui-stage-ink: var(--shoreline-text-body); /* dark foreground */ - --gpui-stage-ink-soft: var(--shoreline-text-muted); /* dark muted_foreground */ - --gpui-stage-ink-dim: var(--shoreline-text-dim); - --gpui-stage-edge: var(--shoreline-border); /* dark border */ - --gpui-primary-dark: var(--shoreline-action); /* dark primary */ - --gpui-primary-hover-dark: #93cde1; /* dark button_primary_hover — no Shoreline slot */ - --gpui-on-primary: #102d38; /* dark primary_foreground — no Shoreline slot */ - --gpui-accent-dark: #303f48; /* dark accent — no Shoreline slot */ - --gpui-selection-dark: var(--shoreline-selection); /* dark selection */ + /* --- GPUI dark (below the waterline) --- */ + --gpui-stage: var(--gpui-dark-bg); /* background — warm charcoal */ + --gpui-stage-deep: var(--gpui-dark-sidebar); /* sidebar — deepest dark */ + --gpui-stage-muted: var(--gpui-dark-panel); /* muted — raised input plate */ + --gpui-stage-raised: var(--gpui-dark-list-active); /* list_active — top plate */ + --gpui-stage-ink: var(--gpui-dark-text); /* foreground */ + --gpui-stage-ink-soft: var(--gpui-dark-text-muted); /* muted_foreground */ + --gpui-stage-ink-dim: var(--gpui-dark-text-muted); /* muted_foreground (AA, UX-05) */ + --gpui-stage-edge: var(--gpui-dark-border); /* border */ + --gpui-primary-dark: var(--gpui-dark-primary); /* primary */ + --gpui-primary-hover-dark: rgb(var(--gpui-dark-primary-rgb) / 0.9); /* button_primary_hover */ + --gpui-accent-dark: var(--gpui-dark-accent); /* accent — hover wash */ + --gpui-selection-dark: rgb(var(--gpui-dark-primary-rgb) / 0.28); /* selection */ /* Warm-muted state hues from the GPUI mockups, darkened or lifted per - side of the waterline so text uses of them still clear AA. No Shoreline - slots: these are site-specific adaptations. */ + side of the waterline so text uses of them still clear AA. No + set_theme slots: these are site-specific adaptations. */ --gpui-tan: #b1a17a; /* human mark */ --gpui-moss: #739686; /* outcome */ --gpui-rust: #aa6f6c; /* attention */ @@ -54,17 +50,16 @@ /* RGB channel triples backing the Tailwind surface/ink tokens (see tailwind.config.ts). `.ocean-column` and the opt-in docs dark sheet override them for their dark subtree. */ - --c-paper: var(--shoreline-light-surface-rgb); - --c-paper-deep: var(--shoreline-light-panel-rgb); - --c-paper-edge: var(--shoreline-light-border-rgb); - --c-ink: var(--shoreline-light-text-body-rgb); - --c-ink-soft: var(--shoreline-light-text-soft-rgb); - --c-ink-mute: var(--shoreline-light-text-muted-rgb); - --c-indigo: var(--shoreline-light-action-rgb); - --c-indigo-deep: 0 83 109; /* light primary hover — no Shoreline slot */ - --c-stage-soft: var(--shoreline-text-muted-rgb); - --c-stage-deep: var(--shoreline-chrome-rgb); - --c-primary-dark: var(--shoreline-action-rgb); + --c-paper: var(--gpui-light-bg-rgb); + --c-paper-deep: var(--gpui-light-sidebar-rgb); + --c-paper-edge: var(--gpui-light-border-rgb); + --c-ink: var(--gpui-light-text-rgb); + --c-ink-soft: var(--gpui-light-text-muted-rgb); + --c-ink-mute: var(--gpui-light-text-muted-rgb); + --c-indigo: var(--gpui-light-primary-rgb); + --c-stage-soft: var(--gpui-dark-text-muted-rgb); + --c-stage-deep: var(--gpui-dark-sidebar-rgb); + --c-primary-dark: var(--gpui-dark-primary-rgb); /* The sheet. */ --paper: var(--gpui-paper); @@ -145,14 +140,13 @@ .ocean-column, .site-footer, html[data-theme="dark"] .docs-portal { - --c-paper: var(--shoreline-surface-rgb); - --c-paper-deep: var(--shoreline-panel-rgb); - --c-paper-edge: var(--shoreline-border-rgb); - --c-ink: var(--shoreline-text-body-rgb); - --c-ink-soft: var(--shoreline-text-muted-rgb); - --c-ink-mute: var(--shoreline-text-dim-rgb); - --c-indigo: var(--shoreline-action-rgb); - --c-indigo-deep: 147 205 225; /* lifted primary — hover on dark, no Shoreline slot */ + --c-paper: var(--gpui-dark-bg-rgb); + --c-paper-deep: var(--gpui-dark-panel-rgb); + --c-paper-edge: var(--gpui-dark-border-rgb); + --c-ink: var(--gpui-dark-text-rgb); + --c-ink-soft: var(--gpui-dark-text-muted-rgb); + --c-ink-mute: var(--gpui-dark-text-muted-rgb); + --c-indigo: var(--gpui-dark-primary-rgb); --paper: var(--gpui-stage); --paper-deep: var(--gpui-stage-muted); --paper-edge: var(--gpui-stage-edge); @@ -174,3 +168,80 @@ html[data-theme="dark"] .docs-portal { --hairline: rgb(var(--c-stage-soft) / 0.2); color-scheme: dark; } + +/* ---------- role tokens — GPUI set_theme ---------- */ +/* + * One token authority: the GPUI desktop client's `set_theme` + * (codewhale-app/src/workspace/mod.rs), mirrored as the `GPUI_*` / + * `GPUI_LIGHT_*` consts in crates/palette/src/tokens.rs and generated into + * ./tokens.css as `--gpui-dark-*` / `--gpui-light-*`. Nothing below repeats a + * hex for a role: every role value is a generated token, or the primary at + * the opacity `set_theme` derives (hover 0.9, selection 0.28). + * + * The role layer follows the OS: light by default, dark under + * `prefers-color-scheme: dark` unless the page pins `data-theme="light"`, + * and dark whenever it pins `data-theme="dark"`. + * + * --bg background the reading canvas + * --surface sidebar recessed navigation and chrome + * --panel muted raised inputs, cards, popovers + * --text foreground + * --muted muted_foreground secondary text (AA on --bg and --panel) + * --line border + * --accent primary action, links, whale blue + * --on-accent primary_foreground + * --hover accent hover wash, distinct from --panel + * --selected list_active the current row + * --selection primary @ 0.28 text selection + * --ring primary focus ring + * + * Shoreline (`--shoreline-*`) is the TUI's charcoal theme and is not the + * GPUI palette; `--whale-*`/`--light-*` remain available to anything that + * genuinely wants the terminal palette. + */ +:root { + --bg: var(--gpui-light-bg); + --surface: var(--gpui-light-sidebar); + --panel: var(--gpui-light-panel); + --text: var(--gpui-light-text); + --muted: var(--gpui-light-text-muted); + --line: var(--gpui-light-border); + --accent: var(--gpui-light-primary); + --on-accent: var(--gpui-light-on-primary); + --hover: var(--gpui-light-accent); + --selected: var(--gpui-light-list-active); + --selection: rgb(var(--gpui-light-primary-rgb) / 0.28); + --ring: var(--gpui-light-primary); +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --bg: var(--gpui-dark-bg); + --surface: var(--gpui-dark-sidebar); + --panel: var(--gpui-dark-panel); + --text: var(--gpui-dark-text); + --muted: var(--gpui-dark-text-muted); + --line: var(--gpui-dark-border); + --accent: var(--gpui-dark-primary); + --on-accent: var(--gpui-dark-on-primary); + --hover: var(--gpui-dark-accent); + --selected: var(--gpui-dark-list-active); + --selection: rgb(var(--gpui-dark-primary-rgb) / 0.28); + --ring: var(--gpui-dark-primary); + } +} + +:root[data-theme="dark"] { + --bg: var(--gpui-dark-bg); + --surface: var(--gpui-dark-sidebar); + --panel: var(--gpui-dark-panel); + --text: var(--gpui-dark-text); + --muted: var(--gpui-dark-text-muted); + --line: var(--gpui-dark-border); + --accent: var(--gpui-dark-primary); + --on-accent: var(--gpui-dark-on-primary); + --hover: var(--gpui-dark-accent); + --selected: var(--gpui-dark-list-active); + --selection: rgb(var(--gpui-dark-primary-rgb) / 0.28); + --ring: var(--gpui-dark-primary); +} diff --git a/web/app/tokens.css b/web/app/tokens.css index 007c76340..8fbe8e262 100644 --- a/web/app/tokens.css +++ b/web/app/tokens.css @@ -245,4 +245,44 @@ --shoreline-light-diff-deleted-fg-rgb: 168 40 80; --shoreline-light-diff-deleted-bg: #fbe6ec; --shoreline-light-diff-deleted-bg-rgb: 251 230 236; + --gpui-dark-bg: #202123; + --gpui-dark-bg-rgb: 32 33 35; + --gpui-dark-panel: #2a2b2e; + --gpui-dark-panel-rgb: 42 43 46; + --gpui-dark-sidebar: #191a1c; + --gpui-dark-sidebar-rgb: 25 26 28; + --gpui-dark-text: #efeeeb; + --gpui-dark-text-rgb: 239 238 235; + --gpui-dark-text-muted: #b1b1ad; + --gpui-dark-text-muted-rgb: 177 177 173; + --gpui-dark-border: #3b3c3f; + --gpui-dark-border-rgb: 59 60 63; + --gpui-dark-primary: #90b9ff; + --gpui-dark-primary-rgb: 144 185 255; + --gpui-dark-on-primary: #15243e; + --gpui-dark-on-primary-rgb: 21 36 62; + --gpui-dark-accent: #303134; + --gpui-dark-accent-rgb: 48 49 52; + --gpui-dark-list-active: #37393d; + --gpui-dark-list-active-rgb: 55 57 61; + --gpui-light-bg: #faf8f5; + --gpui-light-bg-rgb: 250 248 245; + --gpui-light-panel: #ffffff; + --gpui-light-panel-rgb: 255 255 255; + --gpui-light-sidebar: #f0ede8; + --gpui-light-sidebar-rgb: 240 237 232; + --gpui-light-text: #28292b; + --gpui-light-text-rgb: 40 41 43; + --gpui-light-text-muted: #5f605d; + --gpui-light-text-muted-rgb: 95 96 93; + --gpui-light-border: #d9d5cf; + --gpui-light-border-rgb: 217 213 207; + --gpui-light-primary: #245bc7; + --gpui-light-primary-rgb: 36 91 199; + --gpui-light-on-primary: #fbf5ee; + --gpui-light-on-primary-rgb: 251 245 238; + --gpui-light-accent: #e8e5e0; + --gpui-light-accent-rgb: 232 229 224; + --gpui-light-list-active: #dfdcd6; + --gpui-light-list-active-rgb: 223 220 214; } diff --git a/web/lib/blue-stage-contract.test.ts b/web/lib/blue-stage-contract.test.ts index f0afe5c72..b4721bf2f 100644 --- a/web/lib/blue-stage-contract.test.ts +++ b/web/lib/blue-stage-contract.test.ts @@ -34,26 +34,28 @@ const BELOW_WATERLINE = selectorBlock( describe("GPUI public-surface contract", () => { it("grounds the paper sheet in the GPUI light theme's warm paper and inks", () => { // Above the waterline the field is the GPUI light background — warm - // paper — and the ink is its plum-charcoal foreground. The literals are - // the Shoreline theme constants in crates/palette/src/tokens.rs, reached - // through the generated tokens in app/tokens.css. - expect(cssHexIn(ROOT, "paper")).toBe("#f5f0e9"); - expect(cssHexIn(ROOT, "paper-deep")).toBe("#ece5e0"); - expect(cssHexIn(ROOT, "paper-edge")).toBe("#d7ced5"); - expect(cssHexIn(ROOT, "paper-card")).toBe("#fffcf7"); - expect(cssHexIn(ROOT, "ink")).toBe("#302832"); - expect(cssHexIn(ROOT, "ink-soft")).toBe("#4a414c"); - expect(cssHexIn(ROOT, "ink-mute")).toBe("#6b606e"); - // Action on paper is the GPUI light primary; hover sinks to its hover. - expect(cssHexIn(ROOT, "indigo")).toBe("#006684"); - expect(cssHexIn(ROOT, "indigo-deep")).toBe("#00536d"); - expect(cssHexIn(ROOT, "mark-ink")).toBe("#302832"); + // paper — and the ink is its foreground. The literals are the GPUI_LIGHT_* + // consts in crates/palette/src/tokens.rs (mirrored from set_theme), + // reached through the generated tokens in app/tokens.css. + expect(cssHexIn(ROOT, "paper")).toBe("#faf8f5"); + expect(cssHexIn(ROOT, "paper-deep")).toBe("#f0ede8"); + expect(cssHexIn(ROOT, "paper-edge")).toBe("#d9d5cf"); + expect(cssHexIn(ROOT, "paper-card")).toBe("#ffffff"); + expect(cssHexIn(ROOT, "ink")).toBe("#28292b"); + expect(cssHexIn(ROOT, "ink-soft")).toBe("#5f605d"); + expect(cssHexIn(ROOT, "ink-mute")).toBe("#5f605d"); + // Action on paper is the GPUI light primary; hover is the same hue at + // 0.9 opacity (`button_primary_hover`), not a second blue. + expect(cssHexIn(ROOT, "indigo")).toBe("#245bc7"); + expect(resolveWhale("var(--gpui-primary-hover)")).toBe("rgb(var(--gpui-light-primary-rgb) / 0.9)"); + expect(ROOT).toMatch(/--indigo-deep:\s*var\(--gpui-primary-hover\);/); + expect(cssHexIn(ROOT, "mark-ink")).toBe("#28292b"); // The deep field is always the stage's darkest, and code plates keep the // stage deep on either side of the waterline. - expect(cssHexIn(ROOT, "ocean-deep")).toBe("#1a181c"); - expect(cssHexIn(ROOT, "action-on-dark")).toBe("#67b8d6"); - expect(cssHexIn(ROOT, "ocean-current")).toBe("#67b8d6"); - expect(cssHexIn(ROOT, "code-bg")).toBe("#1a181c"); + expect(cssHexIn(ROOT, "ocean-deep")).toBe("#191a1c"); + expect(cssHexIn(ROOT, "action-on-dark")).toBe("#90b9ff"); + expect(cssHexIn(ROOT, "ocean-current")).toBe("#90b9ff"); + expect(cssHexIn(ROOT, "code-bg")).toBe("#191a1c"); }); it("re-inks every dark subtree with the GPUI charcoal tokens through one rule", () => { @@ -61,13 +63,13 @@ describe("GPUI public-surface contract", () => { // share one below-the-waterline rule, so a component never needs to know // which side of the surface it is on. expect(CSS).toMatch(/\.ocean-column,\s*\.site-footer,\s*html\[data-theme="dark"\] \.docs-portal\s*\{/); - expect(cssHexIn(BELOW_WATERLINE, "paper")).toBe("#211f23"); - expect(cssHexIn(BELOW_WATERLINE, "paper-deep")).toBe("#2b282e"); - expect(cssHexIn(BELOW_WATERLINE, "paper-edge")).toBe("#49424d"); - expect(cssHexIn(BELOW_WATERLINE, "ink")).toBe("#f2ece5"); - expect(cssHexIn(BELOW_WATERLINE, "ink-soft")).toBe("#b0a7b2"); - expect(cssHexIn(BELOW_WATERLINE, "ink-mute")).toBe("#7e7583"); - expect(cssHexIn(BELOW_WATERLINE, "indigo")).toBe("#67b8d6"); + expect(cssHexIn(BELOW_WATERLINE, "paper")).toBe("#202123"); + expect(cssHexIn(BELOW_WATERLINE, "paper-deep")).toBe("#2a2b2e"); + expect(cssHexIn(BELOW_WATERLINE, "paper-edge")).toBe("#3b3c3f"); + expect(cssHexIn(BELOW_WATERLINE, "ink")).toBe("#efeeeb"); + expect(cssHexIn(BELOW_WATERLINE, "ink-soft")).toBe("#b1b1ad"); + expect(cssHexIn(BELOW_WATERLINE, "ink-mute")).toBe("#b1b1ad"); + expect(cssHexIn(BELOW_WATERLINE, "indigo")).toBe("#90b9ff"); expect(cssHexIn(BELOW_WATERLINE, "jade")).toBe("#9ec7b2"); expect(cssHexIn(BELOW_WATERLINE, "signal-gold")).toBe("#d6c78f"); }); diff --git a/web/lib/gpui-role-tokens.test.ts b/web/lib/gpui-role-tokens.test.ts new file mode 100644 index 000000000..4e452be05 --- /dev/null +++ b/web/lib/gpui-role-tokens.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import { resolveWhale } from "./whale-tokens"; +import { siteCss } from "./site-css"; + +const CSS = siteCss(); +const ROLES = ["bg", "surface", "panel", "text", "muted", "line", "accent", "on-accent", "hover", "selected", "selection", "ring"]; + +function declarations(block: string): Record { + const vars: Record = {}; + for (const match of block.matchAll(/--([\w-]+):\s*([^;]+);/g)) vars[match[1]] = match[2].trim(); + return vars; +} + +/** The first block for `selector` (at any indent) that declares `--bg`. */ +function roleBlock(selector: string): Record { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + for (const match of CSS.matchAll(new RegExp(`(?:^|\\n)\\s*${escaped}\\s*\\{([^}]*)\\}`, "g"))) { + const vars = declarations(match[1]); + if ("bg" in vars) return vars; + } + throw new Error(`No role block for ${selector}`); +} + +function hex(value: string): string { + const resolved = resolveWhale(value); + if (!/^#[0-9a-f]{6}$/i.test(resolved)) throw new Error(`not a hex color: ${value} -> ${resolved}`); + return resolved.toLowerCase(); +} + +function luminance(color: string): number { + const [r, g, b] = color + .slice(1) + .match(/.{2}/g)! + .map((v) => Number.parseInt(v, 16) / 255) + .map((v) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4)); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +function contrast(a: string, b: string): number { + const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x); + return (hi + 0.05) / (lo + 0.05); +} + +const light = () => roleBlock(":root"); +const osDark = () => roleBlock(':root:not([data-theme="light"])'); +const pinnedDark = () => roleBlock(':root[data-theme="dark"]'); + +describe("GPUI role tokens", () => { + it("defines every role in light, OS-dark, and pinned-dark schemes", () => { + for (const scheme of [light(), osDark(), pinnedDark()]) { + for (const role of ROLES) expect(scheme, role).toHaveProperty(role); + } + // The OS-dark scheme is guarded so a pinned light page stays light, and + // the pinned dark block repeats it exactly. + expect(CSS).toMatch(/@media \(prefers-color-scheme: dark\)\s*\{\s*:root:not\(\[data-theme="light"\]\)\s*\{/); + expect(pinnedDark()).toEqual(osDark()); + }); + + it("uses generated GPUI tokens only, never a literal hex, in role positions", () => { + for (const scheme of [light(), osDark()]) { + for (const role of ROLES) { + expect(scheme[role], role).not.toMatch(/#[0-9a-f]{3,8}\b/i); + expect(scheme[role], role).toMatch(/var\(--gpui-(light|dark)-[\w-]+\)/); + } + } + }); + + it("paints the GPUI set_theme values", () => { + expect(hex(light().bg)).toBe("#faf8f5"); + expect(hex(light().accent)).toBe("#245bc7"); + expect(hex(light().hover)).toBe("#e8e5e0"); + expect(hex(light().selected)).toBe("#dfdcd6"); + expect(hex(osDark().bg)).toBe("#202123"); + expect(hex(osDark().accent)).toBe("#90b9ff"); + expect(hex(osDark().hover)).toBe("#303134"); + expect(hex(osDark().selected)).toBe("#37393d"); + expect(light().selection).toBe("rgb(var(--gpui-light-primary-rgb) / 0.28)"); + expect(osDark().selection).toBe("rgb(var(--gpui-dark-primary-rgb) / 0.28)"); + }); + + it("keeps text, muted text, links, and button text at WCAG AA in both schemes", () => { + for (const scheme of [light(), osDark()]) { + const bg = hex(scheme.bg); + expect(contrast(hex(scheme.muted), bg)).toBeGreaterThanOrEqual(4.5); + expect(contrast(hex(scheme.muted), hex(scheme.panel))).toBeGreaterThanOrEqual(4.5); + expect(contrast(hex(scheme.text), bg)).toBeGreaterThanOrEqual(4.5); + expect(contrast(hex(scheme.accent), bg)).toBeGreaterThanOrEqual(4.5); + expect(contrast(hex(scheme["on-accent"]), hex(scheme.accent))).toBeGreaterThanOrEqual(4.5); + } + }); +}); diff --git a/web/lib/whale-tokens.ts b/web/lib/whale-tokens.ts index e35a87aa0..b6785a79d 100644 --- a/web/lib/whale-tokens.ts +++ b/web/lib/whale-tokens.ts @@ -5,13 +5,14 @@ import { siteCss } from "./site-css"; * The design palette as the site actually resolves it. * * `app/tokens.css` is generated from `crates/palette/src/tokens.rs` by - * `scripts/export-design-tokens.py`, and `app/styles/tokens-roles.css` carries the hand-kept - * `--gpui-*` block that mirrors the GPUI client's theme. Site variables state + * `scripts/export-design-tokens.py` (including the GPUI desktop's `set_theme` + * pair as `--gpui-dark-*` / `--gpui-light-*`), and `app/styles/tokens-roles.css` + * carries the hand-kept `--gpui-*` aliases over them. Site variables state * which token each uses (`--paper: var(--gpui-paper)`) instead of repeating * the hex. The contract tests still need the literal color to check parity * and contrast, so this reads both files and flattens the alias chains * (`--whale-success` -> `--whale-working-green` -> `#9bd66f`, - * `--paper` -> `--gpui-paper` -> `#f5f0e9`). The Blue Stage light preset's + * `--paper` -> `--gpui-paper` -> `--gpui-light-bg` -> `#faf8f5`). The Blue Stage light preset's * `LIGHT_*` consts export as `--light-*` beside them, and the Shoreline * redesign's dark/light pair exports as `--shoreline-*` / * `--shoreline-light-*`. @@ -22,7 +23,7 @@ const RAW: Record = (() => { const generated = readFileSync(new URL("../app/tokens.css", import.meta.url), "utf8"); const globals = siteCss(); const raw: Record = {}; - for (const match of generated.matchAll(/--((?:whale|light|shoreline-light|shoreline)-[\w-]+):\s*([^;]+);/g)) { + for (const match of generated.matchAll(/--((?:whale|light|shoreline-light|shoreline|gpui-dark|gpui-light)-[\w-]+):\s*([^;]+);/g)) { raw[match[1]] = match[2].trim(); } for (const match of globals.matchAll(/--(gpui-[\w-]+):\s*([^;]+);/g)) { diff --git a/web/tailwind.config.ts b/web/tailwind.config.ts index f45f8083b..07edcf4e4 100644 --- a/web/tailwind.config.ts +++ b/web/tailwind.config.ts @@ -6,8 +6,9 @@ export default { extend: { colors: { // The surface, ink, and accent tokens all resolve through CSS custom - // properties so the docs light sheet can re-theme the subtree, while - // the default values stay the Tideline dark whale palette. + // properties (app/styles/tokens-roles.css) so the dark subtrees can + // re-theme themselves; the values are the generated GPUI set_theme + // tokens. Hover is the primary at 0.9 opacity, as in set_theme. paper: "rgb(var(--c-paper) / )", "paper-deep": "rgb(var(--c-paper-deep) / )", "paper-edge": "rgb(var(--c-paper-edge) / )", @@ -18,7 +19,7 @@ export default { "ink-soft": "rgb(var(--c-ink-soft) / )", "ink-mute": "rgb(var(--c-ink-mute) / )", indigo: "rgb(var(--c-indigo) / )", - "indigo-deep": "rgb(var(--c-indigo-deep) / )", + "indigo-deep": "var(--indigo-deep)", "indigo-pale": "var(--indigo-pale)", ochre: "var(--ochre)", jade: "var(--jade)",