返回 CodeWhale
design-grammar-contract.test.ts
根目录 / web / lib / design-grammar-contract.test.ts
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
83 lines TYPESCRIPT