feat(web): GPUI role tokens from set_theme via the palette pipeline

Add a separate GPUI_* / GPUI_LIGHT_* const set to crates/palette
(mirrored from codewhale-app set_theme; Shoreline and the TUI are
unchanged, per founder decision D2 = separate set), export it as
--gpui-dark-* / --gpui-light-* through scripts/export-design-tokens.py,
and regenerate web/app/tokens.css.

web/app/styles/tokens-roles.css:
- New role layer (--bg --surface --panel --text --muted --line --accent
  --on-accent --hover --selected --selection --ring) that follows the
  OS: dark under prefers-color-scheme guarded by
  :root:not([data-theme="light"]), repeated for [data-theme="dark"].
  Not yet consumed; S2 moves the site onto it.
- The folio's --gpui-* aliases and --c-* triples now resolve to the
  generated GPUI values instead of Shoreline, with no literal hexes in
  role positions: hover is primary @ 0.9 and selection primary @ 0.28,
  as set_theme derives them. Dark ink-mute moves from #7e7583 (3.70:1)
  to muted_foreground #b1b1ad (UX-05).
- Removed the false "same constants the GPUI client..." comments
  (tokens-roles.css, tokens.rs Shoreline header).
tailwind indigo-deep now reads --indigo-deep (the dropped literal
--c-indigo-deep hover triple).

Checks (web/):
- npm run check:tokens: OK (142 tokens)
- npm run lint: 0 errors, 2 pre-existing warnings
- npx tsc --noEmit: pass
- npx vitest run lib/gpui-role-tokens lib/blue-stage-contract
  lib/docs-theme-contract: 3 files, 11/11 passed
- npm run build: pass
- cargo test -p codewhale-palette: 81 passed, 0 failed

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-09-22 11:42:18 -07:00
1 parent 28754c4c2b
commit 3cd160e9b1
8 files changed
+347 -102

No files matched your search

+33 -2
View File
@@ -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;
+17 -9
View File
@@ -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: <repo>/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(
"_", "-"
+130 -59
View File
@@ -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);
}
+40
View File
@@ -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;
}
+27 -25
View File
@@ -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");
});
+91
View File
@@ -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<string, string> {
const vars: Record<string, string> = {};
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<string, string> {
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);
}
});
});
+5 -4
View File
@@ -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<string, string> = (() => {
const generated = readFileSync(new URL("../app/tokens.css", import.meta.url), "utf8");
const globals = siteCss();
const raw: Record<string, string> = {};
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)) {
+4 -3
View File
@@ -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) / <alpha-value>)",
"paper-deep": "rgb(var(--c-paper-deep) / <alpha-value>)",
"paper-edge": "rgb(var(--c-paper-edge) / <alpha-value>)",
@@ -18,7 +19,7 @@ export default {
"ink-soft": "rgb(var(--c-ink-soft) / <alpha-value>)",
"ink-mute": "rgb(var(--c-ink-mute) / <alpha-value>)",
indigo: "rgb(var(--c-indigo) / <alpha-value>)",
"indigo-deep": "rgb(var(--c-indigo-deep) / <alpha-value>)",
"indigo-deep": "var(--indigo-deep)",
"indigo-pale": "var(--indigo-pale)",
ochre: "var(--ochre)",
jade: "var(--jade)",