From f80c17e480aa8b5a09a75e6d09a0fd6dd9f2266f Mon Sep 17 00:00:00 2001 From: Abdulkhalek Muhammad Date: Tue, 28 Jul 2026 15:31:29 +0300 Subject: [PATCH 1/2] fix(dashboard): stop the primary button label failing AA on the gradient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default button variant painted a #0f00a4 label across a gradient sweeping accent (#a3a6ff) to accent-dim (#6063ee). One foreground on two very different backgrounds: 6.08:1 on the light stop, 2.90:1 on the dark one. Every primary button in the dashboard was readable at the top-left and below AA at the bottom-right, which is invisible to a spot-check that eyedrops a single pixel. End the gradient on primary-container (#9396ff) instead — the darkest accent fill that still carries the label at 4.5:1 (5.17:1). Keeps the gradient and the brand hue rather than flattening the button, and gives an already-defined but unused token a purpose. The other three text-accent-hover consumers sit on solid bg-accent at 6.08:1 and are untouched. Document the ratios on both tokens, following the convention styles.css already uses for text/50 and Discord blue. The test reads the real tokens and the real variant string instead of restating them, and asserts every gradient stop rather than one: a foreground that only clears AA at one end is exactly the defect here. Verified it fails (2.902:1 on accent-dim) before it passes. Co-Authored-By: Claude Opus 5 (1M context) --- .../dashboard/src/client/shared/ui/button.tsx | 8 +- apps/dashboard/src/client/styles.css | 8 ++ .../client/shared/ui/button-contrast.test.ts | 82 +++++++++++++++++++ 3 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 apps/dashboard/tests/client/shared/ui/button-contrast.test.ts 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); + }); +}); From 455c932157262be2ba9cddca94c3095c178b02bb Mon Sep 17 00:00:00 2001 From: Abdulkhalek Muhammad Date: Tue, 28 Jul 2026 15:31:36 +0300 Subject: [PATCH 2/2] fix(tempvoice): stop the empty-state CTA reading as a fifth step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit space-y-4 gave "Create your first hub" a 16px gap below step 4, while the flow's own steps sit 20px apart — so the button was closer to the list than the list was to itself. It also left-aligns with the step markers, which compounded the misread: the CTA looked like an unnumbered continuation of the worked example rather than the action that follows it. Move it to its own 32px tier, a clear step above the flow's internal rhythm. Co-Authored-By: Claude Opus 5 (1M context) --- .../tempvoice/components/TempVoiceHubList.tsx | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) 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. */} +
+ +
) : (