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
2 changes: 1 addition & 1 deletion frontend/src/styles/tokens.css
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
--color-header-bg: #ffffff;
--color-header-border: #d8d8d8;
--color-footer-bg: #f9f9fa;
--color-footer-text: #898c8e;
--color-footer-text: #6b6375;
--color-footer-border: #e5e4e7;

/* Focus Ring (§3.1.4 – cursor input form) */
Expand Down
35 changes: 35 additions & 0 deletions frontend/src/styles/tokens.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,34 @@ const RETIRED_LIGHT_ONLY_HEX = [
"#721c24",
];

// WCAG 2.x relative luminance / contrast ratio (SC 1.4.3).
function relativeLuminance(hex: string): number {
const rgb = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
const [r, g, b] = rgb.map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}

function contrastRatio(hexA: string, hexB: string): number {
const lA = relativeLuminance(hexA);
const lB = relativeLuminance(hexB);
const [lighter, darker] = lA > lB ? [lA, lB] : [lB, lA];
return (lighter + 0.05) / (darker + 0.05);
}

function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

function readToken(block: string, token: string): string {
// Safe regex: `token` is always a hardcoded CSS custom-property name
// literal passed by the call sites below (e.g. "--color-footer-bg"), and
// escapeRegExp() neutralizes any regex metacharacters before
// interpolation, so no caller-controlled input reaches RegExp unescaped.
const match = block.match(new RegExp(`${escapeRegExp(token)}:\\s*(#[0-9a-fA-F]{6})`)); // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp
if (!match) throw new Error(`${token} not found as a hex value`);
return match[1];
}
Comment thread
seonghobae marked this conversation as resolved.

// Secondary <details>/<summary> disclosure toggles (advanced tools,
// evidence-extraction actions, related-post/hint expanders) must meet the
// same --size-control-min touch target as primary controls like
Expand All @@ -61,6 +89,13 @@ const DISCLOSURE_TOGGLE_SELECTORS = [
];

describe("design tokens", () => {
it("keeps footer text at or above the WCAG AA 4.5:1 contrast minimum (SC 1.4.3)", () => {
const footerBg = readToken(lightBlock, "--color-footer-bg");
const footerText = readToken(lightBlock, "--color-footer-text");
expect(contrastRatio(footerBg, footerText)).toBeGreaterThanOrEqual(4.5);
});
Comment thread
seonghobae marked this conversation as resolved.


it("defines every badge/accent token in both the light and dark blocks", () => {
for (const token of BADGE_AND_ACCENT_TOKENS) {
expect(lightBlock, `${token} missing from the light :root block`).toContain(`${token}:`);
Expand Down
Loading