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
1 change: 1 addition & 0 deletions changelog.d/changed-destructive-darker.changed.md
Original file line number Diff line number Diff line change
@@ -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).
1 change: 1 addition & 0 deletions changelog.d/changed-text-warning-darker.changed.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions changelog.d/feat-canonical-ts-tokens.added.md
Original file line number Diff line number Diff line change
@@ -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`.
1 change: 1 addition & 0 deletions changelog.d/feat-contrast-matrix.added.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions changelog.d/feat-dark-mode-tokens.added.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions changelog.d/feat-focus-ring-and-reduced-motion.added.md
Original file line number Diff line number Diff line change
@@ -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`.
1 change: 1 addition & 0 deletions changelog.d/feat-quarto-theme.added.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 11 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand All @@ -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",
Expand Down Expand Up @@ -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"
Expand Down
332 changes: 332 additions & 0 deletions scripts/generate-css.ts
Original file line number Diff line number Diff line change
@@ -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, string>): 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();
}
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down
Loading