feat(design): darken status tokens to pass AAA contrast
The previous color values did not actually meet WCAG AAA (7:1) on
all 4 page surfaces — the spec's contrast table was aspirational.
The contrast test correctly caught 19 of 37 failing assertions.
Fix:
- Darken status colors to green-900 / red-900 / amber-900 so they
pass AAA on the surfaces they actually appear on (bg, surface,
and their -soft pill backgrounds).
- Restructure the test to match real usage:
- body text → AAA on all 4 surfaces
- text-faint → AA on all 4 surfaces (lowered from 6e6e73 to 5e5e63)
- status text → AAA on bg + surface (not surface-3, where it
does not actually render; that's a skeleton/divider surface)
- status text on its -soft pill bg → AAA
- accent-2 → tested as a button background with white text on top
- Update spec + plan to reflect the actual contrast guarantees.
Result: 35/35 contrast assertions pass, full vitest suite green
(except 3 pre-existing getAdminUser failures unrelated to this work).
This commit is contained in:
@@ -51,22 +51,25 @@ Add a new `@theme` block immediately after the existing one (Tailwind v4 support
|
||||
--color-surface-2: #f5f5f7;
|
||||
--color-surface-3: #e8e8ed;
|
||||
|
||||
/* Text — all AAA on --color-surface (14.6 / 9.4) except --text-faint (5.3 / AA only) */
|
||||
/* Text — AAA on every surface except --text-faint (AA only, ≥ 5.28:1) */
|
||||
--color-text: #1d1d1f;
|
||||
--color-text-muted: #424245;
|
||||
--color-text-faint: #6e6e73;
|
||||
--color-text-faint: #5e5e63; /* AA on every surface (≥ 5.28:1) */
|
||||
|
||||
/* Accent / status — AAA on --color-surface */
|
||||
--color-accent: #166534; /* primary actions, brand */
|
||||
--color-accent-2: #15803d; /* hover state */
|
||||
--color-accent-soft: #dcfce7; /* tinted backgrounds, accent at ~10% */
|
||||
--color-danger: #b91c1c; /* errors, destructive */
|
||||
/* Accent / status — darkened to pass AAA on the surfaces they actually
|
||||
* appear on (bg, surface, and their -soft pill backgrounds). The darker
|
||||
* hues (green-900 / red-900 / amber-900) intentionally trade a touch of
|
||||
* vibrancy for legibility in outdoor / sun-glare conditions. */
|
||||
--color-accent: #14532d; /* AAA on bg (9.11), surface (8.59), s2 (8.37), s3 (7.46) */
|
||||
--color-accent-2: #166534; /* hover state — used as button background, NOT text */
|
||||
--color-accent-soft: #dcfce7; /* pill background; accent text on it = 8.36:1 */
|
||||
--color-danger: #7f1d1d; /* AAA on bg (10.02), surface (9.45), s2 (9.20), s3 (8.20) */
|
||||
--color-danger-soft: #fee2e2;
|
||||
--color-warning: #854d0e; /* warnings, low stock */
|
||||
--color-warning: #5e2a04; /* AAA on bg (11.61), surface (10.95), s2 (10.66), s3 (9.51) */
|
||||
--color-warning-soft: #fef9c3;
|
||||
--color-success: #166534; /* completed, positive */
|
||||
--color-success: #14532d; /* mirrors accent */
|
||||
--color-success-soft: #dcfce7;
|
||||
--color-info: #1e40af; /* informational */
|
||||
--color-info: #1e3a8a; /* AAA on every surface (8.48–10.36) */
|
||||
--color-info-soft: #dbeafe;
|
||||
}
|
||||
|
||||
@@ -152,12 +155,24 @@ git commit -m "feat(design): add Field Almanac tokens (type, color, spacing, mot
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
/**
|
||||
* Asserts all token combinations meet WCAG contrast requirements.
|
||||
* - AAA = 7:1 (every text token vs every surface it appears on), EXCEPT --color-text-faint
|
||||
* which is documented as AA-only.
|
||||
* - AA = 4.5:1 (for --color-text-faint).
|
||||
* - Large text (18pt+ or 14pt+ bold) gets a relaxed 3:1 floor, but we require AAA anyway
|
||||
* because outdoor + sun glare is harder than AAA accounts for.
|
||||
* Asserts all token combinations meet WCAG contrast requirements for the
|
||||
* surfaces they actually render on. Rationale: outdoor + sun-glare is
|
||||
* harder than WCAG accounts for, so we aim for AAA (7:1) on the
|
||||
* surfaces text appears on, and AA (4.5:1) as the documented floor.
|
||||
*
|
||||
* Surface roles:
|
||||
* bg — page background
|
||||
* surface — card / panel background
|
||||
* surface-2 — input fields, secondary cards
|
||||
* surface-3 — high-emphasis containers, skeletons, dividers
|
||||
*
|
||||
* Token roles (and which assertion groups they belong to):
|
||||
* text / text-muted — body text, appears on all 4 surfaces → AAA
|
||||
* text-faint — meta / timestamps, appears on all 4 surfaces → AA
|
||||
* accent / success — brand + positive status text → AAA on bg, surface
|
||||
* danger / warning / info — status text, on bg + surface + their -soft pill bg → AAA
|
||||
* accent-2 — button background, white text sits on top → AAA
|
||||
* *-soft — pill background, distinct from page surface (decorative)
|
||||
*/
|
||||
|
||||
type RGB = [number, number, number];
|
||||
@@ -199,43 +214,98 @@ const SURFACES: Record<string, string> = {
|
||||
"surface-3": "#e8e8ed",
|
||||
};
|
||||
|
||||
const TEXT_TOKENS_AAA: Record<string, string> = {
|
||||
text: "#1d1d1f",
|
||||
"text-muted": "#424245",
|
||||
accent: "#166534",
|
||||
"accent-2": "#15803d",
|
||||
danger: "#b91c1c",
|
||||
warning: "#854d0e",
|
||||
success: "#166534",
|
||||
info: "#1e40af",
|
||||
// Page surfaces that status text actually renders on. Status text does
|
||||
// not render directly on --color-surface-3 (skeleton/dividers); it lives
|
||||
// on its own -soft pill background instead.
|
||||
const PAGE_SURFACES: Record<string, string> = {
|
||||
bg: "#ffffff",
|
||||
surface: "#faf8f5",
|
||||
};
|
||||
|
||||
const TEXT_FAINTS_AA: Record<string, string> = {
|
||||
"text-faint": "#6e6e73",
|
||||
const BODY_TEXT_AAA: Record<string, string> = {
|
||||
text: "#1d1d1f",
|
||||
"text-muted": "#424245",
|
||||
info: "#1e3a8a",
|
||||
};
|
||||
|
||||
const META_TEXT_AA: Record<string, string> = {
|
||||
"text-faint": "#5e5e63",
|
||||
};
|
||||
|
||||
const STATUS_TEXT_AAA: Record<string, string> = {
|
||||
accent: "#14532d",
|
||||
danger: "#7f1d1d",
|
||||
warning: "#5e2a04",
|
||||
success: "#14532d",
|
||||
};
|
||||
|
||||
const STATUS_ON_SOFT: Record<string, { text: string; soft: string }> = {
|
||||
"accent on accent-soft": { text: "#14532d", soft: "#dcfce7" },
|
||||
"danger on danger-soft": { text: "#7f1d1d", soft: "#fee2e2" },
|
||||
"warning on warning-soft": { text: "#5e2a04", soft: "#fef9c3" },
|
||||
"success on success-soft": { text: "#14532d", soft: "#dcfce7" },
|
||||
"info on info-soft": { text: "#1e3a8a", soft: "#dbeafe" },
|
||||
};
|
||||
|
||||
const ON_ACCENT_BUTTON = {
|
||||
fg: "#ffffff", // button label
|
||||
bg: "#166534", // --color-accent-2
|
||||
};
|
||||
|
||||
describe("design tokens — WCAG contrast", () => {
|
||||
Object.entries(TEXT_TOKENS_AAA).forEach(([token, hex]) => {
|
||||
Object.entries(SURFACES).forEach(([surfaceName, surfaceHex]) => {
|
||||
it(`${token} on ${surfaceName} meets AAA (>= 7:1)`, () => {
|
||||
const ratio = contrastRatio(hex, surfaceHex);
|
||||
expect(ratio, `${token} (${hex}) on ${surfaceName} (${surfaceHex}) = ${ratio.toFixed(2)}:1`).toBeGreaterThanOrEqual(7);
|
||||
describe("body text on every page surface (AAA)", () => {
|
||||
Object.entries(BODY_TEXT_AAA).forEach(([token, hex]) => {
|
||||
Object.entries(SURFACES).forEach(([surfaceName, surfaceHex]) => {
|
||||
it(`${token} on ${surfaceName} meets AAA (>= 7:1)`, () => {
|
||||
const ratio = contrastRatio(hex, surfaceHex);
|
||||
expect(ratio, `${token} (${hex}) on ${surfaceName} (${surfaceHex}) = ${ratio.toFixed(2)}:1`).toBeGreaterThanOrEqual(7);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Object.entries(TEXT_FAINTS_AA).forEach(([token, hex]) => {
|
||||
Object.entries(SURFACES).forEach(([surfaceName, surfaceHex]) => {
|
||||
it(`${token} on ${surfaceName} meets AA (>= 4.5:1) [documented exception]`, () => {
|
||||
const ratio = contrastRatio(hex, surfaceHex);
|
||||
expect(ratio, `${token} (${hex}) on ${surfaceName} (${surfaceHex}) = ${ratio.toFixed(2)}:1`).toBeGreaterThanOrEqual(4.5);
|
||||
describe("meta text on every page surface (AA)", () => {
|
||||
Object.entries(META_TEXT_AA).forEach(([token, hex]) => {
|
||||
Object.entries(SURFACES).forEach(([surfaceName, surfaceHex]) => {
|
||||
it(`${token} on ${surfaceName} meets AA (>= 4.5:1)`, () => {
|
||||
const ratio = contrastRatio(hex, surfaceHex);
|
||||
expect(ratio, `${token} (${hex}) on ${surfaceName} (${surfaceHex}) = ${ratio.toFixed(2)}:1`).toBeGreaterThanOrEqual(4.5);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("status-pill backgrounds (soft variants) read on surface-2", () => {
|
||||
// The "soft" status backgrounds are intended for filled pills, not body text.
|
||||
// We only assert they're visually distinct from the surface they sit on.
|
||||
describe("status text on page surfaces (AAA)", () => {
|
||||
Object.entries(STATUS_TEXT_AAA).forEach(([token, hex]) => {
|
||||
Object.entries(PAGE_SURFACES).forEach(([surfaceName, surfaceHex]) => {
|
||||
it(`${token} on ${surfaceName} meets AAA (>= 7:1)`, () => {
|
||||
const ratio = contrastRatio(hex, surfaceHex);
|
||||
expect(ratio, `${token} (${hex}) on ${surfaceName} (${surfaceHex}) = ${ratio.toFixed(2)}:1`).toBeGreaterThanOrEqual(7);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("status text on its -soft pill background (AAA)", () => {
|
||||
Object.entries(STATUS_ON_SOFT).forEach(([name, { text, soft }]) => {
|
||||
it(`${name} meets AAA (>= 7:1)`, () => {
|
||||
const ratio = contrastRatio(text, soft);
|
||||
expect(ratio, `${name}: ${text} on ${soft} = ${ratio.toFixed(2)}:1`).toBeGreaterThanOrEqual(7);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("button text on accent-2 background (AAA)", () => {
|
||||
it(`white on accent-2 meets AAA (>= 7:1)`, () => {
|
||||
const ratio = contrastRatio(ON_ACCENT_BUTTON.fg, ON_ACCENT_BUTTON.bg);
|
||||
expect(ratio, `white on ${ON_ACCENT_BUTTON.bg} = ${ratio.toFixed(2)}:1`).toBeGreaterThanOrEqual(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pill backgrounds are visually distinct from page surface", () => {
|
||||
// The "soft" backgrounds are decorative. They don't need to pass
|
||||
// contrast on their own, but they must read as a distinct fill
|
||||
// against the page surface.
|
||||
const softs: Record<string, string> = {
|
||||
"accent-soft": "#dcfce7",
|
||||
"danger-soft": "#fee2e2",
|
||||
@@ -244,8 +314,10 @@ describe("design tokens — WCAG contrast", () => {
|
||||
"info-soft": "#dbeafe",
|
||||
};
|
||||
Object.entries(softs).forEach(([token, hex]) => {
|
||||
const ratio = contrastRatio(hex, "#faf8f5");
|
||||
expect(ratio, `${token} too close to surface`).toBeLessThan(1.5);
|
||||
it(`${token} reads as a distinct fill on --color-surface`, () => {
|
||||
const ratio = contrastRatio(hex, "#faf8f5");
|
||||
expect(ratio, `${token} too close to surface (${ratio.toFixed(2)}:1)`).toBeLessThan(1.5);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -254,7 +326,7 @@ describe("design tokens — WCAG contrast", () => {
|
||||
- [ ] **Step 2: Run the test**
|
||||
|
||||
Run: `npx vitest run src/lib/__tests__/design-tokens.test.ts`
|
||||
Expected: 35+ tests, all PASS. (4 text AAA × 4 surfaces = 16, plus 1 text-faint AA × 4 surfaces = 4, plus 5 soft variant tests = 25 total. If some combinations aren't reachable in production code, narrow the SURFACES object; the goal is to cover what ships.)
|
||||
Expected: 35 tests, all PASS. (3 body text AAA × 4 surfaces = 12, plus 1 meta text AA × 4 surfaces = 4, plus 4 status text AAA × 2 page surfaces = 8, plus 5 status-on-soft AAA = 5, plus 1 button-text AAA = 1, plus 5 soft-distinct = 5, total = 35. If a real product surface is missing from the matrix, add it to `SURFACES` or `PAGE_SURFACES`; the goal is to cover what ships.)
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
|
||||
Reference in New Issue
Block a user