diff --git a/apps/web/src/styles/theme.css b/apps/web/src/styles/theme.css
index 3918bfb..ff00adc 100644
--- a/apps/web/src/styles/theme.css
+++ b/apps/web/src/styles/theme.css
@@ -67,6 +67,22 @@
--color-rec: #fb6b70;
--color-rec-soft: #fb6b701a;
+ /* Something went wrong.
+ *
+ * This token did not exist. Seventeen places said `text-danger` or `bg-danger-soft` — the failed
+ * import, the calendar that would not fetch, the model that would not download, the agent that
+ * refused, every `role="alert"` in onboarding — and Tailwind v4 emits only the utilities whose
+ * variable it can find, so `.text-danger` was never in the bundle at all. Every error message in
+ * this app has been rendering in ordinary body colour, which is exactly as loud as the sentence
+ * above it.
+ *
+ * The same red as recording, deliberately. Two reds would be two things to learn, and the
+ * contexts never meet: recording is a disc and a chip in the header, an error is a sentence
+ * announced to a screen reader. Kept as its own name so a future change to one does not silently
+ * move the other. */
+ --color-danger: #fb6b70;
+ --color-danger-soft: #fb6b701a;
+
/* Task and job states. Semantic, not decorative. */
--color-done: #64e3a1;
--color-running: #7aa2f7;
@@ -190,6 +206,8 @@
--color-ai-fg: #ffffff;
--color-rec: #c62f32;
--color-rec-soft: #c62f3214;
+ --color-danger: #c62f32;
+ --color-danger-soft: #c62f3214;
--color-done: #0f7350;
--color-running: #2f5fc0;
--color-running-soft: #2f5fc014;
@@ -284,6 +302,8 @@
--color-ai-fg: #120a26;
--color-rec: #fb6b70;
--color-rec-soft: #fb6b701a;
+ --color-danger: #fb6b70;
+ --color-danger-soft: #fb6b701a;
--avatar-fill-l: 0.72;
--avatar-fill-c: 0.11;
--avatar-fill-a: 0.22;
diff --git a/apps/web/src/styles/theme.test.ts b/apps/web/src/styles/theme.test.ts
index f47a89a..b662a1b 100644
--- a/apps/web/src/styles/theme.test.ts
+++ b/apps/web/src/styles/theme.test.ts
@@ -1,4 +1,5 @@
-import { readFileSync } from "node:fs";
+import { readdirSync, readFileSync } from "node:fs";
+import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
@@ -67,3 +68,108 @@ describe("swatch tokens", () => {
expect(theme).not.toContain("--color-swatch-");
});
});
+
+/**
+ * Every colour a component asks for is a colour the theme defines.
+ *
+ * The failure this catches is silent in a way that is worth spelling out. Tailwind v4 generates a
+ * utility only when it can resolve the token behind it, and generating nothing is not an error —
+ * `class="text-danger"` with no `--color-danger` produces no rule, no warning, and no visible sign
+ * except that the element is the colour it would have been anyway. `text-danger` was used in
+ * seventeen places for over a year: the failed import, the calendar that would not fetch, the model
+ * that would not download, every `role="alert"` in onboarding. All of them rendered in ordinary
+ * body colour, and every check in this repository passed. The screenshot audit could not see it
+ * either — grey text on the page background is a perfectly legible contrast pair.
+ *
+ * So the direction matters: the other tests here check that what the theme declares survives into
+ * the bundle, and this one checks that what the interface *asks for* was ever declared.
+ */
+describe("colour utilities", () => {
+ const css = read("./theme.css");
+
+ /** Every `--color-*` the theme defines, anywhere in the file. */
+ const defined = new Set([...css.matchAll(/--color-([a-z0-9-]+):/g)].map((m) => m[1]!));
+
+ /**
+ * The four prefixes that are colours often enough to be worth checking, and the values they take
+ * that are *not* colours.
+ *
+ * Tailwind overloads these: `text-sm` is a size, `bg-cover` is a fit, `border-b` is an edge. The
+ * list is static, it is Tailwind's rather than ours, and a false positive costs one line here —
+ * which is the right trade against a whole class of silently-dead styling.
+ */
+ const NOT_A_COLOUR = new Set([
+ // text-
+ ...["xs", "sm", "base", "lg", "xl", "2xl", "3xl", "4xl", "5xl", "6xl", "7xl", "8xl", "9xl"],
+ ...["left", "center", "right", "justify", "start", "end", "balance", "pretty"],
+ ...["wrap", "nowrap", "ellipsis", "clip"],
+ // bg-
+ ...["fixed", "local", "scroll", "cover", "contain", "none", "repeat", "no-repeat"],
+ ...["top", "bottom", "auto", "clip-text", "clip-border", "clip-padding", "clip-content"],
+ // border-
+ ...["0", "2", "4", "8", "x", "y", "s", "e", "t", "r", "b", "l"],
+ ...["solid", "dashed", "dotted", "double", "hidden", "collapse", "separate"],
+ // ring-
+ ...["1", "3", "inset", "offset"],
+ // Tailwind's own palette, used directly in a few places where a literal is honest.
+ ...["white", "black", "transparent", "current", "inherit"],
+ ]);
+
+ /** Every `.tsx` and `.ts` under `src`. */
+ function sources(dir: string): string[] {
+ const here = fileURLToPath(new URL(dir, import.meta.url));
+ const out: string[] = [];
+ for (const entry of readdirSync(here, { withFileTypes: true })) {
+ const path = join(dir, entry.name);
+ if (entry.isDirectory()) out.push(...sources(`${path}/`));
+ else if (/\.tsx?$/.test(entry.name) && !entry.name.endsWith(".test.ts")) out.push(path);
+ }
+ return out;
+ }
+
+ it("has a value behind every text-, bg-, border- and ring- colour the interface names", () => {
+ const files = sources("../");
+ expect(files.length, "no sources found — this test is checking nothing").toBeGreaterThan(20);
+
+ /** Whether Tailwind can resolve `
-` to something real. */
+ const resolves = (name: string): boolean => {
+ if (defined.has(name) || NOT_A_COLOUR.has(name)) return true;
+ // `text-meta` is a size, not a colour, and `rounded-card` a radius. Same shape, other token.
+ if (new RegExp(`--(?:text|radius|font|shadow)-${name}:`).test(css)) return true;
+ // `border-l-accent`, `border-b-0`, `ring-offset-bg`: an edge or an offset, then the value.
+ const [head, ...rest] = name.split("-");
+ if (rest.length > 0 && /^(?:x|y|s|e|t|r|b|l|offset)$/.test(head!)) {
+ return resolves(rest.join("-"));
+ }
+ return false;
+ };
+
+ const missing = new Map();
+ for (const file of files) {
+ // Comments first: this file's own prose names utilities in backticks, and a doc comment
+ // explaining why `bg-soft` was rejected must not read as a use of it.
+ //
+ // Arbitrary values are Tailwind passing CSS straight through, and the CSS inside them says
+ // things like `var(--color-bg-elevated)` and `transition-[background,border-color]` that look
+ // exactly like a utility and are not one. Dropped before scanning rather than pattern-matched
+ // around, which is what a lookbehind alone could not do.
+ const source = read(file)
+ .replace(/\/\*[\s\S]*?\*\//g, "")
+ .replace(/\/\/[^\n]*/g, "")
+ .replace(/\[[^\]]*\]/g, "");
+ // The name only, without the opacity suffix: `text-accent/60` is `accent`. The lookbehind is
+ // what stops `--avatar-text-c` and `--color-bg-elevated` reading as utilities.
+ for (const [, name] of source.matchAll(
+ /(? `${name} (first seen in ${file})`),
+ "these utilities generate no CSS at all",
+ ).toEqual([]);
+ });
+});