e8d201dd9a
Second batch from the staging UX audit (BUG-248..253). - css: expose the 32 palette tokens through @theme. Seven utilities including text-ink, text-danger and text-warning compiled to no CSS at all, so 30 call sites had been silently inert (BUG-248) - chat: move the composer into its own component behind a draft store, so a keystroke no longer re-renders a 2723-line component, and persist the draft across reloads (BUG-249) - chat: load gsap, react-markdown and thinking-orbs on demand. First Load JS for / drops 549.5 kB to 476.3 kB gzipped (BUG-250) - chat: route the five in-app destinations through router.push, and keep the five auth redirects and the bootstrap retry as hard loads on purpose (BUG-251) - a11y: announce reply completion, and move the live region out of the aria-busy subtree that was likely suppressing even the start announcement (BUG-252) - docs: give the 27 collided bug ids unique numbers and repair their inbound references; require search rather than a full read of a 3690-line file (BUG-253) Verified: tsc, eslint, next build, and 1592 assertions across the 199 non-database test files. Co-authored-by: Cursor <cursoragent@cursor.com>
133 lines
5.7 KiB
TypeScript
133 lines
5.7 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { readFileSync, readdirSync } from "node:fs";
|
|
import { extname, join } from "node:path";
|
|
import test from "node:test";
|
|
|
|
const root = new URL("../", import.meta.url);
|
|
const css = readFileSync(new URL("src/app/globals.css", root), "utf8");
|
|
|
|
/**
|
|
* Tailwind v4 builds its utility namespace only from `@theme`. A `:root` custom
|
|
* property is invisible to the compiler, so `text-ink` compiles to nothing at
|
|
* all while still looking correct in the JSX. Text assertions on class names
|
|
* cannot catch that, so this file cross-references the two sides instead.
|
|
*/
|
|
function themeDeclarations() {
|
|
const block = css.match(/@theme[^{]*\{([\s\S]*?)\n\}/);
|
|
assert.ok(block, "globals.css must declare a @theme block");
|
|
return new Map(
|
|
[...block[1].matchAll(/^\s*(--[a-z0-9-]+):\s*([^;]+);/gm)].map(([, name, value]) => [name, value.trim()]),
|
|
);
|
|
}
|
|
|
|
function rootDeclarations() {
|
|
const entries = new Map<string, string>();
|
|
for (const [, body] of css.matchAll(/^:root\s*\{([\s\S]*?)\n\}/gm)) {
|
|
for (const [, name, value] of body.matchAll(/^\s*(--[a-z0-9-]+):\s*([^;]+);/gm)) {
|
|
if (!entries.has(name)) entries.set(name, value.trim());
|
|
}
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
function sourceFiles(path: string): string[] {
|
|
const directory = new URL(path, root);
|
|
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
|
const child = join(directory.pathname, entry.name);
|
|
if (entry.isDirectory()) return sourceFiles(`${path}/${entry.name}`);
|
|
return [".ts", ".tsx"].includes(extname(entry.name)) ? [child] : [];
|
|
});
|
|
}
|
|
|
|
// Utility prefixes that resolve their argument through the `--color-*` namespace.
|
|
const colorPrefixes = [
|
|
"accent", "bg", "border", "border-b", "border-e", "border-l", "border-r", "border-s",
|
|
"border-t", "border-x", "border-y", "caret", "decoration", "divide", "fill", "from",
|
|
"inset-shadow", "outline", "placeholder", "ring", "ring-offset", "shadow", "stroke",
|
|
"text", "text-shadow", "to", "via",
|
|
];
|
|
|
|
/** Every `--color-*` token that `src/` uses as a Tailwind utility, with its call sites. */
|
|
function colorTokensUsedAsUtilities(palette: Map<string, string>) {
|
|
const used = new Map<string, Set<string>>();
|
|
for (const file of sourceFiles("src")) {
|
|
const label = file.slice(file.indexOf("/src/") + 1);
|
|
readFileSync(file, "utf8").split("\n").forEach((line, index) => {
|
|
for (const [candidate] of line.matchAll(/[a-z][A-Za-z0-9:\/.-]*/g)) {
|
|
// Strip variants (`hover:`), the important marker and any opacity modifier.
|
|
const utility = candidate.split(":").pop()!.replace(/^!/, "").split("/")[0];
|
|
for (const prefix of colorPrefixes) {
|
|
if (!utility.startsWith(`${prefix}-`)) continue;
|
|
const token = `--color-${utility.slice(prefix.length + 1)}`;
|
|
if (!palette.has(token)) continue;
|
|
if (!used.has(token)) used.set(token, new Set());
|
|
used.get(token)!.add(`${label}:${index + 1} (${utility})`);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
return used;
|
|
}
|
|
|
|
test("every color token used as a Tailwind utility is exposed through @theme", () => {
|
|
const theme = themeDeclarations();
|
|
const palette = rootDeclarations();
|
|
const dead: string[] = [];
|
|
for (const [token, callSites] of colorTokensUsedAsUtilities(palette)) {
|
|
if (theme.has(token)) continue;
|
|
dead.push(`${token} -> ${[...callSites].sort().join(", ")}`);
|
|
}
|
|
assert.deepEqual(
|
|
dead,
|
|
[],
|
|
`these utilities compile to no CSS because the token is missing from @theme:\n${dead.join("\n")}`,
|
|
);
|
|
});
|
|
|
|
test("the whole :root colour palette is reachable from the utility namespace", () => {
|
|
const theme = themeDeclarations();
|
|
const missing = [...rootDeclarations().keys()]
|
|
.filter((token) => token.startsWith("--color-") && !theme.has(token))
|
|
.sort();
|
|
assert.deepEqual(missing, [], `add these to @theme so utilities generate: ${missing.join(", ")}`);
|
|
});
|
|
|
|
test("@theme keeps the shadcn aliases pointing at the same palette tokens", () => {
|
|
const theme = themeDeclarations();
|
|
const aliases: Record<string, string> = {
|
|
"--color-background": "var(--color-canvas)",
|
|
"--color-foreground": "var(--color-ink)",
|
|
"--color-primary": "var(--color-action)",
|
|
"--color-primary-foreground": "var(--color-on-dark)",
|
|
"--color-secondary": "var(--color-canvas-muted)",
|
|
"--color-secondary-foreground": "var(--color-ink)",
|
|
"--color-muted": "var(--color-canvas-muted)",
|
|
"--color-muted-foreground": "var(--color-ink-secondary)",
|
|
"--color-destructive": "var(--color-danger)",
|
|
};
|
|
for (const [name, value] of Object.entries(aliases)) {
|
|
assert.equal(theme.get(name), value, `@theme must keep ${name} as ${value}`);
|
|
}
|
|
});
|
|
|
|
test("@theme resolves palette aliases inline so the :root values stay authoritative", () => {
|
|
// `inline` makes a utility emit the declared value rather than a reference to
|
|
// the theme variable, which is what lets `--color-background` forward to
|
|
// `--color-canvas` instead of shadowing it.
|
|
assert.match(css, /@theme inline \{/);
|
|
// Tailwind emits `@theme` into `@layer theme`, and unlayered declarations beat
|
|
// layered ones, so the literal values below must stay outside any layer.
|
|
assert.ok(css.indexOf("@theme inline {") < css.indexOf(":root {"), "@theme must precede the :root palette");
|
|
assert.match(css, /^:root \{\n color-scheme: light;\n --color-action: #85432f;/m);
|
|
});
|
|
|
|
test("@theme literals do not drift from the :root token they duplicate", () => {
|
|
const palette = rootDeclarations();
|
|
for (const [name, value] of themeDeclarations()) {
|
|
if (!name.startsWith("--color-") || value.startsWith("var(")) continue;
|
|
const token = palette.get(name);
|
|
if (token === undefined) continue;
|
|
assert.equal(value, token, `@theme ${name} must match the :root token ${token}`);
|
|
}
|
|
});
|