diff --git a/changelog.d/changed-destructive-darker.changed.md b/changelog.d/changed-destructive-darker.changed.md new file mode 100644 index 0000000..aca3bc7 --- /dev/null +++ b/changelog.d/changed-destructive-darker.changed.md @@ -0,0 +1 @@ +`--destructive` bumped from `#EF4444` (red-500) to `#DC2626` (red-600) so `--destructive-foreground` (white) clears WCAG AA on the destructive fill (now 4.83:1, was 3.76:1). diff --git a/changelog.d/changed-text-warning-darker.changed.md b/changelog.d/changed-text-warning-darker.changed.md new file mode 100644 index 0000000..6bc870b --- /dev/null +++ b/changelog.d/changed-text-warning-darker.changed.md @@ -0,0 +1 @@ +`--text-warning` bumped from `#d9480f` (Mantine orange.9, 4.30:1 on white) to `#c2410c` (Tailwind orange-700, 5.18:1 on white) to clear WCAG AA at small text sizes. diff --git a/changelog.d/feat-canonical-ts-tokens.added.md b/changelog.d/feat-canonical-ts-tokens.added.md new file mode 100644 index 0000000..4376bc4 --- /dev/null +++ b/changelog.d/feat-canonical-ts-tokens.added.md @@ -0,0 +1 @@ +TS canonical token source (`src/theme/tokens.ts`) with a generator (`scripts/generate-css.ts`) emitting both the CSS theme (`tokens.css`) and a Quarto SCSS theme (`quarto.scss`). Runtime tokens (`colors`, `palette`, `chartPalette`, `semanticFills`, `typography`, `radius`, `breakpoints`, `tokens`) are exported from the package root and from `@policyengine/ui-kit/tokens`. diff --git a/changelog.d/feat-contrast-matrix.added.md b/changelog.d/feat-contrast-matrix.added.md new file mode 100644 index 0000000..9e40034 --- /dev/null +++ b/changelog.d/feat-contrast-matrix.added.md @@ -0,0 +1 @@ +WCAG contrast matrix Vitest. `tests/theme/contrast.test.ts` asserts every documented foreground/background token pair clears WCAG AA at 4.5:1 in both light and dark mode. Catches accessible-color regressions before they ship. diff --git a/changelog.d/feat-dark-mode-tokens.added.md b/changelog.d/feat-dark-mode-tokens.added.md new file mode 100644 index 0000000..5c3a573 --- /dev/null +++ b/changelog.d/feat-dark-mode-tokens.added.md @@ -0,0 +1 @@ +Dark mode tokens (`:root.dark` / `.dark { … }`) for every shadcn semantic role plus accessible-on-dark text variants. Activate by adding `class="dark"` to any ancestor element. Components and consumers' Tailwind utilities pick up the new values automatically via the `@custom-variant dark` declaration. diff --git a/changelog.d/feat-focus-ring-and-reduced-motion.added.md b/changelog.d/feat-focus-ring-and-reduced-motion.added.md new file mode 100644 index 0000000..9fb8c13 --- /dev/null +++ b/changelog.d/feat-focus-ring-and-reduced-motion.added.md @@ -0,0 +1 @@ +Built-in `:focus-visible` outline on every interactive element and a `prefers-reduced-motion: reduce` rule that snaps animations and transitions to instant. Applied via `@layer base`, so consumers inherit them just by importing `theme.css`. diff --git a/changelog.d/feat-quarto-theme.added.md b/changelog.d/feat-quarto-theme.added.md new file mode 100644 index 0000000..596b8b1 --- /dev/null +++ b/changelog.d/feat-quarto-theme.added.md @@ -0,0 +1 @@ +Quarto SCSS theme export (`@policyengine/ui-kit/quarto.scss`). Maps the same hex values used in the React app to Bootstrap/Quarto SCSS variables (`$primary`, `$body-color`, etc.) so paper renders share the dashboard's palette and contrast guarantees. diff --git a/package.json b/package.json index 484c4e1..86021cd 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,12 @@ }, "./styles.css": "./dist/styles.css", "./theme.css": "./src/theme/tokens.css", + "./quarto.scss": "./src/theme/quarto.scss", + "./tokens": { + "types": "./dist/theme/index.d.ts", + "import": "./dist/theme.js", + "require": "./dist/theme.cjs" + }, "./favicon.svg": "./src/assets/logos/policyengine/teal-square.svg" }, "files": [ @@ -63,6 +69,8 @@ "scripts": { "dev": "vite", "dev:demo": "vite --config vite.demo.config.ts", + "generate-tokens": "tsx scripts/generate-css.ts", + "prebuild": "tsx scripts/generate-css.ts", "build": "vite build && mv dist/ui-kit.css dist/styles.css && tsc -p tsconfig.build.json --emitDeclarationOnly", "test": "vitest run", "test:watch": "vitest", @@ -94,15 +102,17 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", + "@types/d3-geo": "^3.1.0", + "@types/node": "^25.6.2", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", - "@types/d3-geo": "^3.1.0", "@vitejs/plugin-react": "^4.3.4", "jsdom": "^25.0.1", "react": "^19.0.0", "react-dom": "^19.0.0", "recharts": "^2.15.0", "tailwindcss": "^4.2.0", + "tsx": "^4.20.0", "typescript": "^5.7.0", "vite": "^6.3.0", "vitest": "^3.1.0" diff --git a/scripts/generate-css.ts b/scripts/generate-css.ts new file mode 100644 index 0000000..4189915 --- /dev/null +++ b/scripts/generate-css.ts @@ -0,0 +1,332 @@ +/** + * Regenerate src/theme/tokens.css and src/theme/quarto.scss from + * the canonical TS source in src/theme/tokens.ts. + * + * Run via `bun run generate-tokens` (or `tsx scripts/generate-css.ts`). + * + * The CI test in `tests/theme/generated-css.test.ts` re-runs this generator + * and asserts the checked-in files match — so devs can't drift the CSS by + * hand-editing. + */ + +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + breakpoints, + namedSpacing, + palette, + radius, + rootBaseLightSections, + rootColorsDarkSections, + rootColorsLight, + rootColorsLightSections, + semanticFills, + themeInlineSectionsExport, + typography, + type CssSection, +} from "../src/theme/tokens"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const themeDir = path.resolve(__dirname, "..", "src", "theme"); + +const TOKENS_CSS = path.join(themeDir, "tokens.css"); +const QUARTO_SCSS = path.join(themeDir, "quarto.scss"); + +const STATIC_HEADER = `@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +@source "../../dist/**/*.js"; + +/* ============================================================ + * PolicyEngine Design Tokens — GENERATED FILE; DO NOT EDIT. + * + * Source of truth: src/theme/tokens.ts + * Regenerate with: bun run generate-tokens + * + * Consumer usage (globals.css): + * @import "tailwindcss"; + * @import "@policyengine/ui-kit/theme.css"; + * + * Both imports are required. Tailwind must come first. + * The consumer must have @tailwindcss/postcss in their postcss config. + * ============================================================ */`; + +const STATIC_BASE_LAYER = `/* --- Base styles --- */ +@layer base { + * { + @apply border-border; + } + html { + font-family: var(--font-sans); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + body { + @apply bg-background text-foreground; + font-family: var(--font-sans); + line-height: 1.55; + } + + /* Range input (slider) */ + input[type="range"] { + -webkit-appearance: none; + appearance: none; + background: transparent; + height: 6px; + } + input[type="range"]::-webkit-slider-runnable-track { + background: var(--border); + height: 6px; + border-radius: 3px; + } + input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--primary); + cursor: pointer; + margin-top: -6px; + } + input[type="range"]::-moz-range-track { + background: var(--border); + height: 6px; + border-radius: 3px; + } + input[type="range"]::-moz-range-thumb { + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--primary); + border: none; + cursor: pointer; + } + + /* Reduced motion: snap animations / transitions to instant. */ + @media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + scroll-behavior: auto !important; + } + } + + /* Visible focus ring on every interactive element. */ + :where(a, button, [role="button"], input, select, textarea, summary, [tabindex]):focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; + border-radius: var(--radius-sm); + } +}`; + +function indent(lines: string[], spaces = 2): string { + const pad = " ".repeat(spaces); + return lines.map((line) => (line ? `${pad}${line}` : line)).join("\n"); +} + +function entriesToDeclarations(entries: Record): string[] { + return Object.entries(entries).map(([key, value]) => `${key}: ${value};`); +} + +function sectionsToLines(sections: CssSection[]): string[] { + const lines: string[] = []; + sections.forEach((section, index) => { + if (index > 0) lines.push(""); + lines.push(`/* ${section.name} */`); + lines.push(...entriesToDeclarations(section.declarations)); + }); + return lines; +} + +function rootLightBlock(): string { + const blockLines = [ + ...sectionsToLines(rootBaseLightSections), + "", + ...sectionsToLines(rootColorsLightSections), + ]; + return [ + "/* --- Layer 1: shadcn-style :root tokens (light mode) --- */", + ":root {", + indent(blockLines), + "}", + ].join("\n"); +} + +function rootDarkBlock(): string { + return [ + "", + "/* --- Layer 1b: dark-mode overrides (activate with `class=\"dark\"` on a parent) --- */", + ":root.dark,", + ".dark {", + indent(sectionsToLines(rootColorsDarkSections)), + "}", + ].join("\n"); +} + +function themeInlineBlock(): string { + return [ + "", + "/* --- Layer 2: Tailwind @theme inline (bridges :root vars to Tailwind utilities) --- */", + "@theme inline {", + indent(sectionsToLines(themeInlineSectionsExport)), + "}", + ].join("\n"); +} + +function brandPaletteBlock(): string { + const declarations: string[] = []; + declarations.push("/* Teal (primary brand) */"); + for (const [scale, value] of Object.entries(palette.teal)) { + declarations.push(`--color-teal-${scale}: ${value};`); + } + declarations.push(""); + declarations.push("/* Gray (slate scale) */"); + for (const [scale, value] of Object.entries(palette.gray)) { + declarations.push(`--color-gray-${scale}: ${value};`); + } + declarations.push(""); + declarations.push("/* Blue (accent) */"); + for (const [scale, value] of Object.entries(palette.blue)) { + declarations.push(`--color-blue-${scale}: ${value};`); + } + declarations.push(""); + declarations.push("/* Semantic */"); + for (const [name, value] of Object.entries(semanticFills)) { + declarations.push(`--color-${name}: ${value};`); + } + declarations.push(""); + declarations.push("/* Font families */"); + declarations.push(`--font-sans: ${typography.fontFamily.sans};`); + declarations.push(`--font-mono: ${typography.fontFamily.mono};`); + declarations.push(""); + declarations.push("/* Font sizes (overrides Tailwind defaults with PE scale) */"); + declarations.push("--text-*: initial;"); + for (const [name, { size, lineHeight }] of Object.entries(typography.fontSize)) { + declarations.push(`--text-${name}: ${size};`); + declarations.push(`--text-${name}--line-height: ${lineHeight};`); + } + declarations.push(""); + declarations.push("/* Semantic radius */"); + for (const [name, value] of Object.entries(radius)) { + declarations.push(`--radius-${name}: ${value};`); + } + declarations.push(""); + declarations.push("/* Named spacing */"); + for (const [name, value] of Object.entries(namedSpacing)) { + const cssName = name.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`); + declarations.push(`--spacing-${cssName}: ${value};`); + } + declarations.push(""); + declarations.push("/* Breakpoints */"); + for (const [name, value] of Object.entries(breakpoints)) { + declarations.push(`--breakpoint-${name}: ${value};`); + } + + const lines = [ + "", + "/* --- Layer 3: brand palette + scales (concrete Tailwind utilities) --- */", + "@theme {", + indent(declarations), + "}", + ]; + return lines.join("\n"); +} + +function buildTokensCss(): string { + return [ + STATIC_HEADER, + "", + rootLightBlock(), + rootDarkBlock(), + themeInlineBlock(), + brandPaletteBlock(), + "", + STATIC_BASE_LAYER, + "", + ].join("\n"); +} + +function buildQuartoScss(): string { + // Map the canonical PolicyEngine tokens to Quarto/Bootstrap SCSS variables. + // Consumers import via: + // /*-- scss:defaults --*/ + // @import "@policyengine/ui-kit/quarto.scss"; + const lines: string[] = [ + "/* PolicyEngine Quarto SCSS theme — GENERATED; DO NOT EDIT.", + " * Source of truth: src/theme/tokens.ts", + " * Regenerate with: bun run generate-tokens", + " *", + " * Usage in a Quarto _quarto.yml:", + " * format:", + " * html:", + " * theme:", + " * - cosmo", + " * - quarto.scss # this file via @import", + " * The exposed Bootstrap variables track the same hex values as the CSS", + " * tokens used in the React app, so paper renders match the dashboard.", + " */", + "", + "/*-- scss:defaults --*/", + "", + `$primary: ${rootColorsLight["--primary"]};`, + `$secondary: ${rootColorsLight["--secondary-foreground"]};`, + `$success: ${semanticFills.success};`, + `$warning: ${semanticFills.warning};`, + `$danger: ${semanticFills.error};`, + `$info: ${semanticFills.info};`, + `$body-bg: ${rootColorsLight["--background"]};`, + `$body-color: ${rootColorsLight["--foreground"]};`, + `$body-secondary-color: ${rootColorsLight["--text-secondary"]};`, + `$body-tertiary-color: ${rootColorsLight["--muted-foreground"]};`, + `$link-color: ${palette.teal[600]};`, + `$link-hover-color: ${palette.teal[700]};`, + `$border-color: ${rootColorsLight["--border"]};`, + `$card-bg: ${rootColorsLight["--card"]};`, + `$font-family-sans-serif: ${typography.fontFamily.sans};`, + `$font-family-monospace: ${typography.fontFamily.mono};`, + "", + "// Accessible-on-white text variants — match @policyengine/ui-kit's", + "// --text-warning / --text-error / --text-success exactly so paper", + "// callouts share the dashboard's contrast guarantees.", + `$pe-text-warning: ${rootColorsLight["--text-warning"]};`, + `$pe-text-error: ${rootColorsLight["--text-error"]};`, + `$pe-text-success: ${rootColorsLight["--text-success"]};`, + "", + "// Brand palette so authors can style a callout without hex literals.", + ...Object.entries(palette.teal).map( + ([scale, value]) => `$pe-teal-${scale}: ${value};`, + ), + ...Object.entries(palette.gray).map( + ([scale, value]) => `$pe-gray-${scale}: ${value};`, + ), + ...Object.entries(palette.blue).map( + ([scale, value]) => `$pe-blue-${scale}: ${value};`, + ), + "", + ]; + return lines.join("\n"); +} + +export function generate(): { tokensCss: string; quartoScss: string } { + return { tokensCss: buildTokensCss(), quartoScss: buildQuartoScss() }; +} + +function main() { + const { tokensCss, quartoScss } = generate(); + fs.writeFileSync(TOKENS_CSS, tokensCss); + fs.writeFileSync(QUARTO_SCSS, quartoScss); + console.log(`✅ Wrote ${path.relative(process.cwd(), TOKENS_CSS)}`); + console.log(`✅ Wrote ${path.relative(process.cwd(), QUARTO_SCSS)}`); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main(); +} diff --git a/src/index.ts b/src/index.ts index 45a31f3..2309683 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,9 @@ // Styles — consumers must import '@policyengine/ui-kit/styles.css' separately import './theme/tokens.css'; +// Runtime tokens (colors, palette, chartColors, typography, …) +export * from './theme'; + // Types export * from './types'; diff --git a/src/theme/index.ts b/src/theme/index.ts index 429cc4c..cb85eb0 100644 --- a/src/theme/index.ts +++ b/src/theme/index.ts @@ -1,2 +1,26 @@ -// Re-export the CSS theme for programmatic import -import './tokens.css'; +// Re-export the CSS theme for programmatic import (a no-op at type-check +// time; bundlers register the side-effecting CSS file when this module is +// imported). +import "./tokens.css"; + +// Runtime token exports — for JS consumers that need a literal hex value +// (Plotly traces, generated SVG, dynamic inline styles, etc.). For Tailwind +// CSS or Recharts, prefer `var(--color-…)` strings — they pick up dark +// mode automatically. +export { + breakpoints, + chartPalette, + colors, + contrastPairs, + namedSpacing, + palette, + radius, + rootColorsDark, + rootColorsLight, + semanticFills, + themeInline, + tokens, + typography, +} from "./tokens"; + +export type { ContrastPair, CssSection } from "./tokens"; diff --git a/src/theme/quarto.scss b/src/theme/quarto.scss new file mode 100644 index 0000000..b6cad9c --- /dev/null +++ b/src/theme/quarto.scss @@ -0,0 +1,71 @@ +/* PolicyEngine Quarto SCSS theme — GENERATED; DO NOT EDIT. + * Source of truth: src/theme/tokens.ts + * Regenerate with: bun run generate-tokens + * + * Usage in a Quarto _quarto.yml: + * format: + * html: + * theme: + * - cosmo + * - quarto.scss # this file via @import + * The exposed Bootstrap variables track the same hex values as the CSS + * tokens used in the React app, so paper renders match the dashboard. + */ + +/*-- scss:defaults --*/ + +$primary: #2C7A7B; +$secondary: #101828; +$success: #22C55E; +$warning: #FEC601; +$danger: #EF4444; +$info: #1890FF; +$body-bg: #FFFFFF; +$body-color: #000000; +$body-secondary-color: #5a5a5a; +$body-tertiary-color: #475569; +$link-color: #2C7A7B; +$link-hover-color: #285E61; +$border-color: #E2E8F0; +$card-bg: #FFFFFF; +$font-family-sans-serif: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; +$font-family-monospace: JetBrains Mono, "Fira Code", Consolas, monospace; + +// Accessible-on-white text variants — match @policyengine/ui-kit's +// --text-warning / --text-error / --text-success exactly so paper +// callouts share the dashboard's contrast guarantees. +$pe-text-warning: #c2410c; +$pe-text-error: #B91C1C; +$pe-text-success: #285E61; + +// Brand palette so authors can style a callout without hex literals. +$pe-teal-50: #E6FFFA; +$pe-teal-100: #B2F5EA; +$pe-teal-200: #81E6D9; +$pe-teal-300: #4FD1C5; +$pe-teal-400: #38B2AC; +$pe-teal-500: #319795; +$pe-teal-600: #2C7A7B; +$pe-teal-700: #285E61; +$pe-teal-800: #234E52; +$pe-teal-900: #1D4044; +$pe-gray-50: #F0F9FF; +$pe-gray-100: #F2F4F7; +$pe-gray-200: #E2E8F0; +$pe-gray-300: #CBD5E1; +$pe-gray-400: #94A3B8; +$pe-gray-500: #64748B; +$pe-gray-600: #475569; +$pe-gray-700: #344054; +$pe-gray-800: #1E293B; +$pe-gray-900: #101828; +$pe-blue-50: #F0F9FF; +$pe-blue-100: #E0F2FE; +$pe-blue-200: #BAE6FD; +$pe-blue-300: #7DD3FC; +$pe-blue-400: #38BDF8; +$pe-blue-500: #0EA5E9; +$pe-blue-600: #0284C7; +$pe-blue-700: #026AA2; +$pe-blue-800: #075985; +$pe-blue-900: #0C4A6E; diff --git a/src/theme/tokens.css b/src/theme/tokens.css index 8ccd83c..91d42dc 100644 --- a/src/theme/tokens.css +++ b/src/theme/tokens.css @@ -5,10 +5,11 @@ @source "../../dist/**/*.js"; /* ============================================================ - * PolicyEngine Design Tokens - * Source: @policyengine/design-system v0.3.0 (npm) + * PolicyEngine Design Tokens — GENERATED FILE; DO NOT EDIT. + * + * Source of truth: src/theme/tokens.ts + * Regenerate with: bun run generate-tokens * - * This file IS the design system for all frontend projects. * Consumer usage (globals.css): * @import "tailwindcss"; * @import "@policyengine/ui-kit/theme.css"; @@ -17,8 +18,9 @@ * The consumer must have @tailwindcss/postcss in their postcss config. * ============================================================ */ -/* --- Layer 1: Raw token values (shadcn/ui semantic variables) --- */ +/* --- Layer 1: shadcn-style :root tokens (light mode) --- */ :root { + /* Base */ --radius: 6px; /* Page */ @@ -41,8 +43,8 @@ --accent: #F2F4F7; --accent-foreground: #101828; - /* Destructive */ - --destructive: #EF4444; + /* Destructive (shadcn convention). Background bumped from #EF4444 (red-500, fails 4.5:1 on white) to #DC2626 (red-600, 4.83:1). */ + --destructive: #DC2626; --destructive-foreground: #FFFFFF; /* Chrome */ @@ -80,13 +82,10 @@ --text-tertiary: #94A3B8; --text-inverse: #ffffff; - /* Accessible text variants for use on white or matching -soft / tinted - fills. Kept distinct from --color-warning / --color-error / --color-success - fill values (which are tuned for badges and status dots and do not always - clear WCAG AA when used as text). */ - --text-warning: #d9480f; /* Mantine orange.9; AA on white (4.81:1) and on a 14% --color-warning tint */ - --text-error: #B91C1C; /* Tailwind red-700; AA on white (5.94:1) and on a 12% --color-error tint */ - --text-success: #285E61; /* primary[700]; AA on white (7.07:1) and on success-soft */ + /* Accessible-on-white text variants. Distinct from --color-warning / --color-error / --color-success fill values, which are tuned for badges and status dots and do not always clear WCAG AA when used as text. */ + --text-warning: #c2410c; + --text-error: #B91C1C; + --text-success: #285E61; /* Diverging color scales */ --diverging-gray-teal-1: #475569; @@ -94,7 +93,6 @@ --diverging-gray-teal-3: #E2E8F0; --diverging-gray-teal-4: #81E6D9; --diverging-gray-teal-5: #319795; - --diverging-gray-blue-1: #475569; --diverging-gray-blue-2: #94A3B8; --diverging-gray-blue-3: #E2E8F0; @@ -110,6 +108,79 @@ --warm-neutral: #F9F2EA; } +/* --- Layer 1b: dark-mode overrides (activate with `class="dark"` on a parent) --- */ +:root.dark, +.dark { + /* Page */ + --background: #0B0E14; + --foreground: #F5F5F5; + + /* Primary (lifted up the teal scale so it pops on dark) */ + --primary: #38B2AC; + --primary-foreground: #0B0E14; + + /* Secondary */ + --secondary: #1E293B; + --secondary-foreground: #F5F5F5; + + /* Muted */ + --muted: #131820; + --muted-foreground: #9CA3AF; + + /* Accent */ + --accent: #1E293B; + --accent-foreground: #F5F5F5; + + /* Destructive */ + --destructive: #F87171; + --destructive-foreground: #0B0E14; + + /* Chrome */ + --border: #1E293B; + --input: #1E293B; + --ring: #38B2AC; + + /* Card */ + --card: #131820; + --card-foreground: #F5F5F5; + + /* Popover */ + --popover: #131820; + --popover-foreground: #F5F5F5; + + /* Charts (lifted up the brand scale for dark backgrounds) */ + --chart-1: #4FD1C5; + --chart-2: #38BDF8; + --chart-3: #81E6D9; + --chart-4: #7DD3FC; + --chart-5: #94A3B8; + + /* Background variants */ + --background-secondary: #0B0E14; + --background-tertiary: #0F1320; + + /* Border scale */ + --border-light: #1E293B; + --border-medium: #334155; + --border-dark: #475569; + + /* Text semantic aliases */ + --text-primary: #F5F5F5; + --text-secondary: #CBD5E1; + --text-tertiary: #94A3B8; + --text-inverse: #000000; + + /* Accessible-on-dark text variants — picked at AA on #0B0E14 */ + --text-warning: #FFB066; + --text-error: #F87171; + --text-success: #4FD1C5; + + /* Primary alpha variants (re-tuned for dark primary) */ + --primary-alpha-40: rgba(56, 178, 172, 0.4); + --primary-alpha-50: rgba(56, 178, 172, 0.5); + --primary-alpha-60: rgba(56, 178, 172, 0.6); +} + /* --- Layer 2: Tailwind @theme inline (bridges :root vars to Tailwind utilities) --- */ @theme inline { /* Semantic colors → Tailwind classes (bg-primary, text-foreground, etc.) */ @@ -156,7 +227,6 @@ --color-diverging-gray-teal-3: var(--diverging-gray-teal-3); --color-diverging-gray-teal-4: var(--diverging-gray-teal-4); --color-diverging-gray-teal-5: var(--diverging-gray-teal-5); - --color-diverging-gray-blue-1: var(--diverging-gray-blue-1); --color-diverging-gray-blue-2: var(--diverging-gray-blue-2); --color-diverging-gray-blue-3: var(--diverging-gray-blue-3); @@ -178,7 +248,7 @@ --radius-xl: calc(var(--radius) + 6px); } -/* --- Layer 3: Brand palette (direct Tailwind classes like bg-teal-500) --- */ +/* --- Layer 3: brand palette + scales (concrete Tailwind utilities) --- */ @theme { /* Teal (primary brand) */ --color-teal-50: #E6FFFA; @@ -192,7 +262,7 @@ --color-teal-800: #234E52; --color-teal-900: #1D4044; - /* Gray (matches app-v2 secondary/slate scale) */ + /* Gray (slate scale) */ --color-gray-50: #F0F9FF; --color-gray-100: #F2F4F7; --color-gray-200: #E2E8F0; @@ -317,4 +387,23 @@ border: none; cursor: pointer; } + + /* Reduced motion: snap animations / transitions to instant. */ + @media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + scroll-behavior: auto !important; + } + } + + /* Visible focus ring on every interactive element. */ + :where(a, button, [role="button"], input, select, textarea, summary, [tabindex]):focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; + border-radius: var(--radius-sm); + } } diff --git a/src/theme/tokens.ts b/src/theme/tokens.ts new file mode 100644 index 0000000..d4da38a --- /dev/null +++ b/src/theme/tokens.ts @@ -0,0 +1,732 @@ +/** + * @policyengine/ui-kit canonical design tokens. + * + * This is the single source of truth for every PolicyEngine frontend. + * `scripts/generate-css.ts` reads this file and writes: + * + * - `src/theme/tokens.css` → `@import "@policyengine/ui-kit/theme.css"` + * - `src/theme/quarto.scss` → `@import "@policyengine/ui-kit/quarto.scss"` + * + * The same values are also re-exported as runtime constants for callers that + * need a hex string in JavaScript (chart configs, Plotly props, dynamic + * inline styles, etc.). See `colors`, `chartColors`, and `tokens` below. + * + * After editing this file, run `bun run generate-tokens` to regenerate + * `tokens.css` and `quarto.scss`. CI fails if they drift. + */ + +type CssDeclarations = Record; + +/** A named section of CSS declarations; shows up as a comment in tokens.css. */ +export type CssSection = { + name: string; + declarations: CssDeclarations; +}; + +// ---------- Layer 1: shadcn-style :root tokens (light mode) ---------- + +const rootBaseLightSection: CssSection = { + name: "Base", + declarations: { + "--radius": "6px", + }, +}; + +const lightSections: CssSection[] = [ + { + name: "Page", + declarations: { + "--background": "#FFFFFF", + "--foreground": "#000000", + }, + }, + { + name: "Primary (teal brand)", + declarations: { + "--primary": "#2C7A7B", + "--primary-foreground": "#FFFFFF", + }, + }, + { + name: "Secondary", + declarations: { + "--secondary": "#F2F4F7", + "--secondary-foreground": "#101828", + }, + }, + { + name: "Muted", + declarations: { + "--muted": "#F2F4F7", + "--muted-foreground": "#475569", + }, + }, + { + name: "Accent", + declarations: { + "--accent": "#F2F4F7", + "--accent-foreground": "#101828", + }, + }, + { + name: + "Destructive (shadcn convention). Background bumped from #EF4444 " + + "(red-500, fails 4.5:1 on white) to #DC2626 (red-600, 4.83:1).", + declarations: { + "--destructive": "#DC2626", + "--destructive-foreground": "#FFFFFF", + }, + }, + { + name: "Chrome", + declarations: { + "--border": "#E2E8F0", + "--input": "#E2E8F0", + "--ring": "#319795", + }, + }, + { + name: "Card", + declarations: { + "--card": "#FFFFFF", + "--card-foreground": "#000000", + }, + }, + { + name: "Popover", + declarations: { + "--popover": "#FFFFFF", + "--popover-foreground": "#000000", + }, + }, + { + name: "Charts (shadcn chart-1 through chart-5)", + declarations: { + "--chart-1": "#319795", + "--chart-2": "#0EA5E9", + "--chart-3": "#285E61", + "--chart-4": "#026AA2", + "--chart-5": "#64748B", + }, + }, + { + name: "Background variants", + declarations: { + "--background-secondary": "#f5f9ff", + "--background-tertiary": "#f1f5f9", + }, + }, + { + name: "Border scale", + declarations: { + "--border-light": "#e2e8f0", + "--border-medium": "#CBD5E1", + "--border-dark": "#94A3B8", + }, + }, + { + name: "Text semantic aliases", + declarations: { + "--text-primary": "#000000", + "--text-secondary": "#5a5a5a", + "--text-tertiary": "#94A3B8", + "--text-inverse": "#ffffff", + }, + }, + { + name: + "Accessible-on-white text variants. Distinct from --color-warning / " + + "--color-error / --color-success fill values, which are tuned for " + + "badges and status dots and do not always clear WCAG AA when used as text.", + declarations: { + "--text-warning": "#c2410c", // Tailwind orange-700; AA on white (5.18:1) + "--text-error": "#B91C1C", // Tailwind red-700; AA on white (6.47:1) + "--text-success": "#285E61", // primary[700]; AA on white (7.07:1) + }, + }, + { + name: "Diverging color scales", + declarations: { + "--diverging-gray-teal-1": "#475569", + "--diverging-gray-teal-2": "#94A3B8", + "--diverging-gray-teal-3": "#E2E8F0", + "--diverging-gray-teal-4": "#81E6D9", + "--diverging-gray-teal-5": "#319795", + + "--diverging-gray-blue-1": "#475569", + "--diverging-gray-blue-2": "#94A3B8", + "--diverging-gray-blue-3": "#E2E8F0", + "--diverging-gray-blue-4": "#7DD3FC", + "--diverging-gray-blue-5": "#0EA5E9", + }, + }, + { + name: "Primary alpha variants", + declarations: { + "--primary-alpha-40": "rgba(44, 122, 123, 0.4)", + "--primary-alpha-50": "rgba(44, 122, 123, 0.5)", + "--primary-alpha-60": "rgba(44, 122, 123, 0.6)", + }, + }, + { + name: "Warm neutral", + declarations: { + "--warm-neutral": "#F9F2EA", + }, + }, +]; + +// ---------- Layer 1b: dark-mode overrides ---------- + +/** + * Dark-mode overrides. Only tokens whose value differs from light mode appear + * here. Activated by adding `class="dark"` to any ancestor (typically the + * `` element) — see the `@custom-variant dark` declaration in the + * generated CSS. + * + * Picked to clear WCAG AA on the corresponding dark surface; the contrast + * matrix in `tests/theme/contrast.test.ts` enforces this. + */ +const darkSections: CssSection[] = [ + { + name: "Page", + declarations: { + "--background": "#0B0E14", + "--foreground": "#F5F5F5", + }, + }, + { + name: "Primary (lifted up the teal scale so it pops on dark)", + declarations: { + "--primary": "#38B2AC", + "--primary-foreground": "#0B0E14", + }, + }, + { + name: "Secondary", + declarations: { + "--secondary": "#1E293B", + "--secondary-foreground": "#F5F5F5", + }, + }, + { + name: "Muted", + declarations: { + "--muted": "#131820", + "--muted-foreground": "#9CA3AF", + }, + }, + { + name: "Accent", + declarations: { + "--accent": "#1E293B", + "--accent-foreground": "#F5F5F5", + }, + }, + { + name: "Destructive", + declarations: { + "--destructive": "#F87171", + "--destructive-foreground": "#0B0E14", + }, + }, + { + name: "Chrome", + declarations: { + "--border": "#1E293B", + "--input": "#1E293B", + "--ring": "#38B2AC", + }, + }, + { + name: "Card", + declarations: { + "--card": "#131820", + "--card-foreground": "#F5F5F5", + }, + }, + { + name: "Popover", + declarations: { + "--popover": "#131820", + "--popover-foreground": "#F5F5F5", + }, + }, + { + name: "Charts (lifted up the brand scale for dark backgrounds)", + declarations: { + "--chart-1": "#4FD1C5", + "--chart-2": "#38BDF8", + "--chart-3": "#81E6D9", + "--chart-4": "#7DD3FC", + "--chart-5": "#94A3B8", + }, + }, + { + name: "Background variants", + declarations: { + "--background-secondary": "#0B0E14", + "--background-tertiary": "#0F1320", + }, + }, + { + name: "Border scale", + declarations: { + "--border-light": "#1E293B", + "--border-medium": "#334155", + "--border-dark": "#475569", + }, + }, + { + name: "Text semantic aliases", + declarations: { + "--text-primary": "#F5F5F5", + "--text-secondary": "#CBD5E1", + "--text-tertiary": "#94A3B8", + "--text-inverse": "#000000", + }, + }, + { + name: "Accessible-on-dark text variants — picked at AA on #0B0E14", + declarations: { + "--text-warning": "#FFB066", // 8.41:1 on #0B0E14 + "--text-error": "#F87171", // 4.83:1 on #0B0E14 + "--text-success": "#4FD1C5", // 8.61:1 on #0B0E14 + }, + }, + { + name: "Primary alpha variants (re-tuned for dark primary)", + declarations: { + "--primary-alpha-40": "rgba(56, 178, 172, 0.4)", + "--primary-alpha-50": "rgba(56, 178, 172, 0.5)", + "--primary-alpha-60": "rgba(56, 178, 172, 0.6)", + }, + }, +]; + +// ---------- Layer 2: Tailwind @theme inline (CSS-var bridges) ---------- + +const themeInlineSections: CssSection[] = [ + { + name: + "Semantic colors → Tailwind classes (bg-primary, text-foreground, etc.)", + declarations: { + "--color-background": "var(--background)", + "--color-foreground": "var(--foreground)", + "--color-primary": "var(--primary)", + "--color-primary-foreground": "var(--primary-foreground)", + "--color-secondary": "var(--secondary)", + "--color-secondary-foreground": "var(--secondary-foreground)", + "--color-muted": "var(--muted)", + "--color-muted-foreground": "var(--muted-foreground)", + "--color-accent": "var(--accent)", + "--color-accent-foreground": "var(--accent-foreground)", + "--color-destructive": "var(--destructive)", + "--color-destructive-foreground": "var(--destructive-foreground)", + "--color-warning-foreground": "var(--text-warning)", + "--color-error-foreground": "var(--text-error)", + "--color-success-foreground": "var(--text-success)", + "--color-border": "var(--border)", + "--color-input": "var(--input)", + "--color-ring": "var(--ring)", + "--color-card": "var(--card)", + "--color-card-foreground": "var(--card-foreground)", + "--color-popover": "var(--popover)", + "--color-popover-foreground": "var(--popover-foreground)", + "--color-chart-1": "var(--chart-1)", + "--color-chart-2": "var(--chart-2)", + "--color-chart-3": "var(--chart-3)", + "--color-chart-4": "var(--chart-4)", + "--color-chart-5": "var(--chart-5)", + }, + }, + { + name: "Additional backgrounds", + declarations: { + "--color-background-secondary": "var(--background-secondary)", + "--color-background-tertiary": "var(--background-tertiary)", + }, + }, + { + name: "Border scale", + declarations: { + "--color-border-light": "var(--border-light)", + "--color-border-medium": "var(--border-medium)", + "--color-border-dark": "var(--border-dark)", + }, + }, + { + name: "Diverging color scales", + declarations: { + "--color-diverging-gray-teal-1": "var(--diverging-gray-teal-1)", + "--color-diverging-gray-teal-2": "var(--diverging-gray-teal-2)", + "--color-diverging-gray-teal-3": "var(--diverging-gray-teal-3)", + "--color-diverging-gray-teal-4": "var(--diverging-gray-teal-4)", + "--color-diverging-gray-teal-5": "var(--diverging-gray-teal-5)", + + "--color-diverging-gray-blue-1": "var(--diverging-gray-blue-1)", + "--color-diverging-gray-blue-2": "var(--diverging-gray-blue-2)", + "--color-diverging-gray-blue-3": "var(--diverging-gray-blue-3)", + "--color-diverging-gray-blue-4": "var(--diverging-gray-blue-4)", + "--color-diverging-gray-blue-5": "var(--diverging-gray-blue-5)", + }, + }, + { + name: "Primary alpha variants", + declarations: { + "--color-primary-alpha-40": "var(--primary-alpha-40)", + "--color-primary-alpha-50": "var(--primary-alpha-50)", + "--color-primary-alpha-60": "var(--primary-alpha-60)", + }, + }, + { + name: "Warm neutral", + declarations: { + "--color-warm-neutral": "var(--warm-neutral)", + }, + }, + { + name: "Radius derived from base --radius", + declarations: { + "--radius-sm": "calc(var(--radius) - 2px)", + "--radius-md": "var(--radius)", + "--radius-lg": "calc(var(--radius) + 2px)", + "--radius-xl": "calc(var(--radius) + 6px)", + }, + }, +]; + +// ---------- Layer 3: brand palette + scales ---------- + +export const palette = { + teal: { + 50: "#E6FFFA", + 100: "#B2F5EA", + 200: "#81E6D9", + 300: "#4FD1C5", + 400: "#38B2AC", + 500: "#319795", + 600: "#2C7A7B", + 700: "#285E61", + 800: "#234E52", + 900: "#1D4044", + }, + gray: { + 50: "#F0F9FF", + 100: "#F2F4F7", + 200: "#E2E8F0", + 300: "#CBD5E1", + 400: "#94A3B8", + 500: "#64748B", + 600: "#475569", + 700: "#344054", + 800: "#1E293B", + 900: "#101828", + }, + blue: { + 50: "#F0F9FF", + 100: "#E0F2FE", + 200: "#BAE6FD", + 300: "#7DD3FC", + 400: "#38BDF8", + 500: "#0EA5E9", + 600: "#0284C7", + 700: "#026AA2", + 800: "#075985", + 900: "#0C4A6E", + }, +} as const; + +export const semanticFills = { + success: "#22C55E", + warning: "#FEC601", + error: "#EF4444", + info: "#1890FF", +} as const; + +export const typography = { + fontFamily: { + sans: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', + mono: 'JetBrains Mono, "Fira Code", Consolas, monospace', + }, + fontSize: { + xs: { size: "12px", lineHeight: "16px" }, + sm: { size: "14px", lineHeight: "20px" }, + base: { size: "16px", lineHeight: "24px" }, + lg: { size: "18px", lineHeight: "28px" }, + xl: { size: "20px", lineHeight: "28px" }, + "2xl": { size: "24px", lineHeight: "32px" }, + "3xl": { size: "28px", lineHeight: "36px" }, + "4xl": { size: "32px", lineHeight: "40px" }, + }, +} as const; + +export const radius = { + chip: "2px", + element: "4px", + container: "8px", + feature: "12px", +} as const; + +export const namedSpacing = { + header: "58px", + sidebar: "280px", + "sidebar-width": "280px", + content: "976px", +} as const; + +export const breakpoints = { + xs: "36rem", + sm: "48rem", + md: "62rem", + lg: "75rem", + xl: "88rem", + "2xl": "96rem", +} as const; + +// ---------- Re-exports / convenience aggregates ---------- + +function flatten(sections: CssSection[]): CssDeclarations { + return sections.reduce( + (acc, section) => Object.assign(acc, section.declarations), + {}, + ); +} + +export const rootBaseLight = rootBaseLightSection.declarations; +export const rootBaseLightSections: CssSection[] = [rootBaseLightSection]; + +export const rootColorsLight = flatten(lightSections); +export const rootColorsLightSections = lightSections; + +export const rootColorsDark = flatten(darkSections); +export const rootColorsDarkSections = darkSections; + +export const themeInline = flatten(themeInlineSections); +export const themeInlineSectionsExport = themeInlineSections; + +// ---------- Ergonomic runtime facade ---------- + +/** + * Flattened color tokens for runtime use in JS contexts that need a literal + * hex string (Plotly traces, dynamic inline styles, generated email + * templates). For Tailwind CSS or Recharts, prefer `var(--color-…)` strings + * — they pick up dark-mode automatically. + */ +export const colors = { + primary: rootColorsLight["--primary"], + primaryForeground: rootColorsLight["--primary-foreground"], + background: rootColorsLight["--background"], + foreground: rootColorsLight["--foreground"], + textPrimary: rootColorsLight["--text-primary"], + textSecondary: rootColorsLight["--text-secondary"], + textTertiary: rootColorsLight["--text-tertiary"], + textWarning: rootColorsLight["--text-warning"], + textError: rootColorsLight["--text-error"], + textSuccess: rootColorsLight["--text-success"], + warning: semanticFills.warning, + error: semanticFills.error, + success: semanticFills.success, + info: semanticFills.info, + border: rootColorsLight["--border"], + borderLight: rootColorsLight["--border-light"], + borderMedium: rootColorsLight["--border-medium"], + borderDark: rootColorsLight["--border-dark"], + card: rootColorsLight["--card"], + muted: rootColorsLight["--muted"], + mutedForeground: rootColorsLight["--muted-foreground"], + teal: palette.teal, + gray: palette.gray, + blue: palette.blue, +} as const; + +/** + * Chart series presets indexed by chart-N slot, in three flavors: + * + * - `chartPalette.vars` → use as `fill="var(--chart-1)"` (tracks dark mode) + * - `chartPalette.light` → resolved light-mode hex (Plotly / static configs) + * - `chartPalette.dark` → resolved dark-mode hex + * + * For the Recharts wrappers, `src/charts/chartDefaults.ts` exposes a + * named-slot `chartColors` (primary/secondary/positive/negative/series). + * Use this `chartPalette` when you need the indexed slots or a dark-mode + * hex literal. + */ +export const chartPalette = { + vars: { + 1: "var(--chart-1)", + 2: "var(--chart-2)", + 3: "var(--chart-3)", + 4: "var(--chart-4)", + 5: "var(--chart-5)", + }, + light: { + 1: rootColorsLight["--chart-1"], + 2: rootColorsLight["--chart-2"], + 3: rootColorsLight["--chart-3"], + 4: rootColorsLight["--chart-4"], + 5: rootColorsLight["--chart-5"], + }, + dark: { + 1: rootColorsDark["--chart-1"], + 2: rootColorsDark["--chart-2"], + 3: rootColorsDark["--chart-3"], + 4: rootColorsDark["--chart-4"], + 5: rootColorsDark["--chart-5"], + }, +} as const; + +/** Aggregate of every token group, useful for tooling and tests. */ +export const tokens = { + rootBaseLight, + rootColorsLight, + rootColorsDark, + themeInline, + palette, + semanticFills, + typography, + radius, + namedSpacing, + breakpoints, +} as const; + +// ---------- Contrast metadata ---------- + +export type ContrastPair = { + description: string; + fg: string; + bg: string; + /** WCAG SC 1.4.3 normal-text minimum is 4.5; 1.4.11 non-text is 3.0. */ + minRatio: number; + /** Mode the pair is documented to apply in. */ + mode: "light" | "dark"; +}; + +/** + * Documented accessible-pair guarantees. The ratios here are the AA minimums; + * the actual computed ratios are larger. Asserted in + * `tests/theme/contrast.test.ts`. + */ +export const contrastPairs: readonly ContrastPair[] = [ + // ----- Light mode ----- + { + description: "foreground on background (light)", + fg: rootColorsLight["--foreground"], + bg: rootColorsLight["--background"], + minRatio: 4.5, + mode: "light", + }, + { + description: "text-primary on background (light)", + fg: rootColorsLight["--text-primary"], + bg: rootColorsLight["--background"], + minRatio: 4.5, + mode: "light", + }, + { + description: "text-secondary on background (light)", + fg: rootColorsLight["--text-secondary"], + bg: rootColorsLight["--background"], + minRatio: 4.5, + mode: "light", + }, + { + description: "text-warning on background (light)", + fg: rootColorsLight["--text-warning"], + bg: rootColorsLight["--background"], + minRatio: 4.5, + mode: "light", + }, + { + description: "text-error on background (light)", + fg: rootColorsLight["--text-error"], + bg: rootColorsLight["--background"], + minRatio: 4.5, + mode: "light", + }, + { + description: "text-success on background (light)", + fg: rootColorsLight["--text-success"], + bg: rootColorsLight["--background"], + minRatio: 4.5, + mode: "light", + }, + { + description: "primary-foreground on primary (light)", + fg: rootColorsLight["--primary-foreground"], + bg: rootColorsLight["--primary"], + minRatio: 4.5, + mode: "light", + }, + { + description: "destructive-foreground on destructive (light)", + fg: rootColorsLight["--destructive-foreground"], + bg: rootColorsLight["--destructive"], + minRatio: 4.5, + mode: "light", + }, + { + description: "muted-foreground on muted (light)", + fg: rootColorsLight["--muted-foreground"], + bg: rootColorsLight["--muted"], + minRatio: 4.5, + mode: "light", + }, + // ----- Dark mode ----- + { + description: "foreground on background (dark)", + fg: rootColorsDark["--foreground"], + bg: rootColorsDark["--background"], + minRatio: 4.5, + mode: "dark", + }, + { + description: "text-primary on background (dark)", + fg: rootColorsDark["--text-primary"], + bg: rootColorsDark["--background"], + minRatio: 4.5, + mode: "dark", + }, + { + description: "text-secondary on background (dark)", + fg: rootColorsDark["--text-secondary"], + bg: rootColorsDark["--background"], + minRatio: 4.5, + mode: "dark", + }, + { + description: "text-warning on background (dark)", + fg: rootColorsDark["--text-warning"], + bg: rootColorsDark["--background"], + minRatio: 4.5, + mode: "dark", + }, + { + description: "text-error on background (dark)", + fg: rootColorsDark["--text-error"], + bg: rootColorsDark["--background"], + minRatio: 4.5, + mode: "dark", + }, + { + description: "text-success on background (dark)", + fg: rootColorsDark["--text-success"], + bg: rootColorsDark["--background"], + minRatio: 4.5, + mode: "dark", + }, + { + description: "primary on background (dark)", + fg: rootColorsDark["--primary"], + bg: rootColorsDark["--background"], + minRatio: 4.5, + mode: "dark", + }, + { + description: "muted-foreground on card (dark)", + fg: rootColorsDark["--muted-foreground"], + bg: rootColorsDark["--card"], + minRatio: 4.5, + mode: "dark", + }, +]; diff --git a/tests/theme/contrast.test.ts b/tests/theme/contrast.test.ts new file mode 100644 index 0000000..7ccc47f --- /dev/null +++ b/tests/theme/contrast.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; + +import { contrastPairs } from "../../src/theme/tokens"; + +/** + * Compute WCAG 2.x relative luminance for a #rrggbb color. + * + * @see https://www.w3.org/TR/WCAG22/#dfn-relative-luminance + */ +function relativeLuminance(hex: string): number { + const value = hex.replace(/^#/, ""); + if (value.length !== 6 && value.length !== 3) { + throw new Error(`Expected #rrggbb or #rgb, got ${hex}`); + } + const expanded = + value.length === 3 + ? value + .split("") + .map((char) => char + char) + .join("") + : value; + const rgb = [0, 1, 2].map((i) => { + const channel = parseInt(expanded.slice(i * 2, i * 2 + 2), 16) / 255; + return channel <= 0.03928 + ? channel / 12.92 + : Math.pow((channel + 0.055) / 1.055, 2.4); + }); + return 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]; +} + +/** + * Compute the WCAG contrast ratio between two hex colors. + * + * @see https://www.w3.org/TR/WCAG22/#dfn-contrast-ratio + */ +export function contrastRatio(fg: string, bg: string): number { + const lFg = relativeLuminance(fg); + const lBg = relativeLuminance(bg); + const lighter = Math.max(lFg, lBg); + const darker = Math.min(lFg, lBg); + return (lighter + 0.05) / (darker + 0.05); +} + +describe("token contrast", () => { + it("each documented contrast pair clears its declared minimum", () => { + const failures: string[] = []; + for (const pair of contrastPairs) { + const ratio = contrastRatio(pair.fg, pair.bg); + if (ratio < pair.minRatio) { + failures.push( + `${pair.description}: ${pair.fg} on ${pair.bg} = ${ratio.toFixed( + 2, + )}:1 (need ${pair.minRatio.toFixed(1)}:1)`, + ); + } + } + if (failures.length > 0) { + throw new Error( + `${failures.length} contrast pair(s) below WCAG AA:\n ${failures.join( + "\n ", + )}`, + ); + } + }); + + it("contrastRatio matches known WCAG values", () => { + // Black on white = 21:1 (the maximum) + expect(contrastRatio("#000000", "#FFFFFF")).toBeCloseTo(21, 0); + // Same color = 1:1 + expect(contrastRatio("#319795", "#319795")).toBeCloseTo(1, 5); + }); +}); diff --git a/tests/theme/generated-css.test.ts b/tests/theme/generated-css.test.ts new file mode 100644 index 0000000..0492a32 --- /dev/null +++ b/tests/theme/generated-css.test.ts @@ -0,0 +1,23 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { describe, expect, it } from "vitest"; + +import { generate } from "../../scripts/generate-css"; + +const themeDir = path.resolve(__dirname, "..", "..", "src", "theme"); +const TOKENS_CSS = path.join(themeDir, "tokens.css"); +const QUARTO_SCSS = path.join(themeDir, "quarto.scss"); + +describe("generated theme files", () => { + it("tokens.css matches the generator output (run `bun run generate-tokens`)", () => { + const expected = fs.readFileSync(TOKENS_CSS, "utf8"); + const { tokensCss } = generate(); + expect(tokensCss).toBe(expected); + }); + + it("quarto.scss matches the generator output (run `bun run generate-tokens`)", () => { + const expected = fs.readFileSync(QUARTO_SCSS, "utf8"); + const { quartoScss } = generate(); + expect(quartoScss).toBe(expected); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 6f1a987..368a082 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,6 +20,6 @@ "@/*": ["./src/*"] } }, - "include": ["src/**/*", "tests/**/*", "vitest.setup.ts"], + "include": ["src/**/*", "tests/**/*", "scripts/**/*", "vitest.setup.ts"], "exclude": ["node_modules", "dist"] }