| 1 | import { existsSync, readFileSync } from "node:fs"; |
| 2 | import { describe, expect, it } from "vitest"; |
| 3 | import { resolveWhale } from "./whale-tokens"; |
| 4 | import { siteCss } from "./site-css"; |
| 5 | |
| 6 | // Radii, state roles and motion follow the GPUI client (DESIGN.md, set_theme, |
| 7 | // motion.rs): one radius grammar, one focus ring, spring-fitted easing, and a |
| 8 | // still pose under reduced motion. |
| 9 | const CSS = siteCss(); |
| 10 | const TAILWIND = readFileSync(new URL("../tailwind.config.ts", import.meta.url), "utf8"); |
| 11 | |
| 12 | describe("design grammar contract", () => { |
| 13 | it("draws every radius from the 6/10/14/pill grammar", () => { |
| 14 | const defined = [...CSS.matchAll(/--radius-[\w-]+:\s*([^;]+);/g)].map((m) => resolveWhale(m[1].trim())); |
| 15 | expect(new Set(defined)).toEqual(new Set(["6px", "10px", "14px", "999px"])); |
| 16 | const used = [...CSS.matchAll(/border-radius:\s*([^;]+);/g)].map((m) => m[1].trim()); |
| 17 | expect(used.length).toBeGreaterThan(0); |
| 18 | // A corner is a grammar token or square; per-corner shorthands combine them. |
| 19 | for (const value of used) { |
| 20 | expect(value).toMatch(/^(?:(?:var\(--radius-(control|surface|sheet|pill)\)|0)\s*){1,4}$/); |
| 21 | } |
| 22 | const scale = TAILWIND.match(/borderRadius:\s*\{([\s\S]*?)\}/)?.[1] ?? ""; |
| 23 | expect(scale).not.toMatch(/\d+(px|rem)/); |
| 24 | }); |
| 25 | |
| 26 | it("has one focus ring and the set_theme selection", () => { |
| 27 | expect(CSS.match(/(?<![\w-])outline:/g)).toHaveLength(1); |
| 28 | expect(CSS).toContain(":focus-visible { outline: var(--gpui-focus-width) solid var(--ring); outline-offset: var(--gpui-focus-offset); }"); |
| 29 | expect(resolveWhale("var(--gpui-focus-width)")).toBe("2px"); |
| 30 | expect(resolveWhale("var(--gpui-focus-offset)")).toBe("3px"); |
| 31 | expect(CSS).not.toMatch(/outline:\s*none/); |
| 32 | expect(CSS).toMatch(/::selection \{ background: var\(--selection\); \}/); |
| 33 | // The light ring on the dark stage would fall under 3:1, so the stage |
| 34 | // re-declares it. |
| 35 | expect(CSS).toMatch(/\.stage,\s*\.site-footer\s*\{[^}]*--ring: var\(--gpui-dark-primary\);/); |
| 36 | }); |
| 37 | |
| 38 | it("paints every primary fill with the logo gradient and white text", () => { |
| 39 | for (const cls of ["btn-primary", "paper-install-cta"]) { |
| 40 | const rest = CSS.match(new RegExp(`\\.${cls}[^{]*\\{([^}]*)\\}`))?.[1] ?? ""; |
| 41 | const hover = CSS.match(new RegExp(`\\.${cls}:hover[^{]*\\{([^}]*)\\}`))?.[1] ?? ""; |
| 42 | expect(rest, cls).toMatch(/background: var\(--brand-fill\)/); |
| 43 | expect(rest, cls).toMatch(/color: var\(--on-brand\)/); |
| 44 | expect(hover, cls).toMatch(/background: var\(--brand-fill-hover\)/); |
| 45 | } |
| 46 | }); |
| 47 | |
| 48 | it("keeps every keyframed animation behind a motion preference", () => { |
| 49 | // Strip the blocks that only run when motion is allowed; nothing left |
| 50 | // may start an animation. |
| 51 | let rest = CSS; |
| 52 | for (;;) { |
| 53 | const at = rest.indexOf("@media (prefers-reduced-motion: no-preference)"); |
| 54 | if (at < 0) break; |
| 55 | let depth = 0; |
| 56 | let end = rest.indexOf("{", at); |
| 57 | for (let i = end; i < rest.length; i++) { |
| 58 | if (rest[i] === "{") depth++; |
| 59 | else if (rest[i] === "}" && --depth === 0) { end = i; break; } |
| 60 | } |
| 61 | rest = rest.slice(0, at) + rest.slice(end + 1); |
| 62 | } |
| 63 | const starts = [...rest.matchAll(/animation:\s*([^;]+);/g)].map((m) => m[1].trim()); |
| 64 | for (const value of starts) expect(value).toBe("none"); |
| 65 | }); |
| 66 | |
| 67 | it("times motion with the spring tokens and stills it under reduced motion", () => { |
| 68 | expect(CSS).not.toMatch(/\d+ms ease[,;]/); |
| 69 | expect(CSS).not.toMatch(/cubic-bezier\(0\.25, 0\.46/); |
| 70 | expect(CSS).toMatch(/--ease-spring: cubic-bezier\(/); |
| 71 | expect(CSS).toMatch( |
| 72 | /@media \(prefers-reduced-motion: reduce\) \{\s*:root \{\s*--dur-state: 0ms;\s*--dur-spring: 0ms;/, |
| 73 | ); |
| 74 | expect(TAILWIND).toMatch(/transitionDuration: \{ DEFAULT: "var\(--dur-state\)" \}/); |
| 75 | expect(TAILWIND).toMatch(/transitionTimingFunction: \{ DEFAULT: "var\(--ease-spring\)" \}/); |
| 76 | expect(CSS).not.toMatch(/caustic/); |
| 77 | }); |
| 78 | |
| 79 | it("keeps no unused presence shapes", () => { |
| 80 | expect(existsSync(new URL("../components/presence.tsx", import.meta.url))).toBe(false); |
| 81 | }); |
| 82 | }); |
| 83 |