| 1 | import { readFileSync } from "node:fs"; |
| 2 | import { describe, expect, it } from "vitest"; |
| 3 | import { resolveWhale } from "./whale-tokens"; |
| 4 | |
| 5 | const CSS = readFileSync(new URL("../app/globals.css", import.meta.url), "utf8"); |
| 6 | |
| 7 | function selectorBlock(selector: string): string { |
| 8 | const match = CSS.match( |
| 9 | new RegExp(`(?:^|\n)${selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\{([^}]*)\\}`, "s"), |
| 10 | ); |
| 11 | if (!match) throw new Error(`Missing CSS selector: ${selector}`); |
| 12 | return match[1]; |
| 13 | } |
| 14 | |
| 15 | // globals.css names the palette token (`--paper: var(--gpui-paper)`) |
| 16 | // rather than repeating its hex; resolve one hop through the generated |
| 17 | // app/tokens.css plus the hand-kept --gpui-* block. |
| 18 | function cssHexIn(block: string, name: string): string { |
| 19 | const match = block.match(new RegExp(`--${name}:\\s*([^;]+);`, "i")); |
| 20 | if (!match) throw new Error(`Missing CSS token in block: --${name}`); |
| 21 | const value = resolveWhale(match[1].trim()); |
| 22 | if (!/^#[0-9a-f]{6}$/i.test(value)) { |
| 23 | throw new Error(`--${name} is not a hex color: ${value}`); |
| 24 | } |
| 25 | return value.toLowerCase(); |
| 26 | } |
| 27 | |
| 28 | const ROOT = selectorBlock(":root"); |
| 29 | const BELOW_WATERLINE = selectorBlock( |
| 30 | '.ocean-column,\n.site-footer,\nhtml[data-theme="dark"] .docs-portal', |
| 31 | ); |
| 32 | |
| 33 | describe("GPUI public-surface contract", () => { |
| 34 | it("grounds the paper sheet in the GPUI light theme's warm paper and inks", () => { |
| 35 | // Above the waterline the field is the GPUI light background — warm |
| 36 | // paper — and the ink is its plum-charcoal foreground. The literals are |
| 37 | // the Shoreline theme constants in crates/palette/src/tokens.rs, reached |
| 38 | // through the generated tokens in app/tokens.css. |
| 39 | expect(cssHexIn(ROOT, "paper")).toBe("#f5f0e9"); |
| 40 | expect(cssHexIn(ROOT, "paper-deep")).toBe("#ece5e0"); |
| 41 | expect(cssHexIn(ROOT, "paper-edge")).toBe("#d7ced5"); |
| 42 | expect(cssHexIn(ROOT, "paper-card")).toBe("#fffcf7"); |
| 43 | expect(cssHexIn(ROOT, "ink")).toBe("#302832"); |
| 44 | expect(cssHexIn(ROOT, "ink-soft")).toBe("#4a414c"); |
| 45 | expect(cssHexIn(ROOT, "ink-mute")).toBe("#6b606e"); |
| 46 | // Action on paper is the GPUI light primary; hover sinks to its hover. |
| 47 | expect(cssHexIn(ROOT, "indigo")).toBe("#006684"); |
| 48 | expect(cssHexIn(ROOT, "indigo-deep")).toBe("#00536d"); |
| 49 | expect(cssHexIn(ROOT, "mark-ink")).toBe("#302832"); |
| 50 | // The deep field is always the stage's darkest, and code plates keep the |
| 51 | // stage deep on either side of the waterline. |
| 52 | expect(cssHexIn(ROOT, "ocean-deep")).toBe("#1a181c"); |
| 53 | expect(cssHexIn(ROOT, "action-on-dark")).toBe("#67b8d6"); |
| 54 | expect(cssHexIn(ROOT, "ocean-current")).toBe("#67b8d6"); |
| 55 | expect(cssHexIn(ROOT, "code-bg")).toBe("#1a181c"); |
| 56 | }); |
| 57 | |
| 58 | it("re-inks every dark subtree with the GPUI charcoal tokens through one rule", () => { |
| 59 | // The ocean column, the footer seabed, and the opt-in docs dark sheet |
| 60 | // share one below-the-waterline rule, so a component never needs to know |
| 61 | // which side of the surface it is on. |
| 62 | expect(CSS).toMatch(/\.ocean-column,\s*\.site-footer,\s*html\[data-theme="dark"\] \.docs-portal\s*\{/); |
| 63 | expect(cssHexIn(BELOW_WATERLINE, "paper")).toBe("#211f23"); |
| 64 | expect(cssHexIn(BELOW_WATERLINE, "paper-deep")).toBe("#2b282e"); |
| 65 | expect(cssHexIn(BELOW_WATERLINE, "paper-edge")).toBe("#49424d"); |
| 66 | expect(cssHexIn(BELOW_WATERLINE, "ink")).toBe("#f2ece5"); |
| 67 | expect(cssHexIn(BELOW_WATERLINE, "ink-soft")).toBe("#b0a7b2"); |
| 68 | expect(cssHexIn(BELOW_WATERLINE, "ink-mute")).toBe("#7e7583"); |
| 69 | expect(cssHexIn(BELOW_WATERLINE, "indigo")).toBe("#67b8d6"); |
| 70 | expect(cssHexIn(BELOW_WATERLINE, "jade")).toBe("#9ec7b2"); |
| 71 | expect(cssHexIn(BELOW_WATERLINE, "signal-gold")).toBe("#d6c78f"); |
| 72 | }); |
| 73 | |
| 74 | it("draws the water from palette tokens only, never a hex of its own", () => { |
| 75 | const strata = readFileSync(new URL("../components/strata.tsx", import.meta.url), "utf8"); |
| 76 | expect(strata).not.toMatch(/#[0-9a-f]{3,8}\b/i); |
| 77 | for (const token of ["--gpui-primary-dark", "--gpui-stage-raised", "--gpui-stage-muted", "--gpui-stage-deep", "--gpui-tan", "--ink"]) { |
| 78 | expect(strata, token).toContain(`var(${token})`); |
| 79 | } |
| 80 | // Static and decorative: no animation, hidden from assistive technology. |
| 81 | expect(strata).not.toMatch(/animate|@keyframes/); |
| 82 | expect(strata).toContain('aria-hidden="true"'); |
| 83 | }); |
| 84 | |
| 85 | it("renders the whale mark in the sheet's mark ink while controls use action blue", () => { |
| 86 | expect(CSS).toMatch(/\.codewhale-mark-primary \{ fill: var\(--mark-ink\); \}/); |
| 87 | expect(CSS).toMatch(/\.portal-button-primary[\s\S]*background: var\(--indigo\)/); |
| 88 | expect(CSS).toMatch(/\.nav-link::after[\s\S]*background: var\(--indigo\)/); |
| 89 | }); |
| 90 | |
| 91 | it("keeps localized navigation controls inside compact viewports", () => { |
| 92 | const mobile = CSS.split("@media (max-width: 520px)")[1]; |
| 93 | |
| 94 | expect(mobile).toMatch(/\.site-nav-inner\s*\{\s*gap:\s*0\.5rem/); |
| 95 | expect(mobile).toMatch(/\.site-nav-actions\s*\{[\s\S]*?min-width:\s*0/); |
| 96 | expect(mobile).toMatch( |
| 97 | /\.site-nav-actions select\s*\{[\s\S]*?width:\s*6\.75rem;[\s\S]*?min-width:\s*0/, |
| 98 | ); |
| 99 | expect(CSS).toMatch(/\.paper-wordmark-mark\s*\{[^}]*height:\s*22px;/); |
| 100 | expect(CSS).toMatch(/\.paper-wordmark-logo\s*\{[^}]*height:\s*20px;/); |
| 101 | expect(CSS).toMatch(/\.site-nav-actions\s*\{[\s\S]*?flex-shrink:\s*0/); |
| 102 | expect(CSS).toMatch(/\.site-nav-actions\s*>\s*\*\s*\{\s*flex-shrink:\s*0/); |
| 103 | expect(CSS).toMatch(/@media \(max-width: 900px\)[\s\S]*?\.site-github-link\s*\{\s*display:\s*none/); |
| 104 | expect(mobile).not.toMatch(/body:has\(\.product-home\) \.site-nav-actions select/); |
| 105 | // The locale <select> and the home wordmark must keep a usable hit |
| 106 | // target on every viewport, not only below 520px. Long native option |
| 107 | // labels and 2xl companion text used to collapse the wordmark to 0. |
| 108 | expect(CSS).toMatch( |
| 109 | /\.site-nav-actions select\s*\{\s*width:\s*6\.75rem;\s*max-width:\s*6\.75rem;\s*min-width:\s*0;/, |
| 110 | ); |
| 111 | // `min-width` is the floor that keeps the wordmark clickable; the shrink |
| 112 | // factor stays at 1 so the compact controls are never the ones pushed |
| 113 | // past `overflow-x: clip` when the row is over budget. |
| 114 | expect(CSS).toMatch(/\.paper-wordmark\s*\{[^}]*flex:\s*0 1 auto;[^}]*min-width:\s*8\.75rem;/); |
| 115 | expect(CSS).not.toMatch(/\.paper-wordmark\s*\{[^}]*flex:\s*0 0 auto;/); |
| 116 | }); |
| 117 | }); |
| 118 |