Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -157,8 +157,8 @@ export function TempVoiceHubList() {
and every hub gone, `expanded === null` would take the else branch and
render an empty <div> — no example, no CTA, nothing at all. */}
{configs.length === 0 && !anyExpanded ? (
<div className="space-y-4">
<p className="text-sm text-text-muted">{t("empty.exampleCaption")}</p>
<div>
<p className="mb-4 text-sm text-text-muted">{t("empty.exampleCaption")}</p>
<HubFlow example>
<HubFlowStep n={1} label={t("flow.step1")}>
<ChannelChip kind="voice" name={t("empty.exampleHub")} variant="example" />
Expand All @@ -171,9 +171,17 @@ export function TempVoiceHubList() {
</HubFlowStep>
<HubFlowStep n={4} label={t("flow.step4")} last />
</HubFlow>
<Button id="tv-add-hub" className="min-h-11" onClick={() => setExpanded("new")}>
{t("empty.cta")}
</Button>
{/* 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. */}
<div className="mt-8">
<Button id="tv-add-hub" className="min-h-11" onClick={() => setExpanded("new")}>
{t("empty.cta")}
</Button>
</div>
</div>
) : (
<div className="space-y-3">
Expand Down
8 changes: 7 additions & 1 deletion apps/dashboard/src/client/shared/ui/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 8 additions & 0 deletions apps/dashboard/src/client/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 ── */
Expand Down
82 changes: 82 additions & 0 deletions apps/dashboard/tests/client/shared/ui/button-contrast.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> {
const css = readFileSync(join(CLIENT_DIR, "styles.css"), "utf8");
const tokens = new Map<string, string>();
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);
});
});
Loading