diff --git a/apps/dashboard/src/client/features/tempvoice/components/TempVoiceHubList.tsx b/apps/dashboard/src/client/features/tempvoice/components/TempVoiceHubList.tsx
index 910e3e02..8207679b 100644
--- a/apps/dashboard/src/client/features/tempvoice/components/TempVoiceHubList.tsx
+++ b/apps/dashboard/src/client/features/tempvoice/components/TempVoiceHubList.tsx
@@ -157,8 +157,8 @@ export function TempVoiceHubList() {
and every hub gone, `expanded === null` would take the else branch and
render an empty
— no example, no CTA, nothing at all. */}
{configs.length === 0 && !anyExpanded ? (
-
-
{t("empty.exampleCaption")}
+
+
{t("empty.exampleCaption")}
@@ -171,9 +171,17 @@ export function TempVoiceHubList() {
-
+ {/* Spacing tier, not decoration: the steps are 20px apart (pb-5) and
+ the caption sits 16px above the list, so a 16px gap here let the
+ CTA read as an unnumbered fifth step — it also left-aligns with
+ the step markers, which reinforced the misread. 32px puts it a
+ clear tier above the flow's internal rhythm, marking it as the
+ action that follows the illustration rather than part of it. */}
+
+
+
) : (
diff --git a/apps/dashboard/src/client/shared/ui/button.tsx b/apps/dashboard/src/client/shared/ui/button.tsx
index c872ca3d..1690d736 100644
--- a/apps/dashboard/src/client/shared/ui/button.tsx
+++ b/apps/dashboard/src/client/shared/ui/button.tsx
@@ -8,8 +8,14 @@ const buttonVariants = cva(
{
variants: {
variant: {
+ // The gradient ends on primary-container, not accent-dim. accent-dim
+ // (#6063ee) carries the accent-hover label at only 2.90:1 — the sweep
+ // was wide enough that the bottom-right of every primary button failed
+ // AA while the top-left passed at 6.08:1. primary-container (#9396ff)
+ // is the darkest fill that still clears 4.5:1 (5.17:1), so the label
+ // now passes across the whole sweep.
default:
- "bg-gradient-to-br from-accent to-accent-dim text-accent-hover shadow-[0_4px_20px_rgba(163,166,255,0.3)] hover:brightness-110",
+ "bg-gradient-to-br from-accent to-primary-container text-accent-hover shadow-[0_4px_20px_rgba(163,166,255,0.3)] hover:brightness-110",
destructive:
"bg-danger text-white shadow-sm hover:brightness-110",
outline:
diff --git a/apps/dashboard/src/client/styles.css b/apps/dashboard/src/client/styles.css
index cac83eaf..de9a87b1 100644
--- a/apps/dashboard/src/client/styles.css
+++ b/apps/dashboard/src/client/styles.css
@@ -32,6 +32,12 @@
/* ── Accent & Brand ── */
--color-accent: #a3a6ff;
+ /*
+ * Despite the name, this is the ON-ACCENT foreground — the text/icon colour
+ * that sits on a --color-accent fill (6.08:1, AA). It is not a hover state.
+ * Any accent fill it is paired with must stay at or above --color-primary-
+ * container's lightness, or it drops below 4.5:1 — see the button gradient.
+ */
--color-accent-hover: #0f00a4;
--color-accent-dim: #6063ee;
--color-secondary: #ac8aff;
@@ -40,6 +46,8 @@
--color-secondary-dim: #8455ef;
--color-tertiary: #ffa5d9;
--color-tertiary-container: #ff8ed2;
+ /* Darkest accent fill that still carries --color-accent-hover text at AA
+ * (5.17:1). The end stop of the primary button gradient. */
--color-primary-container: #9396ff;
/* ── Semantic ── */
diff --git a/apps/dashboard/tests/client/shared/ui/button-contrast.test.ts b/apps/dashboard/tests/client/shared/ui/button-contrast.test.ts
new file mode 100644
index 00000000..440c0900
--- /dev/null
+++ b/apps/dashboard/tests/client/shared/ui/button-contrast.test.ts
@@ -0,0 +1,82 @@
+import { readFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { describe, expect, it } from "vitest";
+
+/**
+ * The primary button paints its label on a *gradient*, so a single
+ * foreground/background pair proves nothing: the label has to clear AA against
+ * every stop, not just the one someone happened to eyedrop. It previously swept
+ * accent -> accent-dim, which passed at 6.08:1 on the light stop and failed at
+ * 2.90:1 on the dark one — the top-left of the button was readable and the
+ * bottom-right was not, which is exactly the kind of defect a screenshot hides.
+ *
+ * This reads the real tokens and the real variant string rather than restating
+ * them, so reintroducing a too-dark stop (or retargeting the label colour)
+ * fails here instead of shipping.
+ */
+
+const CLIENT_DIR = join(dirname(fileURLToPath(import.meta.url)), "../../../../src/client");
+
+/** WCAG 2.1 relative luminance. */
+function luminance(hex: string): number {
+ const channels = [1, 3, 5].map((i) => {
+ const c = parseInt(hex.slice(i, i + 2), 16) / 255;
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
+ });
+ return 0.2126 * channels[0]! + 0.7152 * channels[1]! + 0.0722 * channels[2]!;
+}
+
+function contrast(a: string, b: string): number {
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
+ return (hi! + 0.05) / (lo! + 0.05);
+}
+
+/** Resolves a Tailwind colour token name (e.g. "accent-dim") to its hex. */
+function readTokens(): Map {
+ const css = readFileSync(join(CLIENT_DIR, "styles.css"), "utf8");
+ const tokens = new Map();
+ for (const [, name, hex] of css.matchAll(/--color-([\w-]+):\s*(#[0-9a-fA-F]{6})\b/g)) {
+ tokens.set(name!, hex!.toLowerCase());
+ }
+ return tokens;
+}
+
+/** Pulls the `default` variant's class string out of the button's cva config. */
+function readDefaultVariantClasses(): string {
+ const src = readFileSync(join(CLIENT_DIR, "shared/ui/button.tsx"), "utf8");
+ const match = /\n\s*default:\s*\n?\s*"([^"]+)"/.exec(src);
+ if (!match) throw new Error("could not locate the button's `default` variant class string");
+ return match[1]!;
+}
+
+describe("primary button label contrast", () => {
+ const tokens = readTokens();
+ const classes = readDefaultVariantClasses();
+
+ // `bg-gradient-to-br` declares the gradient's *direction*, and its `to-br`
+ // fragment matches a naive /\bto-([\w-]+)/ before the real colour stop does.
+ // Left in, the suite reported on a nonexistent `--color-br` and never looked
+ // at the stop that was actually failing. Drop the direction utility first.
+ const colourClasses = classes.replace(/\bbg-gradient-to-\w+\b/g, "");
+
+ const foreground = /\btext-([\w-]+)\b/.exec(colourClasses)?.[1];
+ const stops = [/\bfrom-([\w-]+)\b/, /\bto-([\w-]+)\b/]
+ .map((re) => re.exec(colourClasses)?.[1])
+ .filter((name): name is string => name !== undefined);
+
+ it("still paints a token-coloured label on a token-coloured gradient", () => {
+ // Guards the two extractors above: if the variant is rewritten to use raw
+ // hexes, arbitrary values, or a solid fill, the assertions below would
+ // silently pass over an empty stop list rather than checking anything.
+ expect(foreground).toBeDefined();
+ expect(tokens.get(foreground!)).toBeDefined();
+ expect(stops).toHaveLength(2);
+ });
+
+ it.each(stops)("clears WCAG AA against the %s stop", (stop) => {
+ const bg = tokens.get(stop);
+ expect(bg, `--color-${stop} is not defined in styles.css`).toBeDefined();
+ expect(contrast(tokens.get(foreground!)!, bg!)).toBeGreaterThanOrEqual(4.5);
+ });
+});