diff --git a/apps/eds-color-palette-generator/ABOUT_PAGE.md b/apps/eds-color-palette-generator/ABOUT_PAGE.md index 48b2e5c6fc..581f8cde5f 100644 --- a/apps/eds-color-palette-generator/ABOUT_PAGE.md +++ b/apps/eds-color-palette-generator/ABOUT_PAGE.md @@ -23,7 +23,7 @@ The page explains: ### 2. Interactive Bell Curve Visualization -The `BellCurveVisualization` component (`src/components/BellCurveVisualization.tsx`) provides: +The `BellCurveVisualization` component (`src/components/docs/BellCurveVisualization.tsx`) provides: - Visual representation of the Gaussian function - Interactive controls for mean and standard deviation @@ -33,7 +33,7 @@ The `BellCurveVisualization` component (`src/components/BellCurveVisualization.t ### 3. Interactive Chroma Distribution Demo -The `ChromaDistributionDemo` component (`src/components/ChromaDistributionDemo.tsx`) provides: +The `ChromaDistributionDemo` component (`src/components/docs/ChromaDistributionDemo.tsx`) provides: - Color picker to select any base color - Interactive controls for Gaussian parameters (mean and standard deviation) @@ -43,7 +43,7 @@ The `ChromaDistributionDemo` component (`src/components/ChromaDistributionDemo.t ### 4. Color Step Pairings and Contrast Requirements -The `ContrastRequirementsTable` component (`src/components/ContrastRequirementsTable.tsx`) provides: +The `ContrastRequirementsTable` component (`src/components/docs/ContrastRequirementsTable.tsx`) provides: - Comprehensive list of all color steps with contrast requirements - Direct reference to configuration file for always up-to-date information @@ -65,7 +65,7 @@ The `ContrastRequirementsTable` component (`src/components/ContrastRequirementsT ### BellCurveVisualization -**File:** `src/components/BellCurveVisualization.tsx` +**File:** `src/components/docs/BellCurveVisualization.tsx` A client-side component that visualizes the Gaussian function used to calculate chroma multipliers. @@ -84,7 +84,7 @@ A client-side component that visualizes the Gaussian function used to calculate ### ChromaDistributionDemo -**File:** `src/components/ChromaDistributionDemo.tsx` +**File:** `src/components/docs/ChromaDistributionDemo.tsx` A client-side component that demonstrates how chroma varies across a color scale. @@ -104,7 +104,7 @@ A client-side component that demonstrates how chroma varies across a color scale ### ContrastRequirementsTable -**File:** `src/components/ContrastRequirementsTable.tsx` +**File:** `src/components/docs/ContrastRequirementsTable.tsx` A client-side component that displays color step pairings and their contrast requirements, directly referencing the configuration file. @@ -122,10 +122,10 @@ A client-side component that displays color step pairings and their contrast req The About page is accessible from the main page via: -- An "About" link in the header (added to `HeaderPanel` component) +- An "About" link in the header (added to the `HeaderPanel` component, now at `/old`) - Direct URL navigation to `/about` -A "Back to generator" link is provided on the About page for easy navigation back to the main tool. +A "Back to generator" link is provided on the About page, pointing at the archived Gaussian generator at `/old` (the tool this page documents). ## Technical Implementation diff --git a/apps/eds-color-palette-generator/README.md b/apps/eds-color-palette-generator/README.md index e20032fd57..e9d7b9d050 100644 --- a/apps/eds-color-palette-generator/README.md +++ b/apps/eds-color-palette-generator/README.md @@ -1,6 +1,13 @@ # EDS Color Palette Generator -An accessible color palette generator for the Equinor Design System. This tool creates harmonious color scales using Gaussian distribution and the OKLCH color space, ensuring consistent, accessible colors across different lightness levels. +Internal tooling for building accessible colour themes and palettes for the Equinor Design System. + +The project hosts two tools in one Next.js App Router app: + +- **Theme Builder** (`/`, the primary tool) — build accessible colour themes and palettes, preview them on example components, and check contrast. Palette state is shareable via the URL. +- **Colour Palette Generator** (`/old`, archived) — the original Gaussian colour-scale generator. It creates harmonious colour scales using Gaussian distribution and the OKLCH colour space, ensuring consistent, accessible colours across different lightness levels. Still fully functional but no longer the default entry point. + +> The `/themebuilder` route now redirects to `/`. ## Features @@ -24,9 +31,9 @@ First, run the development server: pnpm dev ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the color palette generator. +Open [http://localhost:3000](http://localhost:3000) with your browser to see the Theme Builder (the primary tool). -To learn how the generator works internally, visit the About page at [http://localhost:3000/about](http://localhost:3000/about). +The archived Gaussian colour-scale generator is available at [http://localhost:3000/old](http://localhost:3000/old). To learn how that generator works internally, visit the About page at [http://localhost:3000/about](http://localhost:3000/about). ### CLI Tool diff --git a/apps/eds-color-palette-generator/next.config.ts b/apps/eds-color-palette-generator/next.config.ts index c063b651f1..2fe936f50c 100644 --- a/apps/eds-color-palette-generator/next.config.ts +++ b/apps/eds-color-palette-generator/next.config.ts @@ -3,6 +3,12 @@ import type { NextConfig } from 'next' const nextConfig: NextConfig = { /* config options here */ devIndicators: false, + async redirects() { + // The theme builder used to live at /themebuilder and its Share button + // still emits /themebuilder?... links. Next.js preserves the query string. + // Temporary (307) while the app is still evolving. + return [{ source: '/themebuilder', destination: '/', permanent: false }] + }, } export default nextConfig diff --git a/apps/eds-color-palette-generator/package.json b/apps/eds-color-palette-generator/package.json index a82bbe13c5..5e87a82f62 100644 --- a/apps/eds-color-palette-generator/package.json +++ b/apps/eds-color-palette-generator/package.json @@ -29,28 +29,27 @@ "@equinor/eds-tokens": "workspace:^", "@equinor/eds-utils": "workspace:^", "colorjs.io": "^0.6.1", - "lucide-react": "^1.21.0", - "next": "16.2.9", - "react": "^19.2.6", - "react-dom": "^19.2.6" + "lucide-react": "^1.12.0", + "next": "16.2.4", + "react": "^19.2.4", + "react-dom": "^19.2.4" }, "devDependencies": { "@eslint/eslintrc": "^3.3.5", - "@microsoft/api-extractor": "^7.58.9", - "@next/eslint-plugin-next": "16.2.9", - "@playwright/test": "^1.61.1", - "@tailwindcss/postcss": "^4.3.1", + "@next/eslint-plugin-next": "16.2.4", + "@playwright/test": "^1.59.1", + "@tailwindcss/postcss": "^4.2.4", "@types/node": "^25.5.2", - "@types/react": "^19.2.15", + "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "dotenv": "^17.4.0", "eslint": "^9.38.0", - "eslint-config-next": "16.2.9", - "tailwindcss": "^4.3.1", - "tsx": "^4.22.4", + "eslint-config-next": "16.2.4", + "tailwindcss": "^4.2.4", + "tsx": "^4.21.0", "typescript": "^5.9.3", - "vite": "^8.1.0", - "vite-plugin-dts": "^5.0.3", - "vitest": "^4.1.9" + "vite": "^8.0.10", + "vite-plugin-dts": "^4.5.4", + "vitest": "^4.1.5" } } diff --git a/apps/eds-color-palette-generator/plan/fix-lint-failures.prompt.md b/apps/eds-color-palette-generator/plan/fix-lint-failures.prompt.md new file mode 100644 index 0000000000..89b259dd92 --- /dev/null +++ b/apps/eds-color-palette-generator/plan/fix-lint-failures.prompt.md @@ -0,0 +1,136 @@ +# Prompt: Fix the lint failures in the Color Palette Generator app + +> Paste this into a fresh Claude Code session (or hand it to an agent) run from the +> repo root. It is scoped entirely to `apps/eds-color-palette-generator`. +> Read `AGENTS.md` and `.claude/CLAUDE.md` first — repo conventions apply. + +--- + +## Role + +You are fixing ESLint failures in the Next.js app at `apps/eds-color-palette-generator`. +`pnpm --filter @equinor/eds-color-palette-generator lint` currently reports **6 errors + +1 warning**. Type-check (`types`) and unit tests (`test:run`) are already green, and the +theme-builder-to-root restructure is complete. Your job is to get lint clean **without +changing behaviour** and without regressing types or tests. + +## Context (verify before you touch anything) + +These failures are **pre-existing** — they were introduced with the theme builder in +commit `a71f92b1` and only surfaced now because that code was promoted to `/`. Two of the +erroring files (`src/app/palette/page.tsx`, `src/components/themebuilder/ComponentPreviewPanel.tsx`) +are unchanged vs `HEAD`, confirming this is not fallout from the restructure. + +The rules come from `eslint-config-next/core-web-vitals` (React Compiler / `react-hooks` +recommended-latest under Next 16 + React 19), configured in `eslint.config.mjs`. They are +upstream, not custom — **fix the code, do not disable rules** unless a case is genuinely a +false positive (see the localStorage note below), and then only with a one-line +justification comment. + +## The failures (re-run lint to confirm line numbers before editing) + +| # | File:line | Rule | Root cause | +| - | --------- | ---- | ---------- | +| 1 | `src/components/themebuilder/ContrastTable.tsx:42` | `react-hooks/rules-of-hooks` | `useMemo` is called **after** the `if (!palette) return null` early-return on line 40 | +| 2 | `src/components/themebuilder/ComponentPreviewPanel.tsx:37` | `react-hooks/rules-of-hooks` | `useState` is called **after** the `if (palettes.length === 0) return null` early-return on line 20 | +| 3 | `src/components/themebuilder/contrast/DataColorChart.tsx:167` | `react-hooks/immutability` | `cumulative += pct` mutates a render-scope variable during `.map()` in JSX | +| 4 | `src/app/page.tsx:72` | react-hooks (setState-in-effect) | effect resets `contrastPaletteIndex` to 0 when the palette list shrinks | +| 5 | `src/app/example/page.tsx:560` | react-hooks (setState-in-effect) | mount effect calls `setCustomPalettes(getSimulationPalettes())` | +| 6 | `src/app/palette/page.tsx:78` | react-hooks (setState-in-effect) | run-once auto-import effect calls `setHasAutoImported(true)` + `setPalettes(...)` | +| 7 | `src/app/palette/page.tsx:3` | `@typescript-eslint/no-unused-vars` (warning) | `useMemo` imported but never used | + +## Recommended fixes (confirm each against the actual code; adapt if it drifted) + +### 1 & 2 — hooks after an early return (`rules-of-hooks`) + +Hooks must run unconditionally, in the same order, on every render. Move every hook call +**above** the early `return null`. + +- **`ContrastTable.tsx`** — move the `grid` `useMemo` above the `if (!palette) return null` + guard and make it null-safe, e.g. `useMemo(() => { if (!palette) return []; return FG_INDICES.map(...) }, [palette])`, + then keep `if (!palette) return null` after the hook. +- **`ComponentPreviewPanel.tsx`** — the `accentIdx` `useState` initializer depends on + `neutralIdx` / `accentCandidates`, which are derived after the guard. Preserve the current + default (first non-gray palette) by using a **lazy initializer** — `useState(() => …)` — that + computes the default safely even when `palettes` is empty, then move `if (palettes.length === 0) return null` + to run after the hook. Extract the neutral-finding logic into a module-scope helper (per + repo convention: helpers at module scope) so the initializer and the render body don't + duplicate it. + +### 3 — mutation during render (`immutability`) + +`DataColorChart.tsx`'s `DonutChart` accumulates `cumulative` while mapping. Precompute the +segments **before** the JSX with a single pass (e.g. build an array of `{ pct, offset }` via +`reduce`/`map` at the top of the component), then render from that array — no reassignment +inside the JSX `.map()`. + +### 4 — clamp during render instead of in an effect + +`src/app/page.tsx` uses an effect to reset `contrastPaletteIndex` when `palettes` shrinks. +This is the classic "adjusting state during render" case — derive a clamped value during +render and drop the effect: + +```ts +const safeContrastIndex = + contrastPaletteIndex >= palettes.length ? 0 : contrastPaletteIndex +``` + +Use `safeContrastIndex` at every read site. Only keep `setContrastPaletteIndex` for genuine +user-driven changes. Remove the now-dead effect. + +### 5 & 6 — setState in a mount effect (client-only hydration) + +Both call setState from an effect to hydrate client-only data. **Inspect the data source +first**, because the right fix differs: + +- If the source is pure/deterministic and safe during SSR (no `window`/`localStorage`), + convert to a **lazy `useState` initializer** and delete the effect — + `useState(() => getSimulationPalettes())`. +- If it reads `localStorage`/`window` (e.g. `palette/page.tsx`'s auto-import via + `localStorageUtils`, and check whether `getSimulationPalettes` does too), a lazy initializer + would run during SSR and break hydration. Prefer **`useSyncExternalStore`** (server snapshot + returns the default, client snapshot returns the stored value) — the idiomatic SSR-safe + pattern. If that is disproportionate for a one-shot import, keep the effect but add a + narrowly-scoped `// eslint-disable-next-line …` with a one-line comment explaining it is + intentional client-only hydration. Do not blanket-disable the rule for the file. + +Whichever you pick, the on-screen result must be identical to today. + +### 7 — unused import (warning) + +Remove `useMemo` from the import on `src/app/palette/page.tsx:3` (only if fix #6 doesn't +reintroduce a use for it). + +## Constraints (non-negotiable) + +- Follow `AGENTS.md` / `.claude/CLAUDE.md`. Match existing style (2-space, no semicolons, + single quotes, named exports where applicable, `import type`). The format hook runs + eslint/prettier `--fix` on save; still eyeball the diff. +- **Behaviour-preserving only.** These are lint fixes, not a refactor. Do not change what any + tool renders or how it behaves. If a "fix" would alter visible behaviour, stop and flag it. +- **Ask before any git action** — no commits, branches, pushes, or PRs without explicit + approval. **Never** add AI attribution / "Co-authored-by". +- Keep changes minimal and local to the seven sites above. Don't opportunistically rewrite + surrounding code. + +## Verification (all must pass) + +```bash +pnpm --filter @equinor/eds-color-palette-generator lint # 0 errors, 0 warnings +pnpm --filter @equinor/eds-color-palette-generator types # clean +pnpm --filter @equinor/eds-color-palette-generator test:run # green (was 126/126) +``` + +Then drive the app (`pnpm --filter @equinor/eds-color-palette-generator dev`, or the `/run` +skill) and confirm no behaviour changed and no new console errors / hydration warnings on: + +- `/` (theme builder) — tab switching, palette add/remove (exercises fix #4), contrast table + (#1), component preview role selector (#2), the data-colour donut (#3) +- `/example` (#5) and `/palette` (#6) — data loads on first paint exactly as before + +## Report format + +Summarise: (a) each error → the fix applied and why, (b) any case where you chose +`useSyncExternalStore` or a justified disable over a lazy initializer and the reasoning, +(c) verification results (each command's pass/fail), (d) anything you deliberately left +untouched. diff --git a/apps/eds-color-palette-generator/plan/light-dark-parity-comparison.md b/apps/eds-color-palette-generator/plan/light-dark-parity-comparison.md new file mode 100644 index 0000000000..c010febd52 --- /dev/null +++ b/apps/eds-color-palette-generator/plan/light-dark-parity-comparison.md @@ -0,0 +1,363 @@ +# Light / Dark Parity — Color Generator vs Figma + +Comparison of the color palette generator's light and dark themes against the variables in the +Figma file [`Test` (6KTCpIisRSBfWnQBvW1poE)](https://www.figma.com/design/6KTCpIisRSBfWnQBvW1poE/Test). + +**Scope:** light vs dark parity — does every token have consistent light and dark values across +the generator and Figma. **Analysis only, no source changes.** + +Date: 2026-07-10. + +--- + +## 1. Summary / verdict + +The generator renders its previews from a hardcoded semantic-token map (`src/config/semanticColors.ts`) +that was generated from the Figma **"Color Map"** sections — `Color Map Light` (node `73:314`) and +`Color Map Dark` (node `73:455`). So this is effectively a **drift check** between the code and its +Figma source. + +**Result:** **Light** tokens match the Figma Color Map. **Dark** now follows the **tonal-inversion +rule** — the generator's own OKLCH ramp output in dark mode (light accent fills, pure-gray neutrals), +**not** the fixed-`#206f77` Figma dark board (`132:23906`). This is the adopted standard (§4a); +applied in `semanticColors.ts` (`DARK`) and `globals.css` (page background). Remaining item: + +- **3 light values Figma omits** that the code hand-fills (documented in code comments — Figma-side gap, §5). + +> **Decision 2026-07-10 (current):** dark = **tonal inversion**, sourced from the generator's OKLCH +> engine. Accent emphasis `#8cd2da` (light teal, was `#206f77`), neutral canvas `#0b0b0b`, surface +> `#222222` (brighter than canvas → correct elevation), primary text `#fcfcfc`, accent-button label +> `#030303` (dark on light fill). Chosen over the board (`132:23906`) for **accessibility** (the +> board's `#206f77` fill fails WCAG 1.4.11 on dark; tonal passes at 9–12:1) and **consistency** (the +> engine already emits these values; nothing shipped constrains us). Full rationale + contrast +> numbers in §4a. Nothing is shipped — this is the rule going forward. +> +> This **supersedes** the earlier board-alignment (§4, kept for history) which set canvas `#222222`, +> surface `#1a1a1a`, accent `#206f77`, text `#e1e1e1`. + +Both the code and Figma have **full light/dark parity for every token the code imports**. The +notable parity issue lives **on the Figma side**: 3 tokens are defined in the Dark map but missing +from the Light map (§5). Figma is also **internally inconsistent in dark** — the board `132:23906` +and `Color Map Dark` (`73:455`) disagree on ~20 tokens; the code now sidesteps that by following the +engine directly (§4a). + +--- + +## 2. Method & data sources + +| Side | Source | +|---|---| +| Generator light/dark | `src/config/semanticColors.ts` — `LIGHT` / `DARK` maps (50 tokens each, identical key sets) | +| Generator ramps | `src/config/config.ts` (`PALETTE_STEPS`, per-step `lightValue`/`darkValue`); seeds in `src/config/palette.ts` | +| Figma authoritative | `get_variable_defs` on `73:314` (`Color Map Light`) and `73:455` (`Color Map Dark`) | +| Figma secondary | Status Message variants `22:32/35/38/41/44`; Light/Dark instances `1:2907–2917` | + +The Figma Color Map defines ~90+ color tokens (≈91 light / ≈94 dark). The code imports a curated +subset of 50 (§5). + +--- + +## 3. Full token-by-token comparison (the 50 imported tokens) + +Legend: ✅ match · 🎨 intentional deviation (documented) · ⚠️ Figma light value absent (code hand-fills) + +### Neutral surfaces + +| Token | Code L | Figma L | L | Code D | Figma D | D | +|---|---|---|---|---|---|---| +| `bg-neutral-canvas` | `#f5f5f5` | `#f5f5f5` | ✅ | `#141414` | `#0b0b0b` | 🎨 | +| `bg-neutral-surface` | `#ffffff` | `#ffffff` | ✅ | `#1c1c1c` | `#202223` | 🎨 | +| `bg-neutral-fill-muted-default` | `#e1e1e1` | `#e1e1e1` | ✅ | `#525c65` | `#525c65` | ✅ | +| `bg-neutral-fill-muted-hover` | `#d4d4d4` | `#d4d4d4` | ✅ | `#5d6b76` | `#5d6b76` | ✅ | +| `bg-neutral-fill-muted-active` | `#c4c4c4` | `#c4c4c4` | ✅ | `#6b7d8b` | `#6b7d8b` | ✅ | +| `bg-neutral-fill-emphasis-default` | `#636363` | `#636363` | ✅ | `#b4c7d7` | `#b4c7d7` | ✅ | +| `bg-neutral-fill-emphasis-hover` | `#525252` | `#525252` | ✅ | `#cadae7` | `#cadae7` | ✅ | +| `bg-neutral-fill-emphasis-active` | `#4d4d4d` | `#4d4d4d` | ✅ | `#deeaf4` | `#deeaf4` | ✅ | +| `bg-floating` | `#ffffff` | `#ffffff` | ✅ | `#202223` | `#202223` | ✅ | +| `bg-backdrop` | `#aeaeae` | `#aeaeae` | ✅ | `#738696` | `#738696` | ✅ | + +### Accent surfaces + +| Token | Code L | Figma L | L | Code D | Figma D | D | +|---|---|---|---|---|---|---| +| `bg-accent-canvas` | `#eaf8fa` | `#eaf8fa` | ✅ | `#0a0b0b` | `#0a0b0b` | ✅ | +| `bg-accent-surface` | `#f6ffff` | `#f6ffff` | ✅ | `#1e2323` | `#1e2323` | ✅ | +| `bg-accent-fill-muted-default` | `#cfe7e9` | `#cfe7e9` | ✅ | `#3c6266` | `#3c6266` | ✅ | +| `bg-accent-fill-muted-hover` | `#bbdbdf` | `#bbdbdf` | ✅ | `#3e7378` | `#3e7378` | ✅ | +| `bg-accent-fill-muted-active` | `#a2cdd2` | `#a2cdd2` | ✅ | `#41878e` | `#41878e` | ✅ | +| `bg-accent-fill-emphasis-default` | `#206f77` | *(absent)* | ⚠️ | `#8cd2da` | `#8cd2da` | ✅ | +| `bg-accent-fill-emphasis-hover` | `#205c62` | `#205c62` | ✅ | `#ace3e9` | `#ace3e9` | ✅ | +| `bg-accent-fill-emphasis-active` | `#1c5157` | *(absent)* | ⚠️ | `#c7f1f6` | `#c7f1f6` | ✅ | + +### Borders + +| Token | Code L | Figma L | L | Code D | Figma D | D | +|---|---|---|---|---|---|---| +| `border-accent-subtle` | `#bbdbdf` | `#bbdbdf` | ✅ | `#3c6266` | `#3c6266` | ✅ | +| `border-accent-medium` | `#7cbac1` | `#7cbac1` | ✅ | `#439199` | `#439199` | ✅ | +| `border-accent-strong` | `#21767e` | `#21767e` | ✅ | `#6ec0c9` | `#6ec0c9` | ✅ | +| `border-neutral-subtle` | `#d4d4d4` | `#d4d4d4` | ✅ | `#696969` | `#525c65` | 🎨 | +| `border-neutral-medium` | `#aeaeae` | `#aeaeae` | ✅ | `#738696` | `#738696` | ✅ | +| `border-neutral-strong` | `#696969` | `#696969` | ✅ | `#9fb4c6` | `#9fb4c6` | ✅ | +| `border-focus` | `#6fb6e9` | `#6fb6e9` | ✅ | `#2d8bc5` | `#2d8bc5` | ✅ | + +### Text + +| Token | Code L | Figma L | L | Code D | Figma D | D | +|---|---|---|---|---|---|---| +| `text-neutral-subtle` | `#585858` | `#585858` | ✅ | `#d6e3ee` | `#d6e3ee` | ✅ | +| `text-neutral-strong` | `#333333` | *(absent)* | ⚠️ | `#f5fdff` | `#f5fdff` | ✅ | +| `text-neutral-subtle-on-emphasis` | `#dedede` | `#dedede` | ✅ | `#333639` | `#333639` | ✅ | +| `text-neutral-strong-on-emphasis` | `#ffffff` | `#ffffff` | ✅ | `#030303` | `#030303` | ✅ | +| `text-accent-subtle` | `#1f6369` | `#1f6369` | ✅ | `#bcebf1` | `#bcebf1` | ✅ | +| `text-accent-strong` | `#141f20` | `#141f20` | ✅ | `#e6ffff` | `#e6ffff` | ✅ | +| `text-accent-subtle-on-emphasis` | `#cae4e7` | `#cae4e7` | ✅ | `#2c3839` | `#2c3839` | ✅ | +| `text-accent-strong-on-emphasis` | `#ffffff` | `#ffffff` | ✅ | `#030303` | `#030303` | ✅ | +| `text-link` | `#0070a9` | `#0070a9` | ✅ | `#5abbfb` | `#5abbfb` | ✅ | + +### Concept (success / info / warning / danger) + +| Token | Code L | Figma L | L | Code D | Figma D | D | +|---|---|---|---|---|---|---| +| `bg-success-fill-muted-default` | `#cfeacc` | `#cfeacc` | ✅ | `#3c673a` | `#3c673a` | ✅ | +| `bg-success-fill-emphasis-default` | `#207720` | `#207720` | ✅ | `#8cdb87` | `#8cdb87` | ✅ | +| `text-success-subtle` | `#20691f` | `#20691f` | ✅ | `#bcf2b8` | `#bcf2b8` | ✅ | +| `border-success-subtle` | `#bbe0b8` | `#bbe0b8` | ✅ | `#3c673a` | `#3c673a` | ✅ | +| `bg-info-fill-muted-default` | `#cae6fa` | `#cae6fa` | ✅ | `#33607e` | `#33607e` | ✅ | +| `bg-info-fill-emphasis-default` | `#006aa0` | `#006aa0` | ✅ | `#7dceff` | `#7dceff` | ✅ | +| `text-info-subtle` | `#015e8d` | `#015e8d` | ✅ | `#b7e8ff` | `#b7e8ff` | ✅ | +| `border-info-subtle` | `#b5daf5` | `#b5daf5` | ✅ | `#33607e` | `#33607e` | ✅ | +| `bg-warning-fill-muted-default` | `#fbdac1` | `#fbdac1` | ✅ | `#7e4e25` | `#7e4e25` | ✅ | +| `bg-warning-fill-emphasis-default` | `#9b4900` | `#9b4900` | ✅ | `#ffad63` | `#ffad63` | ✅ | +| `text-warning-subtle` | `#8a4100` | `#8a4100` | ✅ | `#ffd4aa` | `#ffd4aa` | ✅ | +| `border-warning-subtle` | `#f6caaa` | `#f6caaa` | ✅ | `#7e4e25` | `#7e4e25` | ✅ | +| `bg-danger-fill-muted-default` | `#ffd0ce` | `#ffd0ce` | ✅ | `#923a3c` | `#923a3c` | ✅ | +| `bg-danger-fill-emphasis-default` | `#bc002a` | `#bc002a` | ✅ | `#ffa3a1` | `#ffa3a1` | ✅ | +| `text-danger-subtle` | `#a50827` | `#a50827` | ✅ | `#ffd0ce` | `#ffd0ce` | ✅ | +| `border-danger-subtle` | `#ffbcba` | `#ffbcba` | ✅ | `#923a3c` | `#923a3c` | ✅ | + +**Totals (vs Color Map):** the **Code** columns above capture the Color-Map baseline — light still +matches (3 absent in Figma light). **Dark has since been re-aligned to node `132:23906`** (§4), so +the Code-D column above is superseded by §4 for the neutral / accent / border / text tokens that +node defines. Concept colours and all light values are unchanged. + +--- + +## 4. Dark tokens — the earlier board alignment (SUPERSEDED — kept for history) + +> **Superseded by §4a (tonal-inversion rule).** This section documents the interim state where the +> dark map followed the Figma board `132:23906`. It no longer matches the code — kept only to explain +> the history and why the board was abandoned. + +The **dark** semantic tokens follow the Figma **dark variables** at node `132:23906` (the +component / container / border / text variable set) — not the Color Map, and not the raw +"surfaces" mockup (`136:26170`, whose fills were raw hex `#141414`/`#1c1c1c` that did **not** equal +the variables). Applied in `src/config/semanticColors.ts` (`DARK`) and `src/app/globals.css` (page +background). Full mapping: + +| App token (dark) | Figma variable (132:23906) | Value | +|---|---|---| +| `bg-neutral-canvas` + page bg | `background/container/canvas/default` | `#222222` | +| `bg-neutral-surface` | `background/container/card/default` | `#1a1a1a` | +| `bg-floating` | `background/container/sheet/dialog/default` | `#1a1a1a` | +| `bg-backdrop` | `misc/scrim` | `#1d1d1d` | +| `bg-neutral-fill-muted-default` | `background/surface/default/default` | `#222222` | +| `bg-neutral-fill-muted-hover` | `background/surface/default/hover` | `#171717` | +| `bg-neutral-fill-muted-active` | `background/surface/default/active` | `#171717` | +| `bg-accent-fill-emphasis-default` | `background/surface/accent/default/default` | `#206f77` | +| `bg-accent-fill-emphasis-hover` | `background/surface/accent/default/hover` | `#498288` | +| `bg-accent-fill-emphasis-active` | `background/surface/accent/default/pressed` | `#50929a` | +| `border-neutral-subtle` | `border/default` | `#696969` | +| `border-neutral-medium` | `border/hover` | `#838383` | +| `border-focus` | `border/selected` | `#daf9fc` | +| `border-accent-subtle` | `border/accent/subtle` | `#2c3536` | +| `border-accent-medium` | `border/accent/default` | `#416165` | +| `border-accent-strong` | `border/accent/strong` | `#50929a` | +| `text-neutral-strong` | `text/primary` | `#e1e1e1` | +| `text-accent-subtle` | `text/accent` → overridden | `#bcebf1` (not `#206f77` — see note) | +| `text-accent-strong-on-emphasis` | `eds-color-text-strong-on-emphasis` | `#ffffff` | + +⚠️ `text-accent-subtle`: the `text/accent` variable (`#206f77`) is **unreadable** on the dark +surfaces (it made the outlined/ghost button labels near-invisible), so it is **overridden to +`#bcebf1`** (a legible light-teal). `text/accent` `#206f77` appears to be a light-surface value; +Figma's *dark* accent-text variable should be a lighter colour. + +**Not defined in node 132:23906 (kept at prior Color-Map values):** neutral emphasis fills +(`bg-neutral-fill-emphasis-*`), accent canvas/surface and accent muted fills, `border-neutral-strong`, +`text-neutral-subtle`, `text-*-on-emphasis` (except accent-strong), `text-link`, and all concept +colours (success / info / warning / danger). + +**Light mode is unchanged** (still Color Map) — node `132:23906` is a dark artboard; a light +variable source is needed to align light. + +**Generated ramp (`config.ts`) not changed here:** it still targets dark lightness `0.19 → #141414` +(canvas) / `0.226 → #1c1c1c` (surface). The container variables are non-monotonic (canvas `#222222` +is *lighter* than card `#1a1a1a`), so the monotonic primitive gray ramp cannot reproduce them in +sequential steps — the semantic tokens above are the source of truth for the previews. + +**Other dark backgrounds in the file (for reference):** Color Map canvas/surface (`#0b0b0b` / +`#202223`) and the legacy generic `background` (`#212121`) are now both outliers vs the applied +`132:23906` variables and should be reconciled in Figma. + +--- + +## 4a. Dark source-of-truth — recommendation (accessibility + consistency) + +There are **three** conflicting dark definitions in play: + +| | canvas | surface | accent fill | neutral border | philosophy | +|---|---|---|---|---|---| +| **A. Board `132:23906`** (preview map follows this) | `#222222` | `#1a1a1a` | `#206f77` (dark teal, = light) | `#696969` pure gray | keep brand hex fixed across modes | +| **B. Color Map Dark `73:455`** | `#0b0b0b` | `#202223` | `#8cd2da` (light teal) | `#525c65` blue-tinted | tonal inversion (lighten accents in dark) | +| **C. Generator algorithm** (`config.ts` output) | `#0a0b0b` / `#141414` | `#1e2323` / `#1c1c1c` | `#8cd2da` | `#5b5b5b` pure gray | tonal inversion, OKLCH, pure-neutral grays | + +**The generator's own OKLCH engine produces B/C, not the board (A) it previews.** The committed +snapshot (`src/cli/__snapshots__/generate-colors.test.ts.snap`) Moss-Green dark ramp equals Color +Map Dark's accent tokens exactly — `bg-accent-fill-emphasis-default #8cd2da`, `hover #ace3e9`, +`active #c7f1f6`, `text-subtle #bcebf1`, `text-strong #e6ffff`. So `semanticColors.ts DARK` +(hand-pinned to A) contradicts the algorithm that is the whole point of the tool. + +**Rule (adopted & implemented 2026-07-10): dark uses the tonal-inversion approach (B / the algorithm +— light accent fills in dark), not the fixed-`#206f77` board (A).** It wins on both accessibility and +consistency, and nothing shipped constrained the choice. `semanticColors.ts DARK` and `globals.css` +now carry the algorithm's dark ramp values (neutral = Gray ramp, accent = Moss Green ramp). + +**Figma sync (applied 2026-07-10):** the Test file's dark theme was updated to match, via `use_figma` +(Plugin API). Figma uses a Primitive→Theme→Semantic pipeline; the changes were: +- Primitive `Dark/Moss Green/1..15` and `Dark/Gray/1..15` set to the generator's tonal dark ramps + (accent emphasis `Dark/Moss Green/9` `#206f77 → #8cd2da`; Gray steps 1/9/15 aligned to the ramp). + The concept/data dark ramps (Blue/Orange/Green/Red) already matched the generator — untouched. +- Theme (Dark mode) neutral remap fixed for elevation: `Neutral/1 → Dark/Gray/1` (canvas `#0b0b0b`), + `Neutral/2`/`Neutral/15 → Dark/Gray/2` (surfaces `#222222`), undoing the bespoke `Neutral/1↔2` swap. +- Board `132:23906`: the one raw-hardcoded default button (`#206f77`/white) + the board background + were re-bound to their semantic tokens (`background/surface/accent/default/default`, `text/on-accent`, + `background/container/canvas/default`) so they follow the pipeline like the rest. + +**Full token-for-token reconciliation (Figma → builder, applied 2026-07-10):** after the sync above, +a live diff still showed 11 dark tokens using different ramp *steps* than the builder. Resolved by: +- Syncing `Light/Gray` + `Light/Moss Green` primitives to the generator's light ramps (mirror of dark). +- Re-pointing 11 Semantic aliases to the builder's step roles: `background/surface/default/{default, + hover,active} → Neutral/{3,4,5}`, `border/default → Neutral/6`, `border/selected → Blue/7`, + `border/accent/{subtle,default,strong} → Accent/{6,7,8}`, `text/primary → Neutral/13`, + `text/accent → Accent/12`, `text/on-accent → Accent/15`. +- Fixed a Theme wiring bug: `Accent/15` light was aliased to `Moss Green/1`, corrected to `Moss Green/15`. + +The 2 remaining light residuals (`text/primary`, accent-pressed) were the builder's hand-picked +values off the generator ramp. Resolved on **accessibility** grounds — both builder picks are the +better/equal WCAG choice, so they were mirrored into Figma (Theme `Neutral/13` light `#333333`, +`Accent/11` light `#1c5157`, set as raw values): +- `text/primary`: `#333333` = 12.6:1 on white vs `#1d1d1d` = 16.9:1. Both far exceed AAA (7:1); above + ~10:1 extra contrast doesn't aid legibility and near-black-on-white raises eye-strain/halation, so + `#333333` is the better real-world body-text value. +- accent-pressed label (white on fill): `#1c5157` = 8.9:1 vs `#20565c` = 8.2:1 — builder is higher. + +**Result: dark = 18/18 AND light = 18/18 token-for-token with the builder (verified via live +`get_variable_defs` resolve pass). Figma's Semantic pipeline and the builder are fully aligned.** + +Accessibility (measured WCAG contrast, sRGB relative luminance): + +| check | A: fixed `#206f77` | B: light `#8cd2da` | +|---|---|---| +| button label on accent fill | white `5.83:1` (AA) | near-black **`12.1:1` (AAA)** | +| accent fill vs surface — 1.4.11 non-text needs ≥3:1 | `2.98:1` **fails** (vs `#1a1a1a`) | **`9.4:1` pass** | +| accent fill vs canvas | `2.73:1` **fails** (vs `#222222`) | **`11.6:1` pass** | + +A's accent fill barely separates from the dark background and forces manual per-token overrides +(the `#bcebf1` hack in §4 exists precisely because A breaks down for accent text). B is +systematically safe with no overrides. + +Consistency: +- **Elevation semantics stay coherent.** Light: elevated surface is *brighter* than canvas + (`#ffffff` > `#f5f5f5`). B keeps that in dark (`#202223` > `#0b0b0b`). A **inverts** it — card + `#1a1a1a` is *darker* than canvas `#222222`, the opposite of light. +- **Hue is preserved systematically.** OKLCH lightens the accent while keeping its hue, so teal + stays teal — a principled rule that scales to all 7 seeds, versus A's hand-maintained exceptions. +- **It matches the engine**, so the preview stops lying about what the tool generates. + +**Caveat (not the generator's call):** if board `132:23906` is the *already-shipped* EDS product +dark theme, that is a product constraint that outranks the ramp. In that case the finding flips to +"the shipped dark theme diverges from the generator's algorithm and from WCAG on fill contrast" — +raise with the design-system owners rather than silently keeping the override. + +--- + +## 5. Figma's own light/dark parity gap (3 tokens dark-only) + +The Figma **Light** Color Map is missing 3 tokens that its **Dark** map defines. The code +hand-fills these light values (marked "not in MCP light dump" in `semanticColors.ts`): + +| Token | Figma light | Figma dark | Code light (hand-filled) | +|---|---|---|---| +| `bg-accent-fill-emphasis-default` | *(absent)* | `#8cd2da` | `#206f77` (brand) | +| `bg-accent-fill-emphasis-active` | *(absent)* | `#c7f1f6` | `#1c5157` (derived) | +| `text-neutral-strong` | *(absent)* | `#f5fdff` | `#333333` (Gray/13) | + +**Recommended Figma fix:** add these three variables to `Color Map Light` (`73:314`) so the two +modes reach parity. Suggested light values = the code's hand-filled values above. + +--- + +## 6. Coverage gap — code imports a subset (~50 of ~90+ Figma tokens) + +The Figma Color Map defines the full concept ramps; `semanticColors.ts` keeps only the tokens the +previews actually use. Present in Figma (both modes) but **absent from code**: + +- `bg-{success,info,warning,danger}-{canvas,surface}` +- `bg-*-fill-muted-{hover,active}` and `bg-*-fill-emphasis-{hover,active}` (concept) +- `border-*-{medium,strong}` (concept) +- `text-*-{strong,subtle-on-emphasis,strong-on-emphasis}` (concept) + +This is **coverage, not parity** — for every token the code does import, both light and dark are +present and complete. Expand `semanticColors.ts` only if a preview needs one of these. + +--- + +## 7. Parity status summary + +| Layer | Light | Dark | Parity | +|---|---|---|---| +| `semanticColors.ts` (50 tokens) | ✅ all | ✅ all | Full L/D parity | +| Generated ramps (`config.ts`, 15 steps) | ✅ `lightValue` | ✅ `darkValue` | Full L/D parity; no direct Figma ramp to compare against | +| Figma "Color Map" (~90+ tokens) | ⚠️ 3 missing | ✅ all | 3 dark-only tokens (§5) | + +--- + +## 8. Secondary — the Test file contains a second, inconsistent color set + +The **Status Message** component in the same Figma file uses a simpler `text` / `background` / +`outline` + feedback vocabulary (neutral / error / warning / validation / brand) whose values do +**not** match the Color Map / EDS concept tokens. + +Example (light) — Status "validation" vs Color Map "success": + +| | Status Message (light) | Color Map / code (light) | +|---|---|---| +| text | `#327830` | `text-success-subtle` `#20691f` | +| background | `#f6fff5` | `bg-success-fill-muted-default` `#cfeacc` | +| outline | `#7cc278` | `border-success-subtle` `#bbe0b8` | + +These feedback tokens **do** have full light+dark parity on their own — in dark they invert to +white text / dark-tinted background / light-tint outline: + +| Feedback | text L→D | background L→D | outline L→D | +|---|---|---|---| +| neutral | `#333333` → `#f5f5f5` | `#f5f5f5` → `#212121` | `#cccccc` → `#999999` | +| error | `#c83c3f` → `#ffffff` | `#fff9f8` → `#562828` | `#ff7a7d` → `#fff9f8` | +| warning | `#b46428` → `#ffffff` | `#fffcf0` → `#633f22` | `#e89959` → `#fffcf0` | +| validation | `#327830` → `#ffffff` | `#f6fff5` → `#315131` | `#7cc278` → `#f6fff5` | +| brand | `#19565c` → `#fbfefe` | `#f3fbfc` → `#10383c` | `#36bbc9` → `#33b0bd` | + +**Takeaway:** the Test file holds two coexisting color systems — the **Color Map** (which the +generator mirrors) and the older **Status Message** demo tokens. Only the Color Map is the +generator's source of truth; don't reconcile the generator against the Status Message set. + +--- + +## 9. How to re-verify + +- Re-run `get_variable_defs` on `73:314` and `73:455` in file `6KTCpIisRSBfWnQBvW1poE` to confirm + the Figma values haven't moved. +- Diff §3's "Code" columns against `src/config/semanticColors.ts` `LIGHT` / `DARK` — should be 1:1 + apart from the 3 flagged deviations (§4). +- Optional visual check: `pnpm --filter eds-color-palette-generator dev`, toggle the color-scheme + switch, and confirm the 3 intentional dark-neutral values in the live previews. diff --git a/apps/eds-color-palette-generator/plan/option2-ramp-mockup.png b/apps/eds-color-palette-generator/plan/option2-ramp-mockup.png new file mode 100644 index 0000000000..15e64440ca Binary files /dev/null and b/apps/eds-color-palette-generator/plan/option2-ramp-mockup.png differ diff --git a/apps/eds-color-palette-generator/plan/organise-generator-code.prompt.md b/apps/eds-color-palette-generator/plan/organise-generator-code.prompt.md new file mode 100644 index 0000000000..4b42666082 --- /dev/null +++ b/apps/eds-color-palette-generator/plan/organise-generator-code.prompt.md @@ -0,0 +1,126 @@ +# Prompt: Tidy up the code organisation of the Color Palette Generator app + +> Paste this into a fresh Claude Code session (or hand it to an agent) run from the +> repo root. It is scoped entirely to `apps/eds-color-palette-generator`. +> Read `AGENTS.md` and `.claude/CLAUDE.md` first — repo conventions apply. + +--- + +## Role + +You are improving the **internal code organisation** of the Next.js app at +`apps/eds-color-palette-generator` — a safe, behaviour-preserving tidy-up. The app works +today; your job is to make its structure easier to navigate and maintain **without +changing what any tool does or looks like**. This is not a redesign and not a rewrite. + +## Scope + +**In scope (safe / mechanical):** + +- Consolidate split type definitions into a single source of truth. +- Fix cross-folder name collisions and clarify the `config/` vs `utils/` boundary. +- Break up over-large route/component files by extracting components and helpers, keeping + the route files thin. +- Add barrel (`index.ts`) files where they genuinely reduce deep-import churn. + +**Out of scope (do NOT do — flag only):** + +- **Do not** merge the two parallel palette/contrast systems (`config/config.ts` / + `PALETTE_STEPS` vs `utils/palette.ts` / `PALETTES`). This is a deeper structural change; + only **document** it in your report as a recommended follow-up. +- **Do not** touch the archived `/old` generator: `src/app/old/**` and + `src/components/old/**` stay exactly as they are. +- No behaviour, styling, copy, or dependency changes. + +## Current state (verify before you touch anything — do not trust this map blindly) + +Next.js 16 App Router. `@/*` → `./src/*`. Confirm every item below against the real code +before acting; the line counts and paths are a starting point, not gospel. + +| Smell | Where | What to do | +| ----- | ----- | ---------- | +| Split types, no single source of truth | `src/types.ts` **and** `src/config/types.ts` | Pick one home per type; consolidate; re-point imports | +| Name collisions across folders | two `palette.ts` (`config/`+`utils/`), two `dataviz.ts` (`config/`+`utils/`) | Rename for clarity so the folder no longer disambiguates | +| `config/` holds pure functions | `config/helpers.ts`, lightness fns in `config/config.ts` | Keep `config/` for data; move pure logic to `utils/` | +| Over-large page files inline logic + JSX | `app/example/page.tsx` (~1012), `app/about/page.tsx` (~866), `app/contrast/page.tsx` (~795), `app/palette/page.tsx` (~485) | Extract feature components/helpers; leave the route as a thin shell | +| No barrel files → deep imports | e.g. `@/components/themebuilder/contrast/StepSelect` | Add `index.ts` re-exports where they cut churn (avoid cycles) | + +## Execution plan + +### Phase 0 — Map & baseline + +- Re-derive the map above from the actual code: list `src/`, and for each type / colliding + filename / oversized file, find its real importers with a reference search. +- Establish a green baseline and record it, so you can prove nothing regressed: + - `pnpm --filter @equinor/eds-color-palette-generator types` + - `pnpm --filter @equinor/eds-color-palette-generator test:run` + - `pnpm --filter @equinor/eds-color-palette-generator lint` +- **Propose your ordered cleanup (which files, what moves where, new names) and confirm it + with the user before making structural moves.** + +### Phase 1 — Consolidate types + +- Decide a single home for each shared domain type (app-wide → `src/types.ts`; + config-only → keep in `config/`). Merge duplicates, delete the emptied file if any, + update all `import type` sites. Prefer `git mv` where a file is relocated wholesale. +- Verify: `types` + `test:run` still green. + +### Phase 2 — Fix naming & the config/utils boundary + +- Rename the colliding `palette.ts` / `dataviz.ts` pairs so each name is self-describing + (e.g. `config/palette-config.ts` vs `utils/palette.ts`); update imports. +- Move pure helper functions out of `config/` into `utils/`; keep `config/` for static + data only. Behaviour-preserving `git mv` + import fixes — do not rewrite the functions. +- Verify: `types` + `test:run` green. + +### Phase 3 — Break up over-large files + +- One file at a time, start with the largest. Extract self-contained pieces (sub-panels, + render helpers, pure calculators) into the matching feature folder + (`components/themebuilder/**`, `components/docs/**`, `utils/**`). The route file should + end up a thin composition of imported components. +- **Extraction is a judgment call — move code verbatim, don't refactor its internals in + the same step.** Confirm the split for each big file with the user if it isn't obvious. +- Verify after each file: `types` + `test:run` green; the page renders identically. + +### Phase 4 — Barrel files (only where they help) + +- Add `index.ts` re-exports for feature folders that are imported deeply from many places. +- Skip any barrel that would create a circular import or that saves nothing. This step is + optional — do not add barrels for their own sake. + +### Phase 5 — Verify end-to-end (all must pass) + +```bash +pnpm --filter @equinor/eds-color-palette-generator types # clean +pnpm --filter @equinor/eds-color-palette-generator lint # 0 errors, 0 warnings +pnpm --filter @equinor/eds-color-palette-generator test:run # green (unchanged count) +pnpm --filter @equinor/eds-color-palette-generator test:e2e # green +pnpm --filter @equinor/eds-color-palette-generator build # succeeds +``` + +Then drive the app (`pnpm --filter @equinor/eds-color-palette-generator dev`, or the +`/run` skill) and confirm every route renders identically with no new console errors: +`/` (theme builder), `/about`, `/contrast`, `/example`, `/palette`, and `/old` (untouched). + +## Constraints (non-negotiable) + +- Follow `AGENTS.md` / `.claude/CLAUDE.md`. Match existing style (2-space, no semicolons, + single quotes, named exports, `import type`). The format hook runs eslint/prettier + `--fix` on save; still eyeball the diff. +- **Behaviour-preserving only.** This is organisation, not a refactor. If a "tidy" would + change what any tool renders or how it behaves, stop and flag it. +- **Do not merge the two palette systems** and **do not touch `/old`** — report the + palette-system overlap as a follow-up instead. +- Prefer `git mv` for moves so history is preserved; update imports to the new paths. +- **Ask before any git action** — no commits, branches, pushes, or PRs without explicit + approval. **Never** add AI attribution / "Co-authored-by". +- Work in small, verifiable steps; run `types` + `test:run` between phases. + +## Report format + +Summarise: (a) types consolidated (from → to), (b) files renamed/moved and imports +updated, (c) large files split (before/after line counts + what was extracted), +(d) barrels added and why, (e) verification results (each command's pass/fail), +(f) the palette-system overlap and any other deeper issues flagged for follow-up, +(g) anything deliberately left untouched. diff --git a/apps/eds-color-palette-generator/plan/restructure-palette-generator.prompt.md b/apps/eds-color-palette-generator/plan/restructure-palette-generator.prompt.md new file mode 100644 index 0000000000..e9492b5be8 --- /dev/null +++ b/apps/eds-color-palette-generator/plan/restructure-palette-generator.prompt.md @@ -0,0 +1,156 @@ +# Prompt: Restructure the Color Palette Generator / Theme Builder app + +> Paste this into a fresh Claude Code session (or hand it to an agent) run from the +> repo root. It is scoped entirely to `apps/eds-color-palette-generator`. +> Read `AGENTS.md` and `.claude/CLAUDE.md` first — repo conventions apply. + +--- + +## Role + +You are restructuring the Next.js app at `apps/eds-color-palette-generator`. There are +two distinct tools living in one App-Router project, and they need to swap places, +after which the whole codebase should be analysed and cleaned up. + +## Current state (verify before you touch anything — do not trust this map blindly) + +Next.js 16 App Router. Routes under `src/app/`: + +| Route | File | What it is | +| --------------- | --------------------------------- | -------------------------------------------------------------------------- | +| `/` | `src/app/page.tsx` (`App`) | **OLD** tool — Gaussian color-scale generator (the one to move to `/old`) | +| `/themebuilder` | `src/app/themebuilder/page.tsx` | **NEW** theme builder (the one to promote to root `/`) | +| `/palette` | `src/app/palette/page.tsx` | Supporting route | +| `/contrast` | `src/app/contrast/page.tsx` | Supporting route | +| `/example` | `src/app/example/page.tsx` | Supporting route | +| `/about` | `src/app/about/page.tsx` | Docs page | + +Component ownership: + +- **Old generator** uses top-level `src/components/*`: `HeaderPanel`, `ColorScale`, + `ColorScalesHeader`, `GaussianParametersPanel`, `DisplayOptionsPanel`, + `LightnessValueInputs`, `QuickActionsPopover`, `AnchorColorInput`, + `BellCurveVisualization`, `ChromaDistributionDemo`, `ContrastRequirementsTable`, + `NameAndControls`, `ThemeToggle`, `Badge`, etc. +- **Theme builder** uses `src/components/themebuilder/**` (`ThemeBuilderHeader`, + `PaletteInputPanel`, `TokenMatrix`, `ContrastTable`, `ComponentPreviewPanel`, + `SimpleColorPicker`, and the `themebuilder/contrast/**` set). +- **Shared** (used by both / by supporting routes): `src/utils/**`, `src/config/**`, + `src/context/ColorSchemeContext.tsx`, `src/styles/**`, `src/types.ts`, `src/cli/**`. + Confirm actual usage with a reference search before assuming anything is shared. + +Known cross-references that WILL break on the swap (find the full set yourself — this +is a starting list, not exhaustive): + +- `src/components/HeaderPanel.tsx` — links to `/themebuilder`, `/contrast`, `/example`, + `/palette`, `/about`. The `/themebuilder` link must point at `/` after the move, and + a link to the old tool at `/old` needs to exist. +- `src/app/about/page.tsx` — `href="/"` (back-to-app link) currently points at the old + tool; it should point at `/old`. +- `src/app/{contrast,example,palette}/page.tsx` — cross-links between supporting routes; + re-verify each still resolves. +- `tests/e2e/*.spec.ts` — every spec does `page.goto('.../')` and exercises the **old** + generator (anchor color, change color, step dropdown, general features). After the + swap `/` is the theme builder, so these must be retargeted to `/old` (or rewritten if + the flows no longer exist). `playwright.config.ts` and `PLAYWRIGHT_URL` env usage too. +- `scripts/**` and `src/cli/**` — check whether any import assumes the root page module. +- `layout.tsx` `metadata`, `README.md`, `ABOUT_PAGE.md`, `PALETTE_OVERVIEW.md` — update + copy/titles that describe `/` as the old generator. + +## Goal + +1. **Move the OLD generator to `/old`.** The Gaussian color-scale generator currently at + `/` (`src/app/page.tsx`) becomes the `/old` route (`src/app/old/page.tsx`). It must + still work identically at its new URL. +2. **Promote the theme builder to root `/`.** `src/app/themebuilder/page.tsx` becomes + `src/app/page.tsx` and serves at `/`. Remove the now-empty `/themebuilder` route + (optionally leave a redirect `/themebuilder` → `/` — see Decision points). +3. **Analyse every code path and clean up + restructure** the app into a more logical + layout, now that the theme builder is the primary tool. + +## Constraints (non-negotiable) + +- Follow `AGENTS.md` / `.claude/CLAUDE.md`. Match existing code style (2-space, no semis, + single quotes, named exports, `import type`). +- **Ask before any git action** — no commits, branches, pushes, or PRs without explicit + approval. **Never** add AI attribution / "Co-authored-by" to commits. +- After editing `.ts`/`.tsx` files, ensure lint/format is clean (`pnpm run lint ` + or rely on the format hook). Don't leave a noisy diff. +- **Behaviour-preserving moves first, cleanup second.** Do not change what the tools do + while relocating them. Do the swap, prove it works, *then* refactor. +- Prefer `git mv` for moves so history is preserved. Update imports to match the moved + paths (the `@/` alias points at `src/`). +- Don't delete the old generator — it is being archived to `/old`, not removed. +- Work in small, verifiable steps. Run the app and the test suites between phases. + +## Execution plan + +### Phase 0 — Map & baseline +- Re-derive the current-state map above from the actual code. List every file in + `src/`, and build a reference graph: which route pulls which components/utils/config. +- Establish a green baseline: `pnpm --filter @equinor/eds-color-palette-generator dev` + boots, `pnpm --filter @equinor/eds-color-palette-generator test:run` passes, + `pnpm --filter @equinor/eds-color-palette-generator types` is clean. Note anything + already broken so you don't get blamed for it. + +### Phase 1 — Move the OLD generator to `/old` +- `git mv src/app/page.tsx src/app/old/page.tsx`; fix relative/`@/` imports. +- Update every inbound reference (nav links, about page `href="/"`, docs) to `/old`. +- Retarget the e2e specs that exercise the old generator to `/old`. +- Verify: `/old` renders and behaves exactly as `/` did before; e2e specs pass against it. + +### Phase 2 — Promote the theme builder to root `/` +- `git mv src/app/themebuilder/page.tsx src/app/page.tsx`; fix imports. +- Remove the empty `src/app/themebuilder/` dir (or convert to a redirect — Decision points). +- Point `HeaderPanel`'s former `/themebuilder` link at `/`, add an `/old` entry. +- Update `layout.tsx` metadata/title if it described the old tool. +- Verify: `/` now serves the theme builder; all nav links across all routes resolve + (no 404s); `next build` succeeds. + +### Phase 3 — Analyse all code paths & restructure +Only after Phases 1–2 are verified green. +- **Dead code:** find components/utils/config/exports no longer referenced by any route + (e.g. old-generator-only helpers that nothing imports post-swap). Report before deleting; + delete only what is provably unused. +- **Duplication:** the old generator and theme builder likely duplicate colour-scale, + contrast, and palette-config logic. Identify overlaps in `src/utils/**` and + `src/config/**`; consolidate shared logic into clearly-named shared modules, keep + genuinely tool-specific logic separate. +- **Component layout:** now that the theme builder is primary, `src/components/**` is + upside-down (primary tool nested under `themebuilder/`, archived tool at top level). + Propose and apply a clearer structure — e.g. group each tool's route-specific + components (`src/components/old/**`, and lift the theme-builder set up a level or into + a `theme-builder/**` group), keep cross-tool components in a shared location. Keep the + move mechanical (`git mv` + import fixes); don't rewrite component internals in the same + step as moving them. +- **Naming & co-location:** align file/dir names with the new hierarchy; co-locate tests + with the code they cover where the repo already does so. +- **Docs:** update `README.md`, `ABOUT_PAGE.md`, `PALETTE_OVERVIEW.md`, and the `plan/` + notes to reflect the new routing and structure. + +### Phase 4 — Verify end-to-end +- `pnpm --filter @equinor/eds-color-palette-generator types` — clean. +- `pnpm --filter @equinor/eds-color-palette-generator lint` — clean. +- `pnpm --filter @equinor/eds-color-palette-generator test:run` — green. +- `pnpm --filter @equinor/eds-color-palette-generator test:e2e` — green (retargeted). +- `pnpm --filter @equinor/eds-color-palette-generator build` — succeeds. +- Manually drive the app (or use the `/run` skill): `/` = theme builder, `/old` = old + generator, and every nav link + cross-link resolves with no console errors or 404s. +- Produce a short written summary of what moved, what was merged/deleted, and why. + +## Decision points — confirm with the user before deciding + +1. **Keep `/themebuilder` as a redirect to `/`,** or remove it entirely (breaks existing + bookmarks/links)? Default recommendation: add a permanent redirect in `next.config.ts`. +2. **Should the OLD generator stay linked in the header nav** (discoverable at `/old`), or + exist unlinked as an archive? Default: keep a low-emphasis "Old generator" link. +3. **How aggressive is the restructure** — mechanical moves + dead-code removal only, or a + deeper consolidation of duplicated colour/contrast logic? Default: do the moves and + safe dead-code removal now; propose the deeper consolidation as a follow-up before + executing it. + +## Report format + +When done, summarise: (a) files moved (old→new paths), (b) references updated, +(c) code deleted/merged with justification, (d) proposed structure diagram, +(e) verification results (each command's pass/fail), (f) any follow-ups deferred. diff --git a/apps/eds-color-palette-generator/scripts/generate-palette-contrast-report.ts b/apps/eds-color-palette-generator/scripts/generate-palette-contrast-report.ts index 9535941973..d08a1f15aa 100644 --- a/apps/eds-color-palette-generator/scripts/generate-palette-contrast-report.ts +++ b/apps/eds-color-palette-generator/scripts/generate-palette-contrast-report.ts @@ -9,7 +9,7 @@ import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' -import { paletteConfig as config } from '../src/config/palette' +import { paletteConfig as config } from '../src/config/palette-config' import { PALETTE_STEPS, lightnessValuesInLightMode, diff --git a/apps/eds-color-palette-generator/src/app/about/page.tsx b/apps/eds-color-palette-generator/src/app/about/page.tsx index db11332dfb..5866a7188f 100644 --- a/apps/eds-color-palette-generator/src/app/about/page.tsx +++ b/apps/eds-color-palette-generator/src/app/about/page.tsx @@ -1,8 +1,17 @@ 'use client' -import { BellCurveVisualization } from '@/components/BellCurveVisualization' -import { ChromaDistributionDemo } from '@/components/ChromaDistributionDemo' -import { ContrastRequirementsTable } from '@/components/ContrastRequirementsTable' +import { + AboutBestPractices, + AboutChromaDistribution, + AboutConfiguration, + AboutContrastRequirements, + AboutGaussianBellCurve, + AboutHowItWorks, + AboutLearnMore, + AboutOklchColorSpace, + AboutOverview, + AboutTableOfContents, +} from '@/components/docs/about' import { ArrowLeft } from 'lucide-react' import Link from 'next/link' @@ -12,7 +21,7 @@ export default function AboutPage() {
@@ -30,836 +39,16 @@ export default function AboutPage() {
- {/* Table of Contents */} - - - {/* Overview */} -
-

Overview

-
-

- The EDS Colour Palette - Generator creates consistent, accessible colour scales for design - systems. It uses a Gaussian (bell curve) distribution to maintain - visual harmony across different lightness levels. -

-

- The tool works in the{' '} - OKLCH colour space - — a perceptually uniform model where equal numerical changes - produce equal perceived differences. This makes it ideal for - programmatic colour generation. -

-
-
- - {/* How It Works */} -
-

How it works

-
-
-

- 1. Define lightness values -

-

- Each colour scale step has a predefined lightness value chosen - to meet contrast requirements: -

-
    -
  • - Background colours: High lightness in light - mode (0.97–0.999), low in dark mode (0.15–0.25) -
  • -
  • - Border colours: Mid-range values for subtle - to strong emphasis -
  • -
  • - Fill colours: Values optimised for - interactive elements -
  • -
  • - Text colours: Values ensuring{' '} - WCAG{' '} - AA/AAA compliance -
  • -
-

- Lightness values are configured separately for light and dark - modes to ensure optimal contrast in both. -

-
- -
-

- 2. Choose your colour approach -

-

- You can define colours in two ways: -

-
-
-

- Single value (traditional) -

-

- Provide one base colour, and the generator creates all steps - with the same hue, varying only lightness and chroma using - Gaussian distribution. -

-
-
-

- Multiple anchors (interpolation) -

-

- Define colours at specific steps (e.g., step 6 and step 9), - and the generator smoothly interpolates between them in{' '} - OKLCH space, - allowing hue to shift naturally while still applying - Gaussian chroma distribution. -

-
-
-
- -
-

- 3. Apply Gaussian distribution to chroma -

-

- While lightness is fixed, chroma (colour intensity) varies using - a Gaussian function. This creates natural colour progression — - colours are most vibrant at a specific lightness level (the - mean) and gradually become more muted as you move away from it. -

-
- gaussian(x, mean, stdDev) = exp((-25 / stdDev) × (mean - x)²) -
-

- The function outputs a multiplier (0–1) applied to the base - colour's chroma at each step. -

-
- -
-

- 4. Generate colour scale -

-

For each step:

-
    -
  1. Convert the base colour(s) to OKLCH colour space
  2. -
  3. Set the lightness to the predefined value for that step
  4. -
  5. - If using multiple anchors, interpolate between them to get the - base colour for this step -
  6. -
  7. - Calculate the chroma multiplier using the Gaussian function - with the step's lightness -
  8. -
  9. - Apply the multiplier to the base colour's chroma: - new_chroma = base_chroma × multiplier -
  10. -
  11. - For single value: Keep the hue unchanged. For multiple - anchors: Hue shifts naturally through interpolation -
  12. -
-

- This ensures colours maintain visual harmony while adapting - intensity to suit different lightness levels. -

-
- -
-

- 5. Multiple colour anchors (interpolation) -

-

- For more control, you can define multiple colour anchors at - specific steps. The generator will interpolate between them in{' '} - OKLCH space. -

- - {/* Single Value Example */} -
-

- Single value (traditional approach) -

-
-
-
- - - oklch(0.4973 0.084851 204.553) - {' '} - (Moss Green) - -
-

- The base colour is used for all steps with Gaussian chroma - distribution applied at each lightness level: -

-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 1 - 15 -
-
-
- - {/* Multiple Anchors Example - Using actual generated values from test snapshots */} -
-

- Multiple anchors (interpolation approach) -

-
-
-
-
-
-
- oklch(0.5915 0.0731 184.63) -
-
- Anchor at step 6 -
-
-
-
-
-
-
- oklch(0.4973 0.084851 204.553) -
-
- Anchor at step 9 -
-
-
-
-

- Colours smoothly interpolate between anchors in OKLCH space, - with Gaussian chroma distribution applied: -

-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 6 - 9 -
-
-
- -

- This technique is useful for creating gradient-like scales where - you want precise control over specific steps while maintaining - smooth transitions between them. The Gaussian chroma - distribution is still applied, but hue and lightness can shift - naturally between your defined anchors. -

-
-
-
- - {/* Bell Curve Visualization */} -
-

The Gaussian bell curve

-

- The bell curve determines how chroma is distributed across lightness - values. Adjust the mean and standard deviation to see how they - affect curve shape and colour intensity at different lightness - levels. -

- -
- - {/* Chroma Distribution Demo */} -
-

- Interactive chroma distribution -

-

- Experiment with different base colours and Gaussian parameters to - see how they affect the final colour scale. The chart shows how - chroma varies across the lightness spectrum. -

- -
- - {/* OKLCH Color Space */} -
-

Why OKLCH colour space?

-
-

- OKLCH is a - cylindrical representation of the Oklab colour space, designed to - be perceptually uniform. It has three components: -

-
-
-
Lightness (L)
-
- Ranges from 0 (black) to 1 (white). Perceptually uniform — 0.5 - is truly medium lightness. -
-
-
-
Chroma (C)
-
- Colour intensity or saturation. 0 is greyscale, higher values - are more vibrant. Unlike{' '} - HSL, chroma is - consistent across hues. -
-
-
-
Hue (H)
-
- The colour angle (0–360 degrees). Represents the basic colour - quality (red, green, blue, etc.). -
-
-
-

- By adjusting lightness and chroma independently while keeping hue - constant, we create colour scales that look natural and maintain - consistent relationships. -

-
-
- - {/* Configuration */} -
-

Configuration options

-
-
-

Lightness values

-

- Each step has predefined lightness values for light and dark - modes, based on: -

-
    -
  • - - APCA - {' '} - contrast requirements -
  • -
  • - WCAG{' '} - 2.1 Level AA/AAA guidelines -
  • -
  • Visual hierarchy needs (backgrounds, borders, text)
  • -
  • - Interactive state differentiation (default, hover, active) -
  • -
-
- -
-

- Gaussian parameters -

-
-
-

Mean

-

- The lightness value where chroma reaches maximum. Typically - set to 0.6 for light mode and 0.7 for dark mode to ensure - vibrant mid-tones. -

-
-
-

Standard deviation

-

- Controls how quickly chroma decreases away from the mean. - Lower values create sharper peaks (dramatic variation), - higher values create gentler curves (gradual changes). -

-
-
-
- -
-

- Separate light/dark modes -

-

- Light and dark modes use different Gaussian parameters because: -

-
    -
  • - Dark mode typically needs higher chroma in lighter colours to - maintain visibility -
  • -
  • - Light mode benefits from vibrant mid-tones but muted extremes -
  • -
  • - Different background lightness ranges require different chroma - distributions -
  • -
-
-
-
- - {/* Color Step Pairings and Contrast Requirements */} -
-

- Colour step pairings and contrast requirements -

-
-

- Each colour step is designed to work with specific other steps to - ensure accessibility. The configuration defines contrast - requirements using both{' '} - APCA{' '} - (Accessible Perceptual Contrast Algorithm) and{' '} - WCAG 2.1 - standards. -

-

- These requirements come directly from the configuration file, - keeping documentation in sync with implementation. Each pairing - specifies minimum contrast levels for different use cases — from - subtle UI components to body text. -

-
- -
-

Understanding the levels:

-
    -
  • - - - APCA - {' '} - Lc values: - {' '} - Range from 15 (decorative elements) to 90 (body text). Higher - values mean stronger contrast requirements. -
  • -
  • - - WCAG{' '} - ratios: - {' '} - Traditional contrast ratios (3:1, 4.5:1, 7:1) for UI components, - normal text, and large text at AA/AAA levels. -
  • -
-
- - - -
-

Key insights

-
    -
  • - Background steps (Canvas, Surface) need minimal - contrast between each other but high contrast with text elements -
  • -
  • - Fill elements have progressive contrast - requirements based on emphasis level (muted vs. emphasis) -
  • -
  • - Border steps maintain visual hierarchy through - three levels (subtle, medium, strong) -
  • -
  • - Text elements have the strictest requirements — - different levels for subtle text, strong text, and text on - emphasis backgrounds -
  • -
  • - Interactive states (default, hover, active) are - differentiated through lightness and contrast requirements -
  • -
-
-
- - {/* Best Practices */} -
-

Best practices

-
-
    -
  • - - ✓ - -
    -

    - Start with a well-saturated base colour -

    -

    - The Gaussian function scales down from the base chroma. - Starting with higher chroma gives more flexibility. -

    -
    -
  • -
  • - - ✓ - -
    -

    Test contrast ratios regularly

    -

    - Enable contrast checking to ensure all colour combinations - meet accessibility requirements. -

    -
    -
  • - -
  • - - ✓ - -
    -

    - Export and version your configurations -

    -

    - Save your palette configurations to maintain consistency and - track changes over time. -

    -
    -
  • -
-
-
- - {/* Additional Resources */} -
-

Learn more

-
- -
-
+ + + + + + + + + +
) diff --git a/apps/eds-color-palette-generator/src/app/contrast/page.tsx b/apps/eds-color-palette-generator/src/app/contrast/page.tsx new file mode 100644 index 0000000000..efb0f8cb5e --- /dev/null +++ b/apps/eds-color-palette-generator/src/app/contrast/page.tsx @@ -0,0 +1,151 @@ +'use client' + +import { useState, useMemo } from 'react' +import Link from 'next/link' +import { PALETTES } from '@/utils/palette' +import { + buildPatternGroups, + buildStepData, + getLightness, + type ViewMode, +} from '@/utils/contrastPageData' +import { CombinedPatternView, SinglePaletteView } from '@/components/contrast' + +export default function ContrastPage() { + const [activePalette, setActivePalette] = useState(0) + const [viewMode, setViewMode] = useState('semantic') + + const palette = PALETTES[activePalette] + + const displayData = useMemo(() => { + if (viewMode === 'combined') return [] + if (viewMode === 'semantic') { + return buildStepData(palette.steps, 'semantic') + } + const sorted = palette.steps + .map((hex, i) => ({ hex, i })) + .sort((a, b) => getLightness(b.hex) - getLightness(a.hex)) + return buildStepData( + sorted.map((s) => s.hex), + 'gradient', + sorted.map((s) => s.i + 1), + ) + }, [palette, viewMode]) + + const patternGroups = useMemo(() => { + if (viewMode !== 'combined') return [] + return buildPatternGroups() + }, [viewMode]) + + return ( +
+ {/* ---- Header ---- */} +
+
+

EDS Palette Contrast

+ +
+ {/* Palette selector — hidden in combined */} + {viewMode !== 'combined' && ( +
+ {PALETTES.map((p, i) => ( + + ))} +
+ )} + + {/* View mode toggle */} +
+ {(['semantic', 'gradient', 'combined'] as const).map((mode) => ( + + ))} +
+ + {/* Nav links */} + + Example + + + Palette + +
+
+
+ +
+ {viewMode === 'combined' && ( + + )} + + {viewMode !== 'combined' && ( + + )} +
+
+ ) +} diff --git a/apps/eds-color-palette-generator/src/app/dataviz/page.tsx b/apps/eds-color-palette-generator/src/app/dataviz/page.tsx new file mode 100644 index 0000000000..ad697c92aa --- /dev/null +++ b/apps/eds-color-palette-generator/src/app/dataviz/page.tsx @@ -0,0 +1,45 @@ +'use client' + +import Link from 'next/link' +import { ArrowLeft } from 'lucide-react' +import { ThemeToggle } from '@/components/shared/ThemeToggle' +import { DataVizPanel } from '@/components/themebuilder/dataviz/DataVizPanel' + +export default function DataVizPage() { + return ( +
+
+
+

+ EDS Data Visualisation Palettes +

+
+ + + + Theme builder + +
+
+
+ +
+

+ Generate accessible colour palettes for charts and data + visualisation: distinct categorical series, ordered sequential + scales, and diverging scales around a midpoint. Everything is checked + for colour-vision-deficiency safety and adapts to light and dark mode. +

+ +
+
+ ) +} diff --git a/apps/eds-color-palette-generator/src/app/example/page.tsx b/apps/eds-color-palette-generator/src/app/example/page.tsx new file mode 100644 index 0000000000..17204a5bde --- /dev/null +++ b/apps/eds-color-palette-generator/src/app/example/page.tsx @@ -0,0 +1,117 @@ +'use client' + +import { useMemo, useState } from 'react' +import Link from 'next/link' +import { PALETTES } from '@/utils/palette' +import { + refreshCustomPalettes, + useCustomPalettes, +} from '@/utils/customPalettesStore' +import { + InteractivePicker, + PredefinedGroups, + SurfacePreviewSection, +} from '@/components/example' + +export default function ExamplePage() { + const [activePalette, setActivePalette] = useState(0) + const customPalettes = useCustomPalettes() + + const allPalettes = useMemo( + () => [...PALETTES, ...customPalettes], + [customPalettes], + ) + + const palette = allPalettes[activePalette] ?? allPalettes[0] + + return ( +
+ {/* ---- Header ---- */} +
+
+

EDS Palette Examples

+ +
+ {/* Palette selector */} +
+ {allPalettes.map((p, i) => ( + + ))} + +
+ + {/* Nav links */} + + Contrast + + + Palette + +
+
+
+ +
+ + + +
+
+ ) +} diff --git a/apps/eds-color-palette-generator/src/app/globals.css b/apps/eds-color-palette-generator/src/app/globals.css index 8c66fc323a..f7592c72dd 100644 --- a/apps/eds-color-palette-generator/src/app/globals.css +++ b/apps/eds-color-palette-generator/src/app/globals.css @@ -6,6 +6,17 @@ --font-mono: var(--font-geist-mono); } +:root { + /* Default page background. Light = EDS neutral *canvas* (#f5f5f5). */ + --background: var(--eds-color-bg-neutral-canvas); +} + +/* Dark: page backdrop = #0b0b0b = tonal-inversion neutral canvas (Gray/1). + See plan/light-dark-parity-comparison.md §4a. */ +[data-color-scheme='dark'] { + --background: #0b0b0b; +} + html, body { min-height: 100vh; diff --git a/apps/eds-color-palette-generator/src/app/layout.tsx b/apps/eds-color-palette-generator/src/app/layout.tsx index 968cb5a535..5a231591bf 100644 --- a/apps/eds-color-palette-generator/src/app/layout.tsx +++ b/apps/eds-color-palette-generator/src/app/layout.tsx @@ -17,8 +17,9 @@ const geistMono = Geist_Mono({ }) export const metadata: Metadata = { - title: 'Color Generator', - description: 'Generate color scales with gaussian distribution', + title: 'EDS Theme Builder', + description: + 'Build accessible colour themes and palettes for the Equinor Design System', } export default function RootLayout({ diff --git a/apps/eds-color-palette-generator/src/app/old/page.tsx b/apps/eds-color-palette-generator/src/app/old/page.tsx new file mode 100644 index 0000000000..284b268c8c --- /dev/null +++ b/apps/eds-color-palette-generator/src/app/old/page.tsx @@ -0,0 +1,464 @@ +'use client' + +import { generateColorScale } from '@/utils/color' +import { useState, useEffect, useMemo } from 'react' +import { useIsMounted } from '@equinor/eds-utils' +import { ColorScale } from '@/components/old/ColorScale' +import { useColorScheme } from '@/context/ColorSchemeContext' +import { HeaderPanel } from '@/components/old/HeaderPanel' +import { GaussianParametersPanel } from '@/components/old/GaussianParametersPanel' +import { DisplayOptionsPanel } from '@/components/old/DisplayOptionsPanel' +import { LightnessValueInputs } from '@/components/old/LightnessValueInputs' +import { ColorScalesHeader } from '@/components/old/ColorScalesHeader' +import { + ColorDefinition, + ColorAnchor, + ConfigFile, + ContrastMethod, + ColorFormat, +} from '@/types' +import { arraysEqual, colorsEqual } from '@/utils/compare' +import { localStorageUtils } from '@/utils/localStorage' +import { paletteConfig as config } from '@/config/palette-config' +import { + lightnessValuesInLightMode, + darknessValuesInDarkMode, +} from '@/config/config' +import { computeContrastSummary } from '@/utils/contrastSummary' +import { QuickActionsPopover } from '@/components/old/QuickActionsPopover' +import { RotateCcw } from 'lucide-react' + +export default function App() { + // Initialize state with values from localStorage or defaults + const [meanLight, setMeanLight] = useState(() => + localStorageUtils.getMeanLight(config.meanLight), + ) + const [stdDevLight, setStdDevLight] = useState(() => + localStorageUtils.getStdDevLight(config.stdDevLight), + ) + const [meanDark, setMeanDark] = useState(() => + localStorageUtils.getMeanDark(config.meanDark), + ) + const [stdDevDark, setStdDevDark] = useState(() => + localStorageUtils.getStdDevDark(config.stdDevDark), + ) + const { colorScheme } = useColorScheme() + const [showContrast, setShowContrast] = useState(() => + localStorageUtils.getShowContrast(false), + ) + const [showLightnessInputs, setShowLightnessInputs] = useState(() => + localStorageUtils.getShowLightnessInputs(false), + ) + const [showGaussianParameters, setShowGaussianParameters] = useState(() => + localStorageUtils.getShowGaussianParameters(false), + ) + const [showConfigPanel, setShowConfigPanel] = useState(false) // Don't persist this one + const [contrastMethod, setContrastMethod] = useState(() => + localStorageUtils.getContrastMethod('APCA'), + ) + const [colorFormat, setColorFormat] = useState(() => + localStorageUtils.getColorFormat('OKLCH'), + ) + + // Add state for lightness values + const [lightModeValues, setLightModeValues] = useState(() => + localStorageUtils.getLightModeValues(lightnessValuesInLightMode), + ) + const [darkModeValues, setDarkModeValues] = useState(() => + localStorageUtils.getDarkModeValues(darknessValuesInDarkMode), + ) + + // Define colors in an array for easier management + const [colors, setColors] = useState(() => + localStorageUtils.getColors(config.colors), + ) + + // Save to localStorage whenever state changes + useEffect(() => { + localStorageUtils.setMeanLight(meanLight) + }, [meanLight]) + + useEffect(() => { + localStorageUtils.setStdDevLight(stdDevLight) + }, [stdDevLight]) + + useEffect(() => { + localStorageUtils.setMeanDark(meanDark) + }, [meanDark]) + + useEffect(() => { + localStorageUtils.setStdDevDark(stdDevDark) + }, [stdDevDark]) + + useEffect(() => { + localStorageUtils.setShowContrast(showContrast) + }, [showContrast]) + + useEffect(() => { + localStorageUtils.setShowLightnessInputs(showLightnessInputs) + }, [showLightnessInputs]) + + useEffect(() => { + localStorageUtils.setShowGaussianParameters(showGaussianParameters) + }, [showGaussianParameters]) + + useEffect(() => { + localStorageUtils.setContrastMethod(contrastMethod) + }, [contrastMethod]) + + useEffect(() => { + localStorageUtils.setColorFormat(colorFormat) + }, [colorFormat]) + + useEffect(() => { + localStorageUtils.setLightModeValues(lightModeValues) + }, [lightModeValues]) + + useEffect(() => { + localStorageUtils.setDarkModeValues(darkModeValues) + }, [darkModeValues]) + + useEffect(() => { + localStorageUtils.setColors(colors) + }, [colors]) + + const updateColorName = (index: number, newName: string) => { + // Use functional update to avoid stale state when multiple edits occur quickly + setColors((prev) => + prev.map((color, i) => + i === index ? { ...color, name: newName } : color, + ), + ) + } + + const updateColorValue = (index: number, newValue: string) => { + // Use functional update to avoid stale state when multiple edits occur quickly + setColors((prev) => + prev.map((color, i) => + i === index ? { ...color, value: newValue } : color, + ), + ) + } + + const updateColorAnchors = (index: number, newAnchors: ColorAnchor[]) => { + // Update the anchors for a color + setColors((prev) => + prev.map((color, i) => { + if (i === index) { + // Intentionally remove the legacy 'value' field when migrating to anchors + // to prevent ambiguity between single-color and multi-anchor formats + return { name: color.name, anchors: newAnchors } + } + return color + }), + ) + } + + const removeColor = (index: number) => { + // Functional update for consistency and to avoid state races + setColors((prev) => prev.filter((_, i) => i !== index)) + } + + const addColor = (newColor: ColorDefinition) => { + // Functional update for consistency and to avoid state races + setColors((prev) => [...prev, newColor]) + } + + // Update a specific lightness value + const updateLightnessValue = (index: number, value: number) => { + if (colorScheme === 'light') { + const newValues = [...lightModeValues] + newValues[index] = value + setLightModeValues(newValues) + } else { + const newValues = [...darkModeValues] + newValues[index] = value + setDarkModeValues(newValues) + } + } + + // Reset only configuration settings (colors, Gaussian parameters, lightness values) + const resetConfiguration = () => { + // Clear only configuration-related localStorage items + localStorageUtils.clearConfiguration() + + // Reset only configuration state to defaults + setMeanLight(config.meanLight) + setStdDevLight(config.stdDevLight) + setMeanDark(config.meanDark) + setStdDevDark(config.stdDevDark) + setLightModeValues(lightnessValuesInLightMode) + setDarkModeValues(darknessValuesInDarkMode) + setColors(config.colors) + } + + // Handle configuration upload + const handleConfigUpload = (cfg: ConfigFile) => { + setLightModeValues(cfg.lightModeValues) + setDarkModeValues(cfg.darkModeValues) + setMeanLight(cfg.meanLight) + setStdDevLight(cfg.stdDevLight) + setMeanDark(cfg.meanDark) + setStdDevDark(cfg.stdDevDark) + if (cfg.colors) { + setColors(cfg.colors) + } + } + + // Efficiently memoize computed scales by value-only dependencies + // Support both legacy single value and new anchor format + const valueKey = useMemo( + () => + colors + .map((c) => { + if ('anchors' in c) { + return c.anchors.map((a) => `${a.step}:${a.value}`).join(',') + } + return c.value + }) + .join('|'), + [colors], + ) + const lightScalesMemo = useMemo( + () => + colors.map((c) => { + // Use anchors if available, otherwise use single value + const colorInput = 'anchors' in c ? c.anchors : c.value + return generateColorScale( + colorInput, + lightModeValues, + meanLight, + stdDevLight, + colorFormat, + ) + }), + // Only recompute when color values or generation inputs change (not when names change) + // eslint-disable-next-line react-hooks/exhaustive-deps + [valueKey, lightModeValues, meanLight, stdDevLight, colorFormat], + ) + const darkScalesMemo = useMemo( + () => + colors.map((c) => { + // Use anchors if available, otherwise use single value + const colorInput = 'anchors' in c ? c.anchors : c.value + return generateColorScale( + colorInput, + darkModeValues, + meanDark, + stdDevDark, + colorFormat, + ) + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [valueKey, darkModeValues, meanDark, stdDevDark, colorFormat], + ) + + // Combine fast: map names to memoized scales without recomputing heavy work on name edits + const lightColorScales = useMemo( + () => colors.map((c, i) => ({ ...c, scale: lightScalesMemo[i] })), + [colors, lightScalesMemo], + ) + const darkColorScales = useMemo( + () => colors.map((c, i) => ({ ...c, scale: darkScalesMemo[i] })), + [colors, darkScalesMemo], + ) + + // Select the appropriate scales based on current color scheme + const currentColorScales = + colorScheme === 'light' ? lightColorScales : darkColorScales + const currentScalesOnly = + colorScheme === 'light' ? lightScalesMemo : darkScalesMemo + + // Client flag to avoid hydration mismatch + const isMounted = useIsMounted() + + // Determine if configuration differs from defaults (mean, stdDev, light/dark values, colors) + const isConfigDirty = useMemo(() => { + return ( + meanLight !== config.meanLight || + stdDevLight !== config.stdDevLight || + meanDark !== config.meanDark || + stdDevDark !== config.stdDevDark || + !arraysEqual(lightModeValues, lightnessValuesInLightMode) || + !arraysEqual(darkModeValues, darknessValuesInDarkMode) || + !colorsEqual(colors, config.colors) + ) + }, [ + meanLight, + stdDevLight, + meanDark, + stdDevDark, + lightModeValues, + darkModeValues, + colors, + ]) + + const contrastSummary = useMemo(() => { + if (!isMounted || !showContrast) { + return { passed: 0, total: 0, percentage: 0 } + } + // Only depend on scales (colors), not names or other metadata + const payload = currentScalesOnly.map((scale) => ({ scale })) + return computeContrastSummary({ + colorScales: payload, + contrastMethod, + enabled: showContrast, + }) + }, [currentScalesOnly, contrastMethod, showContrast, isMounted]) + + return ( +
+
+ +
+ +
+ {/* Config Panel */} + {showConfigPanel && ( +
+
+
+ + + {showGaussianParameters && ( +
+ +
+ )} +
+
+
+ )} + + {/* Full-width sticky subheader after display options */} +
+
+ + {showLightnessInputs && ( + + )} +
+
+ + {/* Render color scales dynamically */} + {isMounted && ( + <> +
+ {currentColorScales.map((colorData, index) => { + const color = colors[index] + return ( +
+ updateColorName(index, name)} + onChangeValue={(value: string) => + updateColorValue(index, value) + } + onChangeAnchors={(anchors) => + updateColorAnchors(index, anchors) + } + onRemove={() => removeColor(index)} + /> +
+ ) + })} +
+ + {/* Add new color button */} +
+ +
+ + )} +
+ +
+ + + {isMounted && showContrast && contrastSummary.total > 0 && ( + <> +
+ {`${contrastSummary.passed}/${contrastSummary.total} checks (${contrastSummary.percentage.toFixed(1)}%)`} +
+ + )} + {isMounted && isConfigDirty && ( + + )} +
+
+ ) +} diff --git a/apps/eds-color-palette-generator/src/app/page.tsx b/apps/eds-color-palette-generator/src/app/page.tsx index baa063a6c5..27208a1f7c 100644 --- a/apps/eds-color-palette-generator/src/app/page.tsx +++ b/apps/eds-color-palette-generator/src/app/page.tsx @@ -1,464 +1,206 @@ 'use client' -import { generateColorScale } from '../utils/color' -import { useState, useEffect, useMemo } from 'react' -import { useIsMounted } from '@equinor/eds-utils' -import { ColorScale } from '@/components/ColorScale' +import { Suspense, useState, useMemo, useEffect, useCallback } from 'react' +import { useSearchParams } from 'next/navigation' +import { generateColorScale, parseColorToHex } from '@/utils/color' import { useColorScheme } from '@/context/ColorSchemeContext' -import { HeaderPanel } from '@/components/HeaderPanel' -import { GaussianParametersPanel } from '@/components/GaussianParametersPanel' -import { DisplayOptionsPanel } from '@/components/DisplayOptionsPanel' -import { LightnessValueInputs } from '@/components/LightnessValueInputs' -import { ColorScalesHeader } from '@/components/ColorScalesHeader' -import { - ColorDefinition, - ColorAnchor, - ConfigFile, - ContrastMethod, - ColorFormat, -} from '@/types' -import { arraysEqual, colorsEqual } from '@/utils/compare' -import { localStorageUtils } from '@/utils/localStorage' -import { paletteConfig as config } from '@/config/palette' import { lightnessValuesInLightMode, darknessValuesInDarkMode, } from '@/config/config' -import { computeContrastSummary } from '@/utils/contrastSummary' -import { QuickActionsPopover } from '@/components/QuickActionsPopover' -import { RotateCcw } from 'lucide-react' +import { paletteConfig } from '@/config/palette-config' +import { getSemanticColors } from '@/config/semanticColors' +import { deserializeState, updateURL } from '@/utils/urlState' +import type { PaletteInput } from '@/utils/urlState' +import { downloadConfiguration } from '@/utils/configurationUtils' +import type { ColorDefinition } from '@/types' +import { ThemeBuilderHeader } from '@/components/themebuilder/ThemeBuilderHeader' +import { PaletteInputPanel } from '@/components/themebuilder/PaletteInputPanel' +import { TokenMatrix } from '@/components/themebuilder/TokenMatrix' +import { ContrastTable } from '@/components/themebuilder/ContrastTable' +import { ComponentPreviewPanel } from '@/components/themebuilder/ComponentPreviewPanel' +import { ContrastTestPanel } from '@/components/themebuilder/ContrastTestPanel' + +type Tab = 'fargesystem' | 'eksempler' | 'kontrast' + +/** Convert paletteConfig.colors to PaletteInput[] for defaults */ +const DEFAULT_PALETTES: PaletteInput[] = (paletteConfig.colors ?? []).map( + (color) => { + if ('anchors' in color) { + return { + name: color.name, + baseColor: '', + anchors: color.anchors, + } + } + // Single value color — store as baseColor + const hex = parseColorToHex(color.value) + return { + name: color.name, + baseColor: hex?.replace('#', '') ?? '808080', + } + }, +) -export default function App() { - // Initialize state with values from localStorage or defaults - const [meanLight, setMeanLight] = useState(() => - localStorageUtils.getMeanLight(config.meanLight), - ) - const [stdDevLight, setStdDevLight] = useState(() => - localStorageUtils.getStdDevLight(config.stdDevLight), - ) - const [meanDark, setMeanDark] = useState(() => - localStorageUtils.getMeanDark(config.meanDark), - ) - const [stdDevDark, setStdDevDark] = useState(() => - localStorageUtils.getStdDevDark(config.stdDevDark), - ) +function ThemeBuilderContent() { + const searchParams = useSearchParams() const { colorScheme } = useColorScheme() - const [showContrast, setShowContrast] = useState(() => - localStorageUtils.getShowContrast(false), - ) - const [showLightnessInputs, setShowLightnessInputs] = useState(() => - localStorageUtils.getShowLightnessInputs(false), - ) - const [showGaussianParameters, setShowGaussianParameters] = useState(() => - localStorageUtils.getShowGaussianParameters(false), - ) - const [showConfigPanel, setShowConfigPanel] = useState(false) // Don't persist this one - const [contrastMethod, setContrastMethod] = useState(() => - localStorageUtils.getContrastMethod('APCA'), - ) - const [colorFormat, setColorFormat] = useState(() => - localStorageUtils.getColorFormat('OKLCH'), - ) - // Add state for lightness values - const [lightModeValues, setLightModeValues] = useState(() => - localStorageUtils.getLightModeValues(lightnessValuesInLightMode), - ) - const [darkModeValues, setDarkModeValues] = useState(() => - localStorageUtils.getDarkModeValues(darknessValuesInDarkMode), - ) + // Initialize state from URL or defaults + const initialState = useMemo(() => { + if (!searchParams) return {} + return deserializeState(searchParams) + }, [searchParams]) - // Define colors in an array for easier management - const [colors, setColors] = useState(() => - localStorageUtils.getColors(config.colors), + const [palettes, setPalettes] = useState( + initialState.palettes ?? DEFAULT_PALETTES, ) + const [activeTab, setActiveTab] = useState( + initialState.activeTab ?? 'fargesystem', + ) + const [contrastPaletteIndex, setContrastPaletteIndex] = useState(0) - // Save to localStorage whenever state changes - useEffect(() => { - localStorageUtils.setMeanLight(meanLight) - }, [meanLight]) - - useEffect(() => { - localStorageUtils.setStdDevLight(stdDevLight) - }, [stdDevLight]) - - useEffect(() => { - localStorageUtils.setMeanDark(meanDark) - }, [meanDark]) - - useEffect(() => { - localStorageUtils.setStdDevDark(stdDevDark) - }, [stdDevDark]) - - useEffect(() => { - localStorageUtils.setShowContrast(showContrast) - }, [showContrast]) - - useEffect(() => { - localStorageUtils.setShowLightnessInputs(showLightnessInputs) - }, [showLightnessInputs]) - - useEffect(() => { - localStorageUtils.setShowGaussianParameters(showGaussianParameters) - }, [showGaussianParameters]) - - useEffect(() => { - localStorageUtils.setContrastMethod(contrastMethod) - }, [contrastMethod]) - - useEffect(() => { - localStorageUtils.setColorFormat(colorFormat) - }, [colorFormat]) - - useEffect(() => { - localStorageUtils.setLightModeValues(lightModeValues) - }, [lightModeValues]) - - useEffect(() => { - localStorageUtils.setDarkModeValues(darkModeValues) - }, [darkModeValues]) - + // Sync state to URL useEffect(() => { - localStorageUtils.setColors(colors) - }, [colors]) - - const updateColorName = (index: number, newName: string) => { - // Use functional update to avoid stale state when multiple edits occur quickly - setColors((prev) => - prev.map((color, i) => - i === index ? { ...color, name: newName } : color, - ), - ) - } - - const updateColorValue = (index: number, newValue: string) => { - // Use functional update to avoid stale state when multiple edits occur quickly - setColors((prev) => - prev.map((color, i) => - i === index ? { ...color, value: newValue } : color, - ), - ) - } - - const updateColorAnchors = (index: number, newAnchors: ColorAnchor[]) => { - // Update the anchors for a color - setColors((prev) => - prev.map((color, i) => { - if (i === index) { - // Intentionally remove the legacy 'value' field when migrating to anchors - // to prevent ambiguity between single-color and multi-anchor formats - return { name: color.name, anchors: newAnchors } - } - return color - }), - ) - } - - const removeColor = (index: number) => { - // Functional update for consistency and to avoid state races - setColors((prev) => prev.filter((_, i) => i !== index)) - } - - const addColor = (newColor: ColorDefinition) => { - // Functional update for consistency and to avoid state races - setColors((prev) => [...prev, newColor]) - } - - // Update a specific lightness value - const updateLightnessValue = (index: number, value: number) => { - if (colorScheme === 'light') { - const newValues = [...lightModeValues] - newValues[index] = value - setLightModeValues(newValues) - } else { - const newValues = [...darkModeValues] - newValues[index] = value - setDarkModeValues(newValues) - } - } - - // Reset only configuration settings (colors, Gaussian parameters, lightness values) - const resetConfiguration = () => { - // Clear only configuration-related localStorage items - localStorageUtils.clearConfiguration() - - // Reset only configuration state to defaults - setMeanLight(config.meanLight) - setStdDevLight(config.stdDevLight) - setMeanDark(config.meanDark) - setStdDevDark(config.stdDevDark) - setLightModeValues(lightnessValuesInLightMode) - setDarkModeValues(darknessValuesInDarkMode) - setColors(config.colors) - } - - // Handle configuration upload - const handleConfigUpload = (cfg: ConfigFile) => { - setLightModeValues(cfg.lightModeValues) - setDarkModeValues(cfg.darkModeValues) - setMeanLight(cfg.meanLight) - setStdDevLight(cfg.stdDevLight) - setMeanDark(cfg.meanDark) - setStdDevDark(cfg.stdDevDark) - if (cfg.colors) { - setColors(cfg.colors) - } - } - - // Efficiently memoize computed scales by value-only dependencies - // Support both legacy single value and new anchor format - const valueKey = useMemo( - () => - colors - .map((c) => { - if ('anchors' in c) { - return c.anchors.map((a) => `${a.step}:${a.value}`).join(',') - } - return c.value - }) - .join('|'), - [colors], - ) - const lightScalesMemo = useMemo( - () => - colors.map((c) => { - // Use anchors if available, otherwise use single value - const colorInput = 'anchors' in c ? c.anchors : c.value - return generateColorScale( - colorInput, - lightModeValues, - meanLight, - stdDevLight, - colorFormat, - ) - }), - // Only recompute when color values or generation inputs change (not when names change) - // eslint-disable-next-line react-hooks/exhaustive-deps - [valueKey, lightModeValues, meanLight, stdDevLight, colorFormat], - ) - const darkScalesMemo = useMemo( - () => - colors.map((c) => { - // Use anchors if available, otherwise use single value - const colorInput = 'anchors' in c ? c.anchors : c.value - return generateColorScale( - colorInput, - darkModeValues, - meanDark, - stdDevDark, - colorFormat, + updateURL({ + palettes, + activeTab, + mode: colorScheme, + }) + }, [palettes, activeTab, colorScheme]) + + // Clamp during render if the palette list shrinks (avoids setState-in-effect) + const safeContrastIndex = + contrastPaletteIndex >= palettes.length ? 0 : contrastPaletteIndex + + const lightnessValues = + colorScheme === 'light' + ? lightnessValuesInLightMode + : darknessValuesInDarkMode + + const mean = + colorScheme === 'light' + ? paletteConfig.meanLight + : paletteConfig.meanDark + const stdDev = + colorScheme === 'light' + ? paletteConfig.stdDevLight + : paletteConfig.stdDevDark + + // Generate color scales — pass anchors when available + const generatedPalettes = useMemo(() => { + return palettes.map((p) => { + let steps: string[] + if (p.anchors && p.anchors.length > 0) { + // Multi-anchor: pass ColorAnchor[] directly + steps = generateColorScale( + p.anchors, + lightnessValues, + mean, + stdDev, + 'HEX', ) - }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [valueKey, darkModeValues, meanDark, stdDevDark, colorFormat], - ) - - // Combine fast: map names to memoized scales without recomputing heavy work on name edits - const lightColorScales = useMemo( - () => colors.map((c, i) => ({ ...c, scale: lightScalesMemo[i] })), - [colors, lightScalesMemo], - ) - const darkColorScales = useMemo( - () => colors.map((c, i) => ({ ...c, scale: darkScalesMemo[i] })), - [colors, darkScalesMemo], - ) - - // Select the appropriate scales based on current color scheme - const currentColorScales = - colorScheme === 'light' ? lightColorScales : darkColorScales - const currentScalesOnly = - colorScheme === 'light' ? lightScalesMemo : darkScalesMemo - - // Client flag to avoid hydration mismatch - const isMounted = useIsMounted() - - // Determine if configuration differs from defaults (mean, stdDev, light/dark values, colors) - const isConfigDirty = useMemo(() => { - return ( - meanLight !== config.meanLight || - stdDevLight !== config.stdDevLight || - meanDark !== config.meanDark || - stdDevDark !== config.stdDevDark || - !arraysEqual(lightModeValues, lightnessValuesInLightMode) || - !arraysEqual(darkModeValues, darknessValuesInDarkMode) || - !colorsEqual(colors, config.colors) + } else { + // Simple hex color. In light mode, step 9 is the dark emphasis fill, so + // we pin the exact input color there — the brand color appears in the + // scale. In dark mode the emphasis fill inverts to a light tint (Model 1 + // / canonical EDS: light fill + dark on-emphasis label), so we let step + // 9 follow the dark lightness curve instead of pinning. + const hex = p.baseColor.startsWith('#') + ? p.baseColor + : `#${p.baseColor}` + steps = + colorScheme === 'light' + ? generateColorScale( + [{ value: hex, step: 9 }], + lightnessValues, + mean, + stdDev, + 'HEX', + ) + : generateColorScale(hex, lightnessValues, mean, stdDev, 'HEX') + } + return { name: p.name, steps } + }) + }, [palettes, lightnessValues, mean, stdDev, colorScheme]) + + const handlePalettesChange = useCallback((next: PaletteInput[]) => { + setPalettes(next) + }, []) + + // Download the current palette configuration as JSON (color-palette-config.json). + // Includes both light/dark lightness ramps + Gaussian params so the config can + // be re-imported or fed to the CLI to regenerate the exact palette. + const handleDownloadConfig = useCallback(() => { + const colors: ColorDefinition[] = palettes.map((p) => + p.anchors && p.anchors.length > 0 + ? { name: p.name, anchors: p.anchors } + : { + name: p.name, + value: p.baseColor.startsWith('#') + ? p.baseColor + : `#${p.baseColor}`, + }, ) - }, [ - meanLight, - stdDevLight, - meanDark, - stdDevDark, - lightModeValues, - darkModeValues, - colors, - ]) + downloadConfiguration( + lightnessValuesInLightMode, + darknessValuesInDarkMode, + paletteConfig.meanLight, + paletteConfig.stdDevLight, + paletteConfig.meanDark, + paletteConfig.stdDevDark, + colors, + ) + }, [palettes]) - const contrastSummary = useMemo(() => { - if (!isMounted || !showContrast) { - return { passed: 0, total: 0, percentage: 0 } - } - // Only depend on scales (colors), not names or other metadata - const payload = currentScalesOnly.map((scale) => ({ scale })) - return computeContrastSummary({ - colorScales: payload, - contrastMethod, - enabled: showContrast, - }) - }, [currentScalesOnly, contrastMethod, showContrast, isMounted]) + // Page background = the EDS neutral *canvas* (the "main application + // background" per the EDS colour roles). Content cards sit on top using + // *surface* ("placed on canvas to create depth"), so cards read as elevated + // above the page in both modes — in dark that means #202223 cards lifting off + // a #0b0b0b page, rather than near-black cards sinking into it. + const canvasBg = getSemanticColors(colorScheme)['bg-neutral-canvas'] return ( -
-
- -
- -
- {/* Config Panel */} - {showConfigPanel && ( -
-
-
- - - {showGaussianParameters && ( -
- -
- )} -
-
-
- )} - - {/* Full-width sticky subheader after display options */} -
-
- - {showLightnessInputs && ( - - )} +
+ + +
+ {activeTab === 'fargesystem' ? ( +
+ + + + +
-
- - {/* Render color scales dynamically */} - {isMounted && ( - <> -
- {currentColorScales.map((colorData, index) => { - const color = colors[index] - return ( -
- updateColorName(index, name)} - onChangeValue={(value: string) => - updateColorValue(index, value) - } - onChangeAnchors={(anchors) => - updateColorAnchors(index, anchors) - } - onRemove={() => removeColor(index)} - /> -
- ) - })} -
- - {/* Add new color button */} -
- -
- + ) : activeTab === 'kontrast' ? ( + + ) : ( + )}
- -
- - - {isMounted && showContrast && contrastSummary.total > 0 && ( - <> -
- {`${contrastSummary.passed}/${contrastSummary.total} checks (${contrastSummary.percentage.toFixed(1)}%)`} -
- - )} - {isMounted && isConfigDirty && ( - - )} -
) } + +export default function ThemeBuilderPage() { + return ( + + + + ) +} diff --git a/apps/eds-color-palette-generator/src/app/palette/page.tsx b/apps/eds-color-palette-generator/src/app/palette/page.tsx new file mode 100644 index 0000000000..27e7776242 --- /dev/null +++ b/apps/eds-color-palette-generator/src/app/palette/page.tsx @@ -0,0 +1,199 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react' +import Link from 'next/link' +import { setSimulationPalettes, type TokenPalette } from '@/utils/palette' +import { generatePalettesFromGenerator } from '@/utils/generatorImport' +import { PaletteCard, type PaletteViewMode } from '@/components/palette' + +export default function PalettePage() { + const [palettes, setPalettes] = useState([]) + const [viewMode, setViewMode] = useState('curve') + const [hasAutoImported, setHasAutoImported] = useState(false) + + /* ---- Auto-import primitives from generator on first load ---- */ + useEffect(() => { + if (hasAutoImported) return + // eslint-disable-next-line react-hooks/set-state-in-effect -- one-shot client-only hydration from localStorage; a lazy initializer would run during SSR and cause a hydration mismatch + setHasAutoImported(true) + setPalettes(generatePalettesFromGenerator()) + }, [hasAutoImported]) + + /* ---- Auto-save to localStorage ---- */ + useEffect(() => { + if (!hasAutoImported) return + setSimulationPalettes(palettes) + }, [palettes, hasAutoImported]) + + /* ---- Palette CRUD ---- */ + const addPalette = useCallback( + (p: TokenPalette) => setPalettes((prev) => [...prev, p]), + [], + ) + + const removePalette = useCallback( + (index: number) => + setPalettes((prev) => prev.filter((_, i) => i !== index)), + [], + ) + + const updatePaletteName = useCallback( + (index: number, name: string) => + setPalettes((prev) => + prev.map((p, i) => (i === index ? { ...p, name } : p)), + ), + [], + ) + + const updateStep = useCallback( + (paletteIndex: number, stepIndex: number, hex: string) => + setPalettes((prev) => + prev.map((p, i) => + i === paletteIndex + ? { + ...p, + steps: p.steps.map((s, j) => (j === stepIndex ? hex : s)), + } + : p, + ), + ), + [], + ) + + /* ---- Re-import from generator (replaces all) ---- */ + const reimportFromGenerator = useCallback(() => { + setPalettes(generatePalettesFromGenerator()) + }, []) + + return ( +
+ {/* ---- Header ---- */} +
+
+

Palette Editor

+ +
+ {/* View mode toggle */} +
+ {(['curve', 'gradient'] as const).map((mode) => ( + + ))} +
+ + {/* Nav links */} + + Contrast + + + Example + +
+
+
+ +
+ {/* ---- Actions ---- */} +
+ + +
+ + {/* ---- Palette list ---- */} +
+ {palettes.map((pal, palIdx) => ( + + ))} +
+
+
+ ) +} diff --git a/apps/eds-color-palette-generator/src/components/contrast/CombinedPatternView.tsx b/apps/eds-color-palette-generator/src/components/contrast/CombinedPatternView.tsx new file mode 100644 index 0000000000..8531936c0d --- /dev/null +++ b/apps/eds-color-palette-generator/src/components/contrast/CombinedPatternView.tsx @@ -0,0 +1,196 @@ +import { getApcaFontBreakdown } from '@/utils/palette' +import { Badge } from '@/components/shared/Badge' +import type { PatternGroup } from '@/utils/contrastPageData' + +export function CombinedPatternView({ + patternGroups, +}: { + patternGroups: PatternGroup[] +}) { + return ( +
+ {patternGroups.map((group) => ( +
+

+ {group.title} +

+

+ {group.description} +

+ +
+ {group.pairings.map((p, i) => { + const lc = parseFloat(p.contrast.apca) + const fontBreakdown = getApcaFontBreakdown(lc) + + return ( +
+ {/* State label */} +
+ {p.state} +
+ + {/* Visual preview — adapts to pairing type */} +
+ {p.type === 'border' ? ( + /* Border: show an input-like box */ +
+ Placeholder +
+ ) : p.type === 'fill' ? ( + /* Fill: show a filled rectangle on the canvas */ +
+ ) : ( + /* Text: show Aa */ +
+ + Aa + +
+ )} +
+ + {/* Info */} +
+ {/* Variable labels */} +
+
+ fg:{' '} + {p.fg.label} +
+
+ bg:{' '} + {p.bg.label} +
+
+ + {/* WCAG */} +
+ + {p.contrast.wcag}:1 + + + +
+ + {/* APCA */} +
+ APCA + + Lc {p.contrast.apca} + +
+ + {/* Font sizes */} +
+ {fontBreakdown.map(({ size, minWeightName }) => ( + + {size}px{minWeightName ? ` ${minWeightName}` : ''} + + ))} +
+
+
+ ) + })} +
+
+ ))} +
+ ) +} diff --git a/apps/eds-color-palette-generator/src/components/contrast/ContrastCell.tsx b/apps/eds-color-palette-generator/src/components/contrast/ContrastCell.tsx new file mode 100644 index 0000000000..59b8e5e8c8 --- /dev/null +++ b/apps/eds-color-palette-generator/src/components/contrast/ContrastCell.tsx @@ -0,0 +1,100 @@ +'use client' + +import { useMemo } from 'react' +import { getApcaFontBreakdown, type ContrastResult } from '@/utils/palette' +import { Badge } from '@/components/shared/Badge' + +export function ContrastCell({ + data, + fgColor, + bgColor, + label, +}: { + data: ContrastResult + fgColor: string + bgColor: string + label: string +}) { + const lc = parseFloat(data.apca) + const fontBreakdown = useMemo(() => getApcaFontBreakdown(lc), [lc]) + + return ( +
+
+ + Aa + +
+ +
+ {label} +
+ +
+ + {data.wcag}:1 + + + +
+ +
+
+ APCA + + Lc {data.apca} + +
+ +
+ {fontBreakdown.map(({ size, minWeightName }) => ( + + {size}px{minWeightName ? ` ${minWeightName}` : ''} + + ))} +
+
+
+ ) +} diff --git a/apps/eds-color-palette-generator/src/components/contrast/CopyButton.tsx b/apps/eds-color-palette-generator/src/components/contrast/CopyButton.tsx new file mode 100644 index 0000000000..c869090bc5 --- /dev/null +++ b/apps/eds-color-palette-generator/src/components/contrast/CopyButton.tsx @@ -0,0 +1,36 @@ +'use client' + +import { useState, useCallback } from 'react' + +export function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false) + + const copy = useCallback(async () => { + try { + await navigator.clipboard.writeText(text) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } catch { + /* ignore */ + } + }, [text]) + + return ( + + ) +} diff --git a/apps/eds-color-palette-generator/src/components/contrast/SinglePaletteView.tsx b/apps/eds-color-palette-generator/src/components/contrast/SinglePaletteView.tsx new file mode 100644 index 0000000000..678a781ec6 --- /dev/null +++ b/apps/eds-color-palette-generator/src/components/contrast/SinglePaletteView.tsx @@ -0,0 +1,155 @@ +import { contrast } from '@/utils/color' +import { CATEGORY_GROUPS } from '@/config/categories' +import type { SortOrder, StepData } from '@/utils/contrastPageData' +import { ContrastCell } from './ContrastCell' +import { CopyButton } from './CopyButton' + +export function SinglePaletteView({ + displayData, + mode, +}: { + displayData: StepData[] + mode: SortOrder +}) { + return ( + <> + {/* ---- Category legend + palette strip (shared 15-col grid) ---- */} +
+ {mode === 'semantic' && + CATEGORY_GROUPS.map((group) => ( +
+ + {group.label} + +
+
+ ))} + + {displayData.map((s, i) => { + const labelColor = + parseFloat( + String( + contrast({ + foreground: '#ffffff', + background: s.hex, + algorithm: 'WCAG21', + silent: true, + }), + ), + ) >= 3 + ? '#fff' + : '#000' + return ( +
+ + {s.step} + +
+ ) + })} +
+ + {/* ---- Step detail cards ---- */} +
+ {displayData.map((s) => { + const labelColor = s.recommended.color + + return ( +
+
+
+ {s.step} + {s.role && ( + + {s.role} + + )} + + {s.hex} + + +
+
+ +
+ + +
+
+ ) + })} +
+ + ) +} diff --git a/apps/eds-color-palette-generator/src/components/contrast/index.ts b/apps/eds-color-palette-generator/src/components/contrast/index.ts new file mode 100644 index 0000000000..2f3a575a36 --- /dev/null +++ b/apps/eds-color-palette-generator/src/components/contrast/index.ts @@ -0,0 +1,4 @@ +export { ContrastCell } from './ContrastCell' +export { CopyButton } from './CopyButton' +export { CombinedPatternView } from './CombinedPatternView' +export { SinglePaletteView } from './SinglePaletteView' diff --git a/apps/eds-color-palette-generator/src/components/BellCurveVisualization.tsx b/apps/eds-color-palette-generator/src/components/docs/BellCurveVisualization.tsx similarity index 100% rename from apps/eds-color-palette-generator/src/components/BellCurveVisualization.tsx rename to apps/eds-color-palette-generator/src/components/docs/BellCurveVisualization.tsx diff --git a/apps/eds-color-palette-generator/src/components/ChromaDistributionDemo.tsx b/apps/eds-color-palette-generator/src/components/docs/ChromaDistributionDemo.tsx similarity index 100% rename from apps/eds-color-palette-generator/src/components/ChromaDistributionDemo.tsx rename to apps/eds-color-palette-generator/src/components/docs/ChromaDistributionDemo.tsx diff --git a/apps/eds-color-palette-generator/src/components/ContrastRequirementsTable.tsx b/apps/eds-color-palette-generator/src/components/docs/ContrastRequirementsTable.tsx similarity index 100% rename from apps/eds-color-palette-generator/src/components/ContrastRequirementsTable.tsx rename to apps/eds-color-palette-generator/src/components/docs/ContrastRequirementsTable.tsx diff --git a/apps/eds-color-palette-generator/src/components/docs/about/AboutBestPractices.tsx b/apps/eds-color-palette-generator/src/components/docs/about/AboutBestPractices.tsx new file mode 100644 index 0000000000..a98f47e8e9 --- /dev/null +++ b/apps/eds-color-palette-generator/src/components/docs/about/AboutBestPractices.tsx @@ -0,0 +1,52 @@ +export function AboutBestPractices() { + return ( +
+

Best practices

+
+
    +
  • + + ✓ + +
    +

    + Start with a well-saturated base colour +

    +

    + The Gaussian function scales down from the base chroma. Starting + with higher chroma gives more flexibility. +

    +
    +
  • +
  • + + ✓ + +
    +

    Test contrast ratios regularly

    +

    + Enable contrast checking to ensure all colour combinations meet + accessibility requirements. +

    +
    +
  • + +
  • + + ✓ + +
    +

    + Export and version your configurations +

    +

    + Save your palette configurations to maintain consistency and + track changes over time. +

    +
    +
  • +
+
+
+ ) +} diff --git a/apps/eds-color-palette-generator/src/components/docs/about/AboutChromaDistribution.tsx b/apps/eds-color-palette-generator/src/components/docs/about/AboutChromaDistribution.tsx new file mode 100644 index 0000000000..39efe35729 --- /dev/null +++ b/apps/eds-color-palette-generator/src/components/docs/about/AboutChromaDistribution.tsx @@ -0,0 +1,17 @@ +import { ChromaDistributionDemo } from '@/components/docs/ChromaDistributionDemo' + +export function AboutChromaDistribution() { + return ( +
+

+ Interactive chroma distribution +

+

+ Experiment with different base colours and Gaussian parameters to see + how they affect the final colour scale. The chart shows how chroma + varies across the lightness spectrum. +

+ +
+ ) +} diff --git a/apps/eds-color-palette-generator/src/components/docs/about/AboutConfiguration.tsx b/apps/eds-color-palette-generator/src/components/docs/about/AboutConfiguration.tsx new file mode 100644 index 0000000000..3c8c9e392f --- /dev/null +++ b/apps/eds-color-palette-generator/src/components/docs/about/AboutConfiguration.tsx @@ -0,0 +1,72 @@ +export function AboutConfiguration() { + return ( +
+

Configuration options

+
+
+

Lightness values

+

+ Each step has predefined lightness values for light and dark modes, + based on: +

+
    +
  • + APCA{' '} + contrast requirements +
  • +
  • + WCAG 2.1 + Level AA/AAA guidelines +
  • +
  • Visual hierarchy needs (backgrounds, borders, text)
  • +
  • Interactive state differentiation (default, hover, active)
  • +
+
+ +
+

Gaussian parameters

+
+
+

Mean

+

+ The lightness value where chroma reaches maximum. Typically set + to 0.6 for light mode and 0.7 for dark mode to ensure vibrant + mid-tones. +

+
+
+

Standard deviation

+

+ Controls how quickly chroma decreases away from the mean. Lower + values create sharper peaks (dramatic variation), higher values + create gentler curves (gradual changes). +

+
+
+
+ +
+

+ Separate light/dark modes +

+

+ Light and dark modes use different Gaussian parameters because: +

+
    +
  • + Dark mode typically needs higher chroma in lighter colours to + maintain visibility +
  • +
  • + Light mode benefits from vibrant mid-tones but muted extremes +
  • +
  • + Different background lightness ranges require different chroma + distributions +
  • +
+
+
+
+ ) +} diff --git a/apps/eds-color-palette-generator/src/components/docs/about/AboutContrastRequirements.tsx b/apps/eds-color-palette-generator/src/components/docs/about/AboutContrastRequirements.tsx new file mode 100644 index 0000000000..b346eb6a6d --- /dev/null +++ b/apps/eds-color-palette-generator/src/components/docs/about/AboutContrastRequirements.tsx @@ -0,0 +1,79 @@ +import { ContrastRequirementsTable } from '@/components/docs/ContrastRequirementsTable' + +export function AboutContrastRequirements() { + return ( +
+

+ Colour step pairings and contrast requirements +

+
+

+ Each colour step is designed to work with specific other steps to + ensure accessibility. The configuration defines contrast requirements + using both{' '} + APCA{' '} + (Accessible Perceptual Contrast Algorithm) and{' '} + WCAG 2.1 + standards. +

+

+ These requirements come directly from the configuration file, keeping + documentation in sync with implementation. Each pairing specifies + minimum contrast levels for different use cases — from subtle UI + components to body text. +

+
+ +
+

Understanding the levels:

+
    +
  • + + APCA{' '} + Lc values: + {' '} + Range from 15 (decorative elements) to 90 (body text). Higher values + mean stronger contrast requirements. +
  • +
  • + + WCAG{' '} + ratios: + {' '} + Traditional contrast ratios (3:1, 4.5:1, 7:1) for UI components, + normal text, and large text at AA/AAA levels. +
  • +
+
+ + + +
+

Key insights

+
    +
  • + Background steps (Canvas, Surface) need minimal + contrast between each other but high contrast with text elements +
  • +
  • + Fill elements have progressive contrast + requirements based on emphasis level (muted vs. emphasis) +
  • +
  • + Border steps maintain visual hierarchy through + three levels (subtle, medium, strong) +
  • +
  • + Text elements have the strictest requirements — + different levels for subtle text, strong text, and text on emphasis + backgrounds +
  • +
  • + Interactive states (default, hover, active) are + differentiated through lightness and contrast requirements +
  • +
+
+
+ ) +} diff --git a/apps/eds-color-palette-generator/src/components/docs/about/AboutGaussianBellCurve.tsx b/apps/eds-color-palette-generator/src/components/docs/about/AboutGaussianBellCurve.tsx new file mode 100644 index 0000000000..f16422ac32 --- /dev/null +++ b/apps/eds-color-palette-generator/src/components/docs/about/AboutGaussianBellCurve.tsx @@ -0,0 +1,15 @@ +import { BellCurveVisualization } from '@/components/docs/BellCurveVisualization' + +export function AboutGaussianBellCurve() { + return ( +
+

The Gaussian bell curve

+

+ The bell curve determines how chroma is distributed across lightness + values. Adjust the mean and standard deviation to see how they affect + curve shape and colour intensity at different lightness levels. +

+ +
+ ) +} diff --git a/apps/eds-color-palette-generator/src/components/docs/about/AboutHowItWorks.tsx b/apps/eds-color-palette-generator/src/components/docs/about/AboutHowItWorks.tsx new file mode 100644 index 0000000000..63d3d97f03 --- /dev/null +++ b/apps/eds-color-palette-generator/src/components/docs/about/AboutHowItWorks.tsx @@ -0,0 +1,375 @@ +export function AboutHowItWorks() { + return ( +
+

How it works

+
+
+

+ 1. Define lightness values +

+

+ Each colour scale step has a predefined lightness value chosen to + meet contrast requirements: +

+
    +
  • + Background colours: High lightness in light mode + (0.97–0.999), low in dark mode (0.15–0.25) +
  • +
  • + Border colours: Mid-range values for subtle to + strong emphasis +
  • +
  • + Fill colours: Values optimised for interactive + elements +
  • +
  • + Text colours: Values ensuring{' '} + WCAG{' '} + AA/AAA compliance +
  • +
+

+ Lightness values are configured separately for light and dark modes + to ensure optimal contrast in both. +

+
+ +
+

+ 2. Choose your colour approach +

+

+ You can define colours in two ways: +

+
+
+

Single value (traditional)

+

+ Provide one base colour, and the generator creates all steps + with the same hue, varying only lightness and chroma using + Gaussian distribution. +

+
+
+

+ Multiple anchors (interpolation) +

+

+ Define colours at specific steps (e.g., step 6 and step 9), and + the generator smoothly interpolates between them in{' '} + OKLCH space, + allowing hue to shift naturally while still applying Gaussian + chroma distribution. +

+
+
+
+ +
+

+ 3. Apply Gaussian distribution to chroma +

+

+ While lightness is fixed, chroma (colour intensity) varies using a + Gaussian function. This creates natural colour progression — colours + are most vibrant at a specific lightness level (the mean) and + gradually become more muted as you move away from it. +

+
+ gaussian(x, mean, stdDev) = exp((-25 / stdDev) × (mean - x)²) +
+

+ The function outputs a multiplier (0–1) applied to the base + colour's chroma at each step. +

+
+ +
+

4. Generate colour scale

+

For each step:

+
    +
  1. Convert the base colour(s) to OKLCH colour space
  2. +
  3. Set the lightness to the predefined value for that step
  4. +
  5. + If using multiple anchors, interpolate between them to get the base + colour for this step +
  6. +
  7. + Calculate the chroma multiplier using the Gaussian function with + the step's lightness +
  8. +
  9. + Apply the multiplier to the base colour's chroma: new_chroma = + base_chroma × multiplier +
  10. +
  11. + For single value: Keep the hue unchanged. For multiple anchors: Hue + shifts naturally through interpolation +
  12. +
+

+ This ensures colours maintain visual harmony while adapting intensity + to suit different lightness levels. +

+
+ +
+

+ 5. Multiple colour anchors (interpolation) +

+

+ For more control, you can define multiple colour anchors at specific + steps. The generator will interpolate between them in{' '} + OKLCH space. +

+ + {/* Single Value Example */} +
+

+ Single value (traditional approach) +

+
+
+
+ + + oklch(0.4973 0.084851 204.553) + {' '} + (Moss Green) + +
+

+ The base colour is used for all steps with Gaussian chroma + distribution applied at each lightness level: +

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 1 + 15 +
+
+
+ + {/* Multiple Anchors Example - Using actual generated values from test snapshots */} +
+

+ Multiple anchors (interpolation approach) +

+
+
+
+
+
+
+ oklch(0.5915 0.0731 184.63) +
+
+ Anchor at step 6 +
+
+
+
+
+
+
+ oklch(0.4973 0.084851 204.553) +
+
+ Anchor at step 9 +
+
+
+
+

+ Colours smoothly interpolate between anchors in OKLCH space, with + Gaussian chroma distribution applied: +

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 6 + 9 +
+
+
+ +

+ This technique is useful for creating gradient-like scales where you + want precise control over specific steps while maintaining smooth + transitions between them. The Gaussian chroma distribution is still + applied, but hue and lightness can shift naturally between your + defined anchors. +

+
+
+
+ ) +} diff --git a/apps/eds-color-palette-generator/src/components/docs/about/AboutLearnMore.tsx b/apps/eds-color-palette-generator/src/components/docs/about/AboutLearnMore.tsx new file mode 100644 index 0000000000..75a7a017d4 --- /dev/null +++ b/apps/eds-color-palette-generator/src/components/docs/about/AboutLearnMore.tsx @@ -0,0 +1,84 @@ +export function AboutLearnMore() { + return ( +
+

Learn more

+ +
+ ) +} diff --git a/apps/eds-color-palette-generator/src/components/docs/about/AboutOklchColorSpace.tsx b/apps/eds-color-palette-generator/src/components/docs/about/AboutOklchColorSpace.tsx new file mode 100644 index 0000000000..fc50b2817f --- /dev/null +++ b/apps/eds-color-palette-generator/src/components/docs/about/AboutOklchColorSpace.tsx @@ -0,0 +1,44 @@ +export function AboutOklchColorSpace() { + return ( +
+

Why OKLCH colour space?

+
+

+ OKLCH is a cylindrical + representation of the Oklab colour space, designed to be perceptually + uniform. It has three components: +

+
+
+
Lightness (L)
+
+ Ranges from 0 (black) to 1 (white). Perceptually uniform — 0.5 is + truly medium lightness. +
+
+
+
Chroma (C)
+
+ Colour intensity or saturation. 0 is greyscale, higher values are + more vibrant. Unlike{' '} + HSL, chroma is + consistent across hues. +
+
+
+
Hue (H)
+
+ The colour angle (0–360 degrees). Represents the basic colour + quality (red, green, blue, etc.). +
+
+
+

+ By adjusting lightness and chroma independently while keeping hue + constant, we create colour scales that look natural and maintain + consistent relationships. +

+
+
+ ) +} diff --git a/apps/eds-color-palette-generator/src/components/docs/about/AboutOverview.tsx b/apps/eds-color-palette-generator/src/components/docs/about/AboutOverview.tsx new file mode 100644 index 0000000000..b592667916 --- /dev/null +++ b/apps/eds-color-palette-generator/src/components/docs/about/AboutOverview.tsx @@ -0,0 +1,22 @@ +export function AboutOverview() { + return ( +
+

Overview

+
+

+ The EDS Colour Palette + Generator creates consistent, accessible colour scales for design + systems. It uses a Gaussian (bell curve) distribution to maintain + visual harmony across different lightness levels. +

+

+ The tool works in the{' '} + OKLCH colour space — a + perceptually uniform model where equal numerical changes produce equal + perceived differences. This makes it ideal for programmatic colour + generation. +

+
+
+ ) +} diff --git a/apps/eds-color-palette-generator/src/components/docs/about/AboutTableOfContents.tsx b/apps/eds-color-palette-generator/src/components/docs/about/AboutTableOfContents.tsx new file mode 100644 index 0000000000..6b4b634c8d --- /dev/null +++ b/apps/eds-color-palette-generator/src/components/docs/about/AboutTableOfContents.tsx @@ -0,0 +1,34 @@ +const SECTIONS = [ + { href: '#overview', label: '1. Overview' }, + { href: '#how-it-works', label: '2. How it works' }, + { href: '#gaussian-bell-curve', label: '3. The Gaussian bell curve' }, + { href: '#chroma-distribution', label: '4. Interactive chroma distribution' }, + { href: '#oklch-color-space', label: '5. Why OKLCH color space?' }, + { href: '#configuration', label: '6. Configuration options' }, + { + href: '#contrast-requirements', + label: '7. Color step pairings and contrast requirements', + }, + { href: '#best-practices', label: '8. Best practices' }, + { href: '#learn-more', label: '9. Learn more' }, +] + +export function AboutTableOfContents() { + return ( + + ) +} diff --git a/apps/eds-color-palette-generator/src/components/docs/about/index.ts b/apps/eds-color-palette-generator/src/components/docs/about/index.ts new file mode 100644 index 0000000000..179b55c75b --- /dev/null +++ b/apps/eds-color-palette-generator/src/components/docs/about/index.ts @@ -0,0 +1,10 @@ +export { AboutTableOfContents } from './AboutTableOfContents' +export { AboutOverview } from './AboutOverview' +export { AboutHowItWorks } from './AboutHowItWorks' +export { AboutGaussianBellCurve } from './AboutGaussianBellCurve' +export { AboutChromaDistribution } from './AboutChromaDistribution' +export { AboutOklchColorSpace } from './AboutOklchColorSpace' +export { AboutConfiguration } from './AboutConfiguration' +export { AboutContrastRequirements } from './AboutContrastRequirements' +export { AboutBestPractices } from './AboutBestPractices' +export { AboutLearnMore } from './AboutLearnMore' diff --git a/apps/eds-color-palette-generator/src/components/example/InteractivePicker.tsx b/apps/eds-color-palette-generator/src/components/example/InteractivePicker.tsx new file mode 100644 index 0000000000..f79bc48089 --- /dev/null +++ b/apps/eds-color-palette-generator/src/components/example/InteractivePicker.tsx @@ -0,0 +1,136 @@ +'use client' + +import { useState } from 'react' +import { STEP_ROLES, roleIndex, type StepRole, type TokenPalette } from '@/utils/palette' +import { PairingCard } from './PairingCard' + +const FG_ROLES = STEP_ROLES.filter( + (r) => r.startsWith('text-') || r.startsWith('border-'), +) +const BG_ROLES = STEP_ROLES.filter((r) => r.startsWith('bg-')) + +export function InteractivePicker({ + allPalettes, +}: { + allPalettes: TokenPalette[] +}) { + const [fgRole, setFgRole] = useState('12 · fg/strong') + const [bgRole, setBgRole] = useState('1 · bg/canvas') + + return ( +
+

+ Interactive Picker +

+

+ Select any foreground and background variable to test contrast +

+ +
+ {/* Selector row */} +
+ + + on + + +
+ + {/* Results for each palette */} +
+ {allPalettes.map((pal, i) => { + const fgHex = pal.steps[roleIndex(fgRole)] + const bgHex = pal.steps[roleIndex(bgRole)] + + return ( +
+
+ {pal.name} +
+ +
+ ) + })} +
+
+
+ ) +} diff --git a/apps/eds-color-palette-generator/src/components/example/PairingCard.tsx b/apps/eds-color-palette-generator/src/components/example/PairingCard.tsx new file mode 100644 index 0000000000..eb16762910 --- /dev/null +++ b/apps/eds-color-palette-generator/src/components/example/PairingCard.tsx @@ -0,0 +1,140 @@ +'use client' + +import { useMemo } from 'react' +import { calcContrast, getApcaFontBreakdown } from '@/utils/palette' +import { Badge } from '@/components/shared/Badge' + +export function PairingCard({ + fgRole, + bgRole, + fgHex, + bgHex, + type = 'text', +}: { + fgRole: string + bgRole: string + fgHex: string + bgHex: string + type?: 'text' | 'border' +}) { + const result = useMemo(() => calcContrast(fgHex, bgHex), [fgHex, bgHex]) + const lc = parseFloat(result.apca) + const fontBreakdown = useMemo(() => getApcaFontBreakdown(lc), [lc]) + + return ( +
+ {/* Visual preview */} +
+ {type === 'border' ? ( +
+ Placeholder +
+ ) : ( +
+ + Aa + +
+ )} +
+ + {/* Info */} +
+ {/* Variable labels */} +
+
+ fg: {fgRole} +
+
+ bg: {bgRole} +
+
+ + {/* WCAG */} +
+ + {result.wcag}:1 + + + +
+ + {/* APCA */} +
+ APCA + + Lc {result.apca} + +
+ + {/* Font sizes */} +
+ {fontBreakdown.map(({ size, minWeightName }) => ( + + {size}px{minWeightName ? ` ${minWeightName}` : ''} + + ))} +
+
+
+ ) +} diff --git a/apps/eds-color-palette-generator/src/components/example/PredefinedGroups.tsx b/apps/eds-color-palette-generator/src/components/example/PredefinedGroups.tsx new file mode 100644 index 0000000000..a3279a463f --- /dev/null +++ b/apps/eds-color-palette-generator/src/components/example/PredefinedGroups.tsx @@ -0,0 +1,48 @@ +import { roleIndex, type TokenPalette } from '@/utils/palette' +import { EXAMPLE_GROUPS } from './exampleGroups' +import { PairingCard } from './PairingCard' + +export function PredefinedGroups({ palette }: { palette: TokenPalette }) { + return ( +
+ {EXAMPLE_GROUPS.map((group) => ( +
+

+ {group.title} +

+

+ {group.description} +

+ +
+ {group.pairings.map((p, i) => ( + + ))} +
+
+ ))} +
+ ) +} diff --git a/apps/eds-color-palette-generator/src/components/example/SurfacePreview.tsx b/apps/eds-color-palette-generator/src/components/example/SurfacePreview.tsx new file mode 100644 index 0000000000..ee166d873d --- /dev/null +++ b/apps/eds-color-palette-generator/src/components/example/SurfacePreview.tsx @@ -0,0 +1,370 @@ +import { + STEP_ROLES, + calcContrast, + roleIndex, + type StepRole, +} from '@/utils/palette' +import { Badge } from '@/components/shared/Badge' + +/* ------------------------------------------------------------------ */ +/* Surface layer roles for the layout preview */ +/* ------------------------------------------------------------------ */ + +export const SURFACE_ROLES = STEP_ROLES.filter((r) => r.startsWith('bg-')) +export const BORDER_ROLES = STEP_ROLES.filter((r) => r.startsWith('border-')) + +export type SurfaceConfig = { + page: StepRole + panel: StepRole + cardRow: StepRole + card: StepRole + border: StepRole + text: StepRole +} + +export const DEFAULT_SURFACE: SurfaceConfig = { + page: '1 · bg/canvas', + panel: '2 · bg/surface', + cardRow: '3 · bg/surface-hover', + card: '1 · bg/canvas', + border: '5 · bg/interactive', + text: '12 · fg/strong', +} + +/** Recommended roles per surface layer — options outside this set still work + * but are flagged as atypical in the dropdown. */ +export const RECOMMENDED: Record> = { + page: new Set([ + '1 · bg/canvas', + '2 · bg/surface', + '15 · bg/card, sheet, popover', + ]), + panel: new Set([ + '1 · bg/canvas', + '2 · bg/surface', + '15 · bg/card, sheet, popover', + ]), + cardRow: new Set([ + '1 · bg/canvas', + '2 · bg/surface', + '3 · bg/surface-hover', + '4 · bg/surface-pressed', + ]), + card: new Set([ + '1 · bg/canvas', + '2 · bg/surface', + '15 · bg/card, sheet, popover', + ]), + border: new Set([ + '4 · bg/surface-pressed', + '5 · bg/interactive', + '7 · border/subtle', + '8 · border/default', + ]), + text: new Set([ + '12 · fg/strong', + '7 · border/subtle', + '8 · border/default', + ]), +} + +function SurfaceLabel({ + label, + role, + hex, +}: { + label: string + role: string + hex: string +}) { + return ( +
+ + + {label}{' '} + + {role} + + +
+ ) +} + +export function SurfaceSelect({ + label, + value, + onChange, + options, + recommended, +}: { + label: string + value: StepRole + onChange: (v: StepRole) => void + options: readonly StepRole[] + recommended?: ReadonlySet +}) { + return ( + + ) +} + +export function SurfacePreview({ + config, + steps, +}: { + config: SurfaceConfig + steps: string[] +}) { + const pageHex = steps[roleIndex(config.page)] + const panelHex = steps[roleIndex(config.panel)] + const cardRowHex = steps[roleIndex(config.cardRow)] + const cardHex = steps[roleIndex(config.card)] + const borderHex = steps[roleIndex(config.border)] + const textHex = steps[roleIndex(config.text)] + + const contrastPagePanel = calcContrast(panelHex, pageHex) + const contrastPanelCardRow = calcContrast(cardRowHex, panelHex) + const contrastCardRowCard = calcContrast(cardHex, cardRowHex) + const contrastBorderCard = calcContrast(borderHex, cardHex) + const contrastTextCard = calcContrast(textHex, cardHex) + + return ( +
+ {/* Live layout wireframe */} +
+ + + {/* Panel */} +
+ + + {/* Card row */} +
+ + + {/* Cards */} +
+ {[0, 1, 2].map((i) => ( +
+
+ Card title +
+
+ Body text content +
+
+ ))} +
+
+
+
+ + {/* Contrast ratios between adjacent layers */} +
+ {[ + { + label: 'Page / Panel', + result: contrastPagePanel, + a: pageHex, + b: panelHex, + }, + { + label: 'Panel / Card row', + result: contrastPanelCardRow, + a: panelHex, + b: cardRowHex, + }, + { + label: 'Card row / Card', + result: contrastCardRowCard, + a: cardRowHex, + b: cardHex, + }, + { + label: 'Border / Card', + result: contrastBorderCard, + a: borderHex, + b: cardHex, + }, + { + label: 'Text / Card', + result: contrastTextCard, + a: textHex, + b: cardHex, + }, + ].map(({ label, result, a, b }) => ( +
+
+ + + + {label} + +
+
+ + {result.wcag}:1 + + + +
+
+ ))} +
+
+ ) +} diff --git a/apps/eds-color-palette-generator/src/components/example/SurfacePreviewSection.tsx b/apps/eds-color-palette-generator/src/components/example/SurfacePreviewSection.tsx new file mode 100644 index 0000000000..7e59f0d8da --- /dev/null +++ b/apps/eds-color-palette-generator/src/components/example/SurfacePreviewSection.tsx @@ -0,0 +1,140 @@ +'use client' + +import { useState } from 'react' +import { STEP_ROLES, type TokenPalette } from '@/utils/palette' +import { + BORDER_ROLES, + DEFAULT_SURFACE, + RECOMMENDED, + SURFACE_ROLES, + SurfacePreview, + SurfaceSelect, + type SurfaceConfig, +} from './SurfacePreview' + +export function SurfacePreviewSection({ + allPalettes, +}: { + allPalettes: TokenPalette[] +}) { + const [surfaceConfig, setSurfaceConfig] = + useState(DEFAULT_SURFACE) + + return ( +
+

+ Surface Preview +

+

+ See how background layers, borders, and text stack in a real layout +

+ +
+ {/* Layer selectors */} +
+ setSurfaceConfig((c) => ({ ...c, page: v }))} + options={SURFACE_ROLES} + recommended={RECOMMENDED.page} + /> + setSurfaceConfig((c) => ({ ...c, panel: v }))} + options={SURFACE_ROLES} + recommended={RECOMMENDED.panel} + /> + setSurfaceConfig((c) => ({ ...c, cardRow: v }))} + options={SURFACE_ROLES} + recommended={RECOMMENDED.cardRow} + /> + setSurfaceConfig((c) => ({ ...c, card: v }))} + options={SURFACE_ROLES} + recommended={RECOMMENDED.card} + /> + setSurfaceConfig((c) => ({ ...c, border: v }))} + options={BORDER_ROLES} + recommended={RECOMMENDED.border} + /> + setSurfaceConfig((c) => ({ ...c, text: v }))} + options={STEP_ROLES.filter((r) => r.startsWith('text-'))} + recommended={RECOMMENDED.text} + /> + +
+ + {/* Preview area — one per palette */} +
+ {allPalettes.map((pal, palIdx) => ( +
+
+ {pal.name} +
+ +
+ ))} +
+
+
+ ) +} diff --git a/apps/eds-color-palette-generator/src/components/example/exampleGroups.ts b/apps/eds-color-palette-generator/src/components/example/exampleGroups.ts new file mode 100644 index 0000000000..2b23094d78 --- /dev/null +++ b/apps/eds-color-palette-generator/src/components/example/exampleGroups.ts @@ -0,0 +1,48 @@ +import type { StepRole } from '@/utils/palette' + +export type ExamplePairing = { + fg: StepRole + bg: StepRole + type?: 'text' | 'border' +} + +export type ExampleGroup = { + title: string + description: string + pairings: ExamplePairing[] +} + +export const EXAMPLE_GROUPS: ExampleGroup[] = [ + { + title: 'Text on interactive fills', + description: + 'Text on muted fill backgrounds across default/hover/active states', + pairings: [ + { fg: '12 · fg/strong', bg: '3 · bg/surface-hover' }, + { fg: '12 · fg/strong', bg: '4 · bg/surface-pressed' }, + { fg: '12 · fg/strong', bg: '5 · bg/interactive' }, + ], + }, + { + title: 'Text on emphasis fills', + description: 'Light text on dark emphasis backgrounds', + pairings: [ + { fg: '15 · bg/card, sheet, popover', bg: '9 · fill/emphasis' }, + { fg: '15 · bg/card, sheet, popover', bg: '10 · fill/emphasis-hover' }, + { fg: '15 · bg/card, sheet, popover', bg: '11 · fill/emphasis-pressed' }, + { fg: '14 · fg/on-emphasis', bg: '9 · fill/emphasis' }, + ], + }, + { + title: 'Borders on backgrounds', + description: 'Border visibility on different surfaces', + pairings: [ + { fg: '5 · bg/interactive', bg: '1 · bg/canvas', type: 'border' }, + { fg: '7 · border/subtle', bg: '1 · bg/canvas', type: 'border' }, + { fg: '8 · border/default', bg: '1 · bg/canvas', type: 'border' }, + { fg: '5 · bg/interactive', bg: '2 · bg/surface', type: 'border' }, + { fg: '7 · border/subtle', bg: '2 · bg/surface', type: 'border' }, + { fg: '8 · border/default', bg: '2 · bg/surface', type: 'border' }, + ], + }, +] diff --git a/apps/eds-color-palette-generator/src/components/example/index.ts b/apps/eds-color-palette-generator/src/components/example/index.ts new file mode 100644 index 0000000000..c1364119d2 --- /dev/null +++ b/apps/eds-color-palette-generator/src/components/example/index.ts @@ -0,0 +1,3 @@ +export { PredefinedGroups } from './PredefinedGroups' +export { SurfacePreviewSection } from './SurfacePreviewSection' +export { InteractivePicker } from './InteractivePicker' diff --git a/apps/eds-color-palette-generator/src/components/AnchorColorInput.tsx b/apps/eds-color-palette-generator/src/components/old/AnchorColorInput.tsx similarity index 100% rename from apps/eds-color-palette-generator/src/components/AnchorColorInput.tsx rename to apps/eds-color-palette-generator/src/components/old/AnchorColorInput.tsx diff --git a/apps/eds-color-palette-generator/src/components/ColorScale.tsx b/apps/eds-color-palette-generator/src/components/old/ColorScale.tsx similarity index 100% rename from apps/eds-color-palette-generator/src/components/ColorScale.tsx rename to apps/eds-color-palette-generator/src/components/old/ColorScale.tsx diff --git a/apps/eds-color-palette-generator/src/components/ColorScalesHeader.tsx b/apps/eds-color-palette-generator/src/components/old/ColorScalesHeader.tsx similarity index 100% rename from apps/eds-color-palette-generator/src/components/ColorScalesHeader.tsx rename to apps/eds-color-palette-generator/src/components/old/ColorScalesHeader.tsx diff --git a/apps/eds-color-palette-generator/src/components/DisplayOptionsPanel.tsx b/apps/eds-color-palette-generator/src/components/old/DisplayOptionsPanel.tsx similarity index 100% rename from apps/eds-color-palette-generator/src/components/DisplayOptionsPanel.tsx rename to apps/eds-color-palette-generator/src/components/old/DisplayOptionsPanel.tsx diff --git a/apps/eds-color-palette-generator/src/components/GaussianParametersPanel.tsx b/apps/eds-color-palette-generator/src/components/old/GaussianParametersPanel.tsx similarity index 100% rename from apps/eds-color-palette-generator/src/components/GaussianParametersPanel.tsx rename to apps/eds-color-palette-generator/src/components/old/GaussianParametersPanel.tsx diff --git a/apps/eds-color-palette-generator/src/components/HeaderPanel.tsx b/apps/eds-color-palette-generator/src/components/old/HeaderPanel.tsx similarity index 51% rename from apps/eds-color-palette-generator/src/components/HeaderPanel.tsx rename to apps/eds-color-palette-generator/src/components/old/HeaderPanel.tsx index 0c1fa4693e..d6a5a1575d 100644 --- a/apps/eds-color-palette-generator/src/components/HeaderPanel.tsx +++ b/apps/eds-color-palette-generator/src/components/old/HeaderPanel.tsx @@ -1,6 +1,6 @@ import React from 'react' -import { ThemeToggle } from './ThemeToggle' -import { Settings, Info } from 'lucide-react' +import { ThemeToggle } from '@/components/shared/ThemeToggle' +import { Settings, Info, BarChart3, Palette, Layers, Paintbrush } from 'lucide-react' import Link from 'next/link' type HeaderPanelProps = { @@ -22,6 +22,38 @@ export const HeaderPanel = ({

+ + + Theme Builder + + + + Contrast + + + + Example + + + + Palette + = 3 ? '#fff' : '#000' + } catch { + return '#000' + } +} + +function isValidHex(v: string): boolean { + return /^#[0-9a-fA-F]{6}$/.test(v) +} + +export function PaletteCard({ + palette, + index, + viewMode, + onNameChange, + onRemove, + onStepChange, +}: { + palette: TokenPalette + index: number + viewMode: PaletteViewMode + onNameChange: (index: number, name: string) => void + onRemove: (index: number) => void + onStepChange: (paletteIndex: number, stepIndex: number, hex: string) => void +}) { + const sorted = + viewMode === 'curve' + ? palette.steps.map((hex, i) => ({ hex, i })) + : palette.steps + .map((hex, i) => ({ hex, i })) + .sort((a, b) => getLightness(b.hex) - getLightness(a.hex)) + + return ( +
+ {/* Palette header */} +
+ onNameChange(index, e.target.value)} + style={{ + fontSize: '15px', + fontWeight: 600, + border: 'none', + background: 'transparent', + outline: 'none', + padding: '4px 0', + width: '200px', + }} + /> + +
+ + {/* Color strip */} +
+
+ {/* Category group headers (curve mode only) */} + {viewMode === 'curve' && + CATEGORY_GROUPS.map((group) => ( +
+ + {group.label} + +
+
+ ))} + + {/* Swatch cells */} + {sorted.map(({ hex, i: origIdx }, displayIdx) => ( +
+ + {origIdx + 1} + +
+ ))} +
+
+ + {/* Hex input grid */} +
+ {sorted.map(({ hex, i: origIdx }) => ( +
+ +
+ + { + const v = e.target.value + onStepChange(index, origIdx, v) + }} + style={{ + fontSize: '12px', + fontFamily: 'var(--font-geist-mono, monospace)', + padding: '4px 6px', + borderRadius: '4px', + border: `1.5px solid ${isValidHex(hex) ? '#d1d5db' : '#fca5a5'}`, + background: '#fff', + width: '100%', + minWidth: 0, + }} + /> +
+
+ ))} +
+
+ ) +} diff --git a/apps/eds-color-palette-generator/src/components/palette/index.ts b/apps/eds-color-palette-generator/src/components/palette/index.ts new file mode 100644 index 0000000000..869c154f7e --- /dev/null +++ b/apps/eds-color-palette-generator/src/components/palette/index.ts @@ -0,0 +1 @@ +export { PaletteCard, type PaletteViewMode } from './PaletteCard' diff --git a/apps/eds-color-palette-generator/src/components/shared/Badge.tsx b/apps/eds-color-palette-generator/src/components/shared/Badge.tsx new file mode 100644 index 0000000000..3d26a9688a --- /dev/null +++ b/apps/eds-color-palette-generator/src/components/shared/Badge.tsx @@ -0,0 +1,39 @@ +type BadgeVariant = 'pass-fail' | 'level' + +type BadgeProps = { + pass: boolean + label: string + variant?: BadgeVariant +} + +export function Badge({ pass, label, variant = 'pass-fail' }: BadgeProps) { + // EDS concept tokens rather than fixed hex, so pass/fail badges adapt to + // light and dark mode (the tokens switch under [data-color-scheme=dark]). + const bgColor = pass + ? 'var(--eds-color-bg-success-fill-muted-default)' + : 'var(--eds-color-bg-danger-fill-muted-default)' + const textColor = pass + ? 'var(--eds-color-text-success-subtle)' + : 'var(--eds-color-text-danger-subtle)' + + // Level variant uses info (blue) for informational badges (DECO, AA18) + const isInfo = variant === 'level' && pass + const finalBg = isInfo ? 'var(--eds-color-bg-info-fill-muted-default)' : bgColor + const finalColor = isInfo ? 'var(--eds-color-text-info-subtle)' : textColor + + return ( + + {label} + + ) +} diff --git a/apps/eds-color-palette-generator/src/components/shared/CVDFilter.tsx b/apps/eds-color-palette-generator/src/components/shared/CVDFilter.tsx new file mode 100644 index 0000000000..63e7c20174 --- /dev/null +++ b/apps/eds-color-palette-generator/src/components/shared/CVDFilter.tsx @@ -0,0 +1,25 @@ +import type { CSSProperties } from 'react' +import { CVD_MATRICES, type CVDType } from '@/utils/cvd' + +/** + * Renders the SVG definition for a colour-vision-deficiency + * simulation. Pair with {@link cvdFilterStyle} to apply it to any preview + * region — reusable beyond the data-colour chart (status previews, swatches). + */ +export function CVDFilter({ type }: { type: CVDType }) { + if (type === 'none') return null + return ( + + + + + + + + ) +} + +/** The inline style that applies the matching CVD filter, or undefined. */ +export function cvdFilterStyle(type: CVDType): CSSProperties | undefined { + return type === 'none' ? undefined : { filter: `url(#cvd-${type})` } +} diff --git a/apps/eds-color-palette-generator/src/components/ThemeToggle.tsx b/apps/eds-color-palette-generator/src/components/shared/ThemeToggle.tsx similarity index 100% rename from apps/eds-color-palette-generator/src/components/ThemeToggle.tsx rename to apps/eds-color-palette-generator/src/components/shared/ThemeToggle.tsx diff --git a/apps/eds-color-palette-generator/src/components/themebuilder/ButtonPreview.tsx b/apps/eds-color-palette-generator/src/components/themebuilder/ButtonPreview.tsx new file mode 100644 index 0000000000..9246a28d8a --- /dev/null +++ b/apps/eds-color-palette-generator/src/components/themebuilder/ButtonPreview.tsx @@ -0,0 +1,147 @@ +'use client' + +import { Fragment } from 'react' +import type { SemanticColors } from '@/config/semanticColors' + +type ButtonPreviewProps = { + colors: SemanticColors +} + +type StateStyle = { bg: string; text: string; border: string } + +const STATES = ['default', 'hover', 'pressed'] as const + +/** + * Button variants × interaction states, following the Figma dark button spec. + * Ghost/outlined use the accent colour for text (and outlined border), with + * subtle accent-tinted fills on hover/pressed: + * + * Solid bg = bg-accent-fill-emphasis-{default,hover,active} text = text-accent-strong-on-emphasis + * Outlined transparent → bg-accent-surface → bg-accent-fill-muted-default + * border = accent text = accent + * Ghost transparent → bg-accent-surface → bg-accent-fill-muted-default + * text = accent + * + * Figma binds ghost/outlined label to `text/accent` and the outlined border to + * `background/surface/accent/default/default` — both resolve to the accent + * emphasis colour, so we use `bg-accent-fill-emphasis-default` as `accent`. + * The hover/pressed fills are Figma's `.../selected/hover` and `.../selected/pressed`. + */ +function buildVariants(c: SemanticColors): Array<{ + name: string + states: Record<(typeof STATES)[number], StateStyle> +}> { + const onEmphasis = c['text-accent-strong-on-emphasis'] + const accent = c['bg-accent-fill-emphasis-default'] + const subtleHover = c['bg-accent-surface'] + const subtlePressed = c['bg-accent-fill-muted-default'] + + return [ + { + name: 'Solid', + states: { + default: { + bg: c['bg-accent-fill-emphasis-default'], + text: onEmphasis, + border: 'transparent', + }, + hover: { + bg: c['bg-accent-fill-emphasis-hover'], + text: onEmphasis, + border: 'transparent', + }, + pressed: { + bg: c['bg-accent-fill-emphasis-active'], + text: onEmphasis, + border: 'transparent', + }, + }, + }, + { + name: 'Outlined', + states: { + default: { bg: 'transparent', text: accent, border: accent }, + hover: { bg: subtleHover, text: accent, border: accent }, + pressed: { bg: subtlePressed, text: accent, border: accent }, + }, + }, + { + name: 'Ghost', + states: { + default: { bg: 'transparent', text: accent, border: 'transparent' }, + hover: { bg: subtleHover, text: accent, border: 'transparent' }, + pressed: { + bg: subtlePressed, + text: accent, + border: 'transparent', + }, + }, + }, + ] +} + +export function ButtonPreview({ colors }: ButtonPreviewProps) { + const variants = buildVariants(colors) + const labelColor = colors['text-neutral-strong'] + + return ( +
+ {/* Header row: interaction states */} +
+ {STATES.map((state) => ( +
+ {state} +
+ ))} + + {/* One row per variant */} + {variants.map((variant) => ( + +
+ {variant.name} +
+ {STATES.map((state) => { + const s = variant.states[state] + return ( +
+ + Label + +
+ ) + })} +
+ ))} +
+ ) +} diff --git a/apps/eds-color-palette-generator/src/components/themebuilder/CardPreview.tsx b/apps/eds-color-palette-generator/src/components/themebuilder/CardPreview.tsx new file mode 100644 index 0000000000..c491a6640d --- /dev/null +++ b/apps/eds-color-palette-generator/src/components/themebuilder/CardPreview.tsx @@ -0,0 +1,124 @@ +'use client' + +import type { SemanticColors } from '@/config/semanticColors' + +type CardPreviewProps = { + colors: SemanticColors +} + +/** + * Wired to canonical EDS semantic tokens (Figma Color Map): + * card → bg-neutral-surface + border-neutral-subtle + * accent bar→ bg-accent-fill-emphasis-default + * tag → bg-{concept}-fill-muted-default + text-{concept}-subtle + border-{concept}-subtle + * title/body→ text-neutral-strong / text-neutral-subtle + * link → text-link + */ +export function CardPreview({ colors: c }: CardPreviewProps) { + const cards = [ + { + concept: 'info', + tag: 'Technology', + title: 'Building Design Systems', + body: 'A comprehensive guide to creating scalable and consistent design tokens.', + }, + { + concept: 'accent', + tag: 'Design', + title: 'Color Theory in UI', + body: 'How perceptual color spaces like OKLCH improve accessibility.', + }, + { + concept: 'success', + tag: 'Engineering', + title: 'Accessible Palettes', + body: 'Using Gaussian chroma distribution for balanced color scales.', + }, + ] + + return ( +
+ {cards.map((card) => ( +
+ {/* Accent bar */} +
+ +
+ {/* Tag — concept muted fill */} + + {card.tag} + + + {/* Title */} +
+ {card.title} +
+ + {/* Body */} +
+ {card.body} +
+ + {/* Link */} +
+ Read more +
+
+
+ ))} +
+ ) +} diff --git a/apps/eds-color-palette-generator/src/components/themebuilder/ComponentPreviewPanel.tsx b/apps/eds-color-palette-generator/src/components/themebuilder/ComponentPreviewPanel.tsx new file mode 100644 index 0000000000..5fa83c9430 --- /dev/null +++ b/apps/eds-color-palette-generator/src/components/themebuilder/ComponentPreviewPanel.tsx @@ -0,0 +1,152 @@ +'use client' + +import { useState } from 'react' +import { LoginFormPreview } from './LoginFormPreview' +import { DataTablePreview } from './DataTablePreview' +import { CardPreview } from './CardPreview' +import { ButtonPreview } from './ButtonPreview' +import { useColorScheme } from '@/context/ColorSchemeContext' +import { getSemanticColors } from '@/config/semanticColors' + +type GeneratedPalette = { + name: string + steps: string[] +} + +type ComponentPreviewPanelProps = { + palettes: GeneratedPalette[] +} + +// Gray is always the neutral — find it by name +function findNeutralIndex(palettes: GeneratedPalette[]): number { + const grayIdx = palettes.findIndex( + (p) => + p.name.toLowerCase().includes('gray') || + p.name.toLowerCase().includes('grey'), + ) + return grayIdx >= 0 ? grayIdx : 0 +} + +// Default accent: first non-gray palette, else the neutral itself +function defaultAccentIndex(palettes: GeneratedPalette[]): number { + if (palettes.length === 0) return 0 + const neutralIdx = findNeutralIndex(palettes) + const firstNonNeutral = palettes.findIndex((_, i) => i !== neutralIdx) + return firstNonNeutral >= 0 ? firstNonNeutral : neutralIdx +} + +export function ComponentPreviewPanel({ + palettes, +}: ComponentPreviewPanelProps) { + const [accentIdx, setAccentIdx] = useState(() => defaultAccentIndex(palettes)) + const { colorScheme } = useColorScheme() + // Canonical EDS semantic colours (from Figma Color Map) — the previews use + // these directly so they render like real EDS variable usage. + const colors = getSemanticColors(colorScheme) + + if (palettes.length === 0) return null + + const neutralIdx = findNeutralIndex(palettes) + const neutral = palettes[neutralIdx].steps + + // Non-gray palettes are candidates for accent + const accentCandidates = palettes + .map((p, i) => ({ ...p, idx: i })) + .filter((_, i) => i !== neutralIdx) + + // All palettes except Gray and the selected accent are data colors + const dataColors = palettes.filter( + (_, i) => i !== neutralIdx && i !== accentIdx, + ) + + return ( +
+ {/* Role selector */} +
+

Palette Roles

+
+
+ Neutral + + + {palettes[neutralIdx].name} + +
+ + + + {dataColors.length > 0 && ( +
+ Data colors: + {dataColors.map((p) => ( + + {p.name} + + ))} +
+ )} +
+
+ + {/* Buttons */} +
+

Buttons

+ +
+ + {/* Login Form */} +
+

Login Form

+ +
+ + {/* Data Table */} +
+

Data Table

+ +
+ + {/* Cards */} +
+

Article Cards

+ +
+
+ ) +} diff --git a/apps/eds-color-palette-generator/src/components/themebuilder/ContrastTable.tsx b/apps/eds-color-palette-generator/src/components/themebuilder/ContrastTable.tsx new file mode 100644 index 0000000000..57b41a76f8 --- /dev/null +++ b/apps/eds-color-palette-generator/src/components/themebuilder/ContrastTable.tsx @@ -0,0 +1,287 @@ +'use client' + +import { useMemo } from 'react' +import { calcContrast } from '@/utils/palette' +import { STEP_ROLES } from '@/utils/palette' +import { Badge } from '@/components/shared/Badge' + +type GeneratedPalette = { + name: string + steps: string[] +} + +type ContrastTableProps = { + palettes: GeneratedPalette[] + activePaletteIndex: number + onActivePaletteChange: (index: number) => void +} + +/** + * Background / surface tokens (contrast columns) as used in the EDS UI. + * `step` is 1-indexed into the palette scale. + */ +const BG_GROUPS = [ + { + group: 'accent / default', + cols: [ + { label: 'default', step: 9 }, + { label: 'hover', step: 10 }, + { label: 'pressed', step: 11 }, + ], + }, + { + group: 'accent / subtle', + cols: [ + { label: 'default', step: 1 }, + { label: 'hover', step: 2 }, + { label: 'pressed', step: 3 }, + ], + }, + { + group: 'accent / selected', + cols: [ + { label: 'default', step: 4 }, + { label: 'hover', step: 5 }, + { label: 'pressed', step: 6 }, + ], + }, +] as const + +const BG_COLUMNS = BG_GROUPS.flatMap((g) => + g.cols.map((c) => ({ ...c, group: g.group })), +) + +/** + * Text / foreground tokens (contrast rows) as named in the EDS UI. + * `step` is 1-indexed into the palette scale. `source` decides which palette + * the text color is pulled from: `'neutral'` (the Gray ramp — e.g. gray text + * on an accent background) or `'accent'` (the active/surface palette). + */ +const FG_TOKENS = [ + { label: 'primary', step: 12, source: 'neutral' }, + { label: 'secondary', step: 8, source: 'neutral' }, + { label: 'muted', step: 7, source: 'neutral' }, + { label: 'disabled', step: 5, source: 'neutral' }, + { label: 'accent', step: 9, source: 'accent' }, + { label: 'pressed', step: 10, source: 'accent' }, + { label: 'on-accent', step: 14, source: 'neutral' }, + { label: 'color on dark', step: 15, source: 'neutral' }, + { label: 'color on light', step: 12, source: 'neutral' }, +] as const + +function getContrastLevel( + wcagRatio: number, +): { label: string; pass: boolean } { + if (wcagRatio >= 7) return { label: 'AAA', pass: true } + if (wcagRatio >= 4.5) return { label: 'AA', pass: true } + if (wcagRatio >= 3) return { label: 'AA18', pass: true } + if (wcagRatio >= 1.5) return { label: 'DECO', pass: true } + return { label: 'FAIL', pass: false } +} + +export function ContrastTable({ + palettes, + activePaletteIndex, + onActivePaletteChange, +}: ContrastTableProps) { + const palette = palettes[activePaletteIndex] + + // Neutral text (primary/secondary/muted/…) is pulled from the Gray ramp when + // one exists, so we can check e.g. gray text on an accent surface. Falls back + // to the active palette when there is no Gray palette. + const neutralPalette = useMemo( + () => palettes.find((p) => /^gr[ae]y$/i.test(p.name.trim())) ?? palette, + [palettes, palette], + ) + + const grid = useMemo(() => { + if (!palette) return [] + return FG_TOKENS.map((fg) => { + const fgPalette = fg.source === 'neutral' ? neutralPalette : palette + const fgHex = fgPalette.steps[fg.step - 1] + return { + ...fg, + fgHex, + fgPaletteName: fgPalette.name, + crossPalette: fgPalette !== palette, + cells: BG_COLUMNS.map((bg) => { + const bgHex = palette.steps[bg.step - 1] + const result = calcContrast(fgHex, bgHex) + const level = getContrastLevel(parseFloat(result.wcag)) + return { ...bg, bgHex, wcag: result.wcag, level } + }), + } + }) + }, [palette, neutralPalette]) + + if (!palette) return null + + return ( +
+
+

Contrast Table

+ + {palettes.length > 1 && ( +
+ {palettes.map((p, i) => ( + + ))} +
+ )} +
+ +
+ + + {/* Group headers — background/surface token families */} + + + ))} + + {/* State + swatch + step per background column */} + + + {BG_COLUMNS.map((col, i) => ( + + ))} + + + + {grid.map((row) => ( + + + {row.cells.map((cell, i) => ( + + ))} + + ))} + +
+ {BG_GROUPS.map((group) => ( + + {group.group} +
+ text \ surface + +
+ + {col.label} + {col.step} +
+
+
+ + + {row.label} + + + {row.crossPalette + ? `${row.fgPaletteName}/${row.step}` + : row.step} + +
+
+
+ + + {cell.wcag} + +
+
+
+
+ ) +} diff --git a/apps/eds-color-palette-generator/src/components/themebuilder/ContrastTestPanel.tsx b/apps/eds-color-palette-generator/src/components/themebuilder/ContrastTestPanel.tsx new file mode 100644 index 0000000000..64e532502f --- /dev/null +++ b/apps/eds-color-palette-generator/src/components/themebuilder/ContrastTestPanel.tsx @@ -0,0 +1,71 @@ +'use client' + +import { useState, useMemo } from 'react' +import { SemanticPairings } from './contrast/SemanticPairings' +import { SurfacePreview } from './contrast/SurfacePreview' +import { InteractivePicker } from './contrast/InteractivePicker' +import { DataColorChart } from './contrast/DataColorChart' +import { SamePalettePairings } from './contrast/SamePalettePairings' + +type GeneratedPalette = { + name: string + steps: string[] +} + +type ContrastTestPanelProps = { + palettes: GeneratedPalette[] +} + +type SubTab = 'eds' | 'custom' + +export function ContrastTestPanel({ palettes }: ContrastTestPanelProps) { + const stablePalettes = useMemo(() => palettes, [palettes]) + const [subTab, setSubTab] = useState('eds') + + if (stablePalettes.length === 0) return null + + return ( +
+ {/* Sub-tab toggle */} +
+ + +
+ + {subTab === 'eds' ? ( +
+ + +
+ ) : ( +
+ + + +
+ )} +
+ ) +} diff --git a/apps/eds-color-palette-generator/src/components/themebuilder/DataTablePreview.tsx b/apps/eds-color-palette-generator/src/components/themebuilder/DataTablePreview.tsx new file mode 100644 index 0000000000..0c66accce1 --- /dev/null +++ b/apps/eds-color-palette-generator/src/components/themebuilder/DataTablePreview.tsx @@ -0,0 +1,124 @@ +'use client' + +import { useId, useState } from 'react' +import type { SemanticColors } from '@/config/semanticColors' + +type DataTablePreviewProps = { + colors: SemanticColors +} + +/** + * Wired to canonical EDS semantic tokens (Figma Color Map): + * row hover / pressed → bg-neutral-fill-muted-{default,hover} + * selected row → bg-accent-fill-muted-default + solid-accent left bar + * separator → border-neutral-subtle + * name / value → text-neutral-strong / text-neutral-subtle + * status → text-{success,warning,danger}-subtle + */ +export function DataTablePreview({ colors: c }: DataTablePreviewProps) { + const uid = useId().replace(/:/g, '') + const [activeRow, setActiveRow] = useState(null) + + const rows = [ + { name: 'Hywind Scotland', status: 'Online', statusColor: c['text-success-subtle'], value: '124 MW', time: '2 min ago' }, + { name: 'Hywind Scotland', status: 'Online', statusColor: c['text-success-subtle'], value: '124 MW', time: '2 min ago' }, + { name: 'Hywind Scotland', status: 'Pending', statusColor: c['text-warning-subtle'], value: '124 MW', time: '2 min ago' }, + { name: 'Hywind Scotland', status: 'Offline', statusColor: c['text-danger-subtle'], value: '124 MW', time: '2 min ago' }, + { name: 'Hywind Scotland', status: 'Online', statusColor: c['text-success-subtle'], value: '124 MW', time: '2 min ago' }, + ] + + return ( +
+ + {rows.map((row, i) => ( +
setActiveRow(activeRow === i ? null : i)} + style={{ + display: 'flex', + alignItems: 'center', + padding: '10px 0', + borderTop: `0.5px solid ${c['border-neutral-subtle']}`, + }} + > +
+ {row.name} +
+
+ {row.status} +
+
+ {row.value} +
+
+ {row.time} +
+
+ ))} +
+ ) +} diff --git a/apps/eds-color-palette-generator/src/components/themebuilder/LoginFormPreview.tsx b/apps/eds-color-palette-generator/src/components/themebuilder/LoginFormPreview.tsx new file mode 100644 index 0000000000..9db4c8255e --- /dev/null +++ b/apps/eds-color-palette-generator/src/components/themebuilder/LoginFormPreview.tsx @@ -0,0 +1,167 @@ +'use client' + +import { useId } from 'react' +import type { SemanticColors } from '@/config/semanticColors' + +type LoginFormPreviewProps = { + colors: SemanticColors +} + +/** + * Wired to canonical EDS semantic tokens (Figma Color Map): + * card → bg-neutral-surface + border-neutral-subtle + * input → bg-neutral-canvas + border-neutral-{subtle,medium}, focus border-focus + * text → text-neutral-{strong,subtle} + * link → text-link + * button → bg-accent-fill-emphasis-{default,hover,active} + text-accent-strong-on-emphasis + */ +export function LoginFormPreview({ colors: c }: LoginFormPreviewProps) { + const uid = useId().replace(/:/g, '') + + return ( +
+ + +
+ Sign in +
+
+ Enter your credentials to continue +
+ + + + + + + +
+ Forgot password? +
+ + +
+ ) +} diff --git a/apps/eds-color-palette-generator/src/components/themebuilder/PaletteInputPanel.tsx b/apps/eds-color-palette-generator/src/components/themebuilder/PaletteInputPanel.tsx new file mode 100644 index 0000000000..4f0cb56915 --- /dev/null +++ b/apps/eds-color-palette-generator/src/components/themebuilder/PaletteInputPanel.tsx @@ -0,0 +1,382 @@ +'use client' + +import { useState, useRef, useCallback } from 'react' +import { Plus, X, Pipette, ChevronDown, ChevronUp } from 'lucide-react' +import { SimpleColorPicker } from './SimpleColorPicker' +import { isValidColorFormat, parseColorToHex } from '@/utils/color' +import type { ColorAnchor } from '@/types' +import type { PaletteInput } from '@/utils/urlState' + +type PaletteInputPanelProps = { + palettes: PaletteInput[] + onChange: (palettes: PaletteInput[]) => void +} + +export function PaletteInputPanel({ + palettes, + onChange, +}: PaletteInputPanelProps) { + const [expandedIndex, setExpandedIndex] = useState(null) + + const updateName = (index: number, name: string) => { + const next = [...palettes] + next[index] = { ...next[index], name } + onChange(next) + } + + const updateBaseColor = (index: number, baseColor: string) => { + const next = [...palettes] + next[index] = { ...next[index], baseColor, anchors: undefined } + onChange(next) + } + + const updateAnchor = ( + paletteIndex: number, + anchorIndex: number, + field: 'value' | 'step', + newValue: string | number, + ) => { + const next = [...palettes] + const p = next[paletteIndex] + if (!p.anchors) return + const anchors = [...p.anchors] + if (field === 'value') { + anchors[anchorIndex] = { ...anchors[anchorIndex], value: newValue as string } + } else { + anchors[anchorIndex] = { ...anchors[anchorIndex], step: newValue as number } + } + next[paletteIndex] = { ...p, anchors } + onChange(next) + } + + const removeAnchor = (paletteIndex: number, anchorIndex: number) => { + const next = [...palettes] + const p = next[paletteIndex] + if (!p.anchors || p.anchors.length <= 1) return + const anchors = p.anchors.filter((_, i) => i !== anchorIndex) + next[paletteIndex] = { ...p, anchors } + onChange(next) + } + + const addAnchor = (paletteIndex: number) => { + const next = [...palettes] + const p = next[paletteIndex] + const existingAnchors = p.anchors ?? [] + const usedSteps = new Set(existingAnchors.map((a) => a.step)) + // Find first unused step + let freeStep = 1 + for (let s = 1; s <= 15; s++) { + if (!usedSteps.has(s)) { + freeStep = s + break + } + } + const newAnchor: ColorAnchor = { value: 'oklch(0.5 0.05 180)', step: freeStep } + next[paletteIndex] = { ...p, anchors: [...existingAnchors, newAnchor] } + onChange(next) + } + + const convertToAnchors = (paletteIndex: number) => { + const next = [...palettes] + const p = next[paletteIndex] + const hex = p.baseColor.startsWith('#') ? p.baseColor : `#${p.baseColor}` + next[paletteIndex] = { + ...p, + anchors: [{ value: hex, step: 9 }], + } + onChange(next) + } + + const convertToSimple = (paletteIndex: number) => { + const next = [...palettes] + const p = next[paletteIndex] + // Try to get hex from first anchor + let hex = '808080' + if (p.anchors && p.anchors.length > 0) { + const parsed = parseColorToHex(p.anchors[0].value) + if (parsed) hex = parsed.replace('#', '') + } + next[paletteIndex] = { ...p, baseColor: hex, anchors: undefined } + onChange(next) + } + + const removePalette = (index: number) => { + if (palettes.length <= 1) return + onChange(palettes.filter((_, i) => i !== index)) + } + + const addPalette = () => { + onChange([ + ...palettes, + { name: `Color ${palettes.length + 1}`, baseColor: '808080' }, + ]) + } + + const hasAnchors = (p: PaletteInput) => p.anchors && p.anchors.length > 0 + + return ( +
+

Palettes

+ +
+ {palettes.map((p, i) => ( +
+ {/* Main row: name + color preview + expand/collapse + remove */} +
+ updateName(i, e.target.value)} + className="w-[140px] px-2 py-1 text-sm rounded-md border border-neutral-subtle bg-default" + placeholder="Palette name" + /> + + {hasAnchors(p) ? ( + // Multi-anchor: show colored dots for each anchor +
+ {p.anchors!.map((a, ai) => { + const hex = parseColorToHex(a.value) + return ( + + ) + })} + + {p.anchors!.length} anchor{p.anchors!.length > 1 ? 's' : ''} + +
+ ) : ( + updateBaseColor(i, hex)} + /> + )} + + + + +
+ + {/* Expanded: anchor editing */} + {expandedIndex === i && ( +
+ {hasAnchors(p) ? ( + <> + {p.anchors!.map((anchor, ai) => ( + + updateAnchor(i, ai, field, val) + } + onRemove={() => removeAnchor(i, ai)} + canRemove={p.anchors!.length > 1} + /> + ))} +
+ + +
+ + ) : ( +
+ + Single color mode + + +
+ )} +
+ )} +
+ ))} +
+ + +
+ ) +} + +/* ------------------------------------------------------------------ */ +/* Anchor row sub-component */ +/* ------------------------------------------------------------------ */ + +function AnchorRow({ + anchor, + allAnchors, + index, + onUpdate, + onRemove, + canRemove, +}: { + anchor: ColorAnchor + allAnchors: ColorAnchor[] + index: number + onUpdate: (field: 'value' | 'step', val: string | number) => void + onRemove: () => void + canRemove: boolean +}) { + const colorInputRef = useRef(null) + const [localValue, setLocalValue] = useState(anchor.value) + const [isValid, setIsValid] = useState(true) + + const localHex = (() => { + try { + return parseColorToHex(anchor.value) ?? '#808080' + } catch { + return '#808080' + } + })() + + const handleValueChange = useCallback( + (val: string) => { + setLocalValue(val) + const valid = isValidColorFormat(val) + setIsValid(valid) + if (valid) { + onUpdate('value', val.trim()) + } + }, + [onUpdate], + ) + + const handleBlur = useCallback(() => { + if (!isValid) { + setLocalValue(anchor.value) + setIsValid(true) + } + }, [isValid, anchor.value]) + + return ( +
+ + + handleValueChange(e.target.value)} + onBlur={handleBlur} + className={[ + 'flex-1 min-w-0 px-2 py-1 text-xs font-mono rounded-md', + isValid + ? 'border border-neutral-subtle' + : 'border-2 border-danger-fill-emphasis-default', + 'bg-default', + ].join(' ')} + aria-label={`Color value for anchor ${index + 1}`} + aria-invalid={!isValid} + /> + + { + setLocalValue(e.target.value) + setIsValid(true) + onUpdate('value', e.target.value) + }} + className="sr-only" + tabIndex={-1} + /> + + + {canRemove && ( + + )} +
+ ) +} diff --git a/apps/eds-color-palette-generator/src/components/themebuilder/SimpleColorPicker.tsx b/apps/eds-color-palette-generator/src/components/themebuilder/SimpleColorPicker.tsx new file mode 100644 index 0000000000..9eafdd6fc0 --- /dev/null +++ b/apps/eds-color-palette-generator/src/components/themebuilder/SimpleColorPicker.tsx @@ -0,0 +1,49 @@ +'use client' + +import { useRef } from 'react' + +type SimpleColorPickerProps = { + value: string + onChange: (hex: string) => void +} + +export function SimpleColorPicker({ value, onChange }: SimpleColorPickerProps) { + const nativeRef = useRef(null) + + const displayValue = value.startsWith('#') ? value : `#${value}` + + return ( +
+ { + const v = e.target.value.trim() + onChange(v.startsWith('#') ? v.slice(1) : v) + }} + maxLength={7} + className="w-[80px] px-2 py-1 text-sm font-mono rounded-md border border-neutral-subtle bg-default" + /> + +
+ ) +} diff --git a/apps/eds-color-palette-generator/src/components/themebuilder/ThemeBuilderHeader.tsx b/apps/eds-color-palette-generator/src/components/themebuilder/ThemeBuilderHeader.tsx new file mode 100644 index 0000000000..cdb5a43d02 --- /dev/null +++ b/apps/eds-color-palette-generator/src/components/themebuilder/ThemeBuilderHeader.tsx @@ -0,0 +1,117 @@ +'use client' + +import { useState, useCallback } from 'react' +import Link from 'next/link' +import { ThemeToggle } from '@/components/shared/ThemeToggle' +import { Link2, Check, Download, BarChart3 } from 'lucide-react' + +type Tab = 'fargesystem' | 'eksempler' | 'kontrast' + +type ThemeBuilderHeaderProps = { + activeTab: Tab + onTabChange: (tab: Tab) => void + /** Download the current palette configuration as JSON */ + onDownloadConfig: () => void +} + +export function ThemeBuilderHeader({ + activeTab, + onTabChange, + onDownloadConfig, +}: ThemeBuilderHeaderProps) { + const [copied, setCopied] = useState(false) + + const copyURL = useCallback(async () => { + try { + await navigator.clipboard.writeText(window.location.href) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch { + /* ignore */ + } + }, []) + + const tabs: { key: Tab; label: string }[] = [ + { key: 'fargesystem', label: 'Fargesystem' }, + { key: 'eksempler', label: 'Eksempler' }, + { key: 'kontrast', label: 'Kontrast' }, + ] + + return ( +
+
+

+ EDS Theme Builder +

+ + {/* Tab buttons */} +
+ {tabs.map((tab) => ( + + ))} +
+ +
+ + + Data viz + + + + + + + +
+
+
+ ) +} diff --git a/apps/eds-color-palette-generator/src/components/themebuilder/TokenMatrix.tsx b/apps/eds-color-palette-generator/src/components/themebuilder/TokenMatrix.tsx new file mode 100644 index 0000000000..d92973c3c1 --- /dev/null +++ b/apps/eds-color-palette-generator/src/components/themebuilder/TokenMatrix.tsx @@ -0,0 +1,168 @@ +'use client' + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { contrast } from '@/utils/color' +import { STEP_ROLES } from '@/utils/palette' +import { STEP_TOKENS, TOKEN_CATEGORY_GROUPS } from '@/config/categories' + +type GeneratedPalette = { + name: string + steps: string[] +} + +type TokenMatrixProps = { + palettes: GeneratedPalette[] +} + +function getTextColor(bgHex: string): string { + const whiteContrast = parseFloat( + String( + contrast({ + foreground: '#ffffff', + background: bgHex, + algorithm: 'WCAG21', + silent: true, + }), + ), + ) + return whiteContrast >= 3 ? '#fff' : '#000' +} + +export function TokenMatrix({ palettes }: TokenMatrixProps) { + if (palettes.length === 0) return null + + return ( +
+

Token Matrix

+ +
+ {/* Category group headers */} +
+ {/* Spacer for row label column */} +
+ {TOKEN_CATEGORY_GROUPS.map((group) => ( +
+ {group.label} +
+
+ ))} + + {/* Token name + step number headers */} +
+ {STEP_TOKENS.map((token, i) => ( +
+
{token.name}
+ {token.sub ?
{token.sub}
: null} +
{i + 1}
+
+ ))} + + {/* Palette rows */} + {palettes.map((palette) => ( + + ))} +
+
+
+ ) +} + +function PaletteRow({ palette }: { palette: GeneratedPalette }) { + const textColors = useMemo( + () => palette.steps.map(getTextColor), + [palette.steps], + ) + const [copiedIndex, setCopiedIndex] = useState(null) + const timerRef = useRef | null>(null) + + // Clear the pending "Copied!" reset timer on unmount + useEffect(() => { + return () => { + if (timerRef.current) clearTimeout(timerRef.current) + } + }, []) + + const handleCopy = useCallback(async (hex: string, i: number) => { + try { + await navigator.clipboard.writeText(hex) + } catch { + // Clipboard unavailable (e.g. non-secure context) — nothing to show + return + } + setCopiedIndex(i) + if (timerRef.current) clearTimeout(timerRef.current) + timerRef.current = setTimeout(() => setCopiedIndex(null), 1200) + }, []) + + return ( + <> +
+ {palette.name} +
+ {palette.steps.map((hex, i) => { + const isCopied = copiedIndex === i + return ( + + ) + })} + + ) +} diff --git a/apps/eds-color-palette-generator/src/components/themebuilder/contrast/ContrastCard.tsx b/apps/eds-color-palette-generator/src/components/themebuilder/contrast/ContrastCard.tsx new file mode 100644 index 0000000000..2699c835b1 --- /dev/null +++ b/apps/eds-color-palette-generator/src/components/themebuilder/contrast/ContrastCard.tsx @@ -0,0 +1,157 @@ +'use client' + +import { useMemo } from 'react' +import { calcContrast, getApcaFontBreakdown } from '@/utils/palette' +import { Badge } from '@/components/shared/Badge' + +type ContrastCardProps = { + fgHex: string + bgHex: string + fgLabel: string + bgLabel: string + paletteName: string + previewType?: 'text' | 'border' +} + +export function ContrastCard({ + fgHex, + bgHex, + fgLabel, + bgLabel, + paletteName, + previewType = 'text', +}: ContrastCardProps) { + const result = useMemo(() => calcContrast(fgHex, bgHex), [fgHex, bgHex]) + const wcagNum = parseFloat(result.wcag) + const absLc = Math.abs(parseFloat(result.apca)) + + const fontBreakdown = useMemo(() => getApcaFontBreakdown(absLc), [absLc]) + + const similarity = useMemo(() => { + if (fgHex.toLowerCase() === bgHex.toLowerCase()) + return { label: 'Same color', bg: '#fef2f2', color: '#991b1b' } + if (wcagNum < 1.2) + return { label: 'Near identical', bg: '#fef2f2', color: '#991b1b' } + if (wcagNum < 2) + return { label: 'Very low', bg: '#fff7ed', color: '#9a3412' } + if (wcagNum < 3) + return { label: 'Low', bg: '#fffbeb', color: '#92400e' } + return null + }, [fgHex, bgHex, wcagNum]) + + return ( +
+ {/* Preview area */} +
+ {previewType === 'text' ? ( + + Aa + + ) : ( +
+ )} +
+ + {/* Info area */} +
+
+ {paletteName} +
+ +
+ + fg: {fgLabel} + + + bg: {bgLabel} + +
+ + {/* WCAG */} +
+ + {result.wcag} + + + + {similarity && ( + + {similarity.label} + + )} +
+ + {/* APCA */} +
+ APCA{' '} + + Lc {result.apca} + +
+ + {/* Font size pills */} +
+ {fontBreakdown.map(({ size, minWeight }) => { + const passes = minWeight !== null + return ( + + {size}px + + ) + })} +
+
+
+ ) +} diff --git a/apps/eds-color-palette-generator/src/components/themebuilder/contrast/DataColorChart.tsx b/apps/eds-color-palette-generator/src/components/themebuilder/contrast/DataColorChart.tsx new file mode 100644 index 0000000000..0879ba3df3 --- /dev/null +++ b/apps/eds-color-palette-generator/src/components/themebuilder/contrast/DataColorChart.tsx @@ -0,0 +1,776 @@ +'use client' + +import { useState, useMemo } from 'react' +import { STEP_ROLES, calcContrast } from '@/utils/palette' +import { Badge } from '@/components/shared/Badge' +import { CVDFilter, cvdFilterStyle } from '@/components/shared/CVDFilter' +import { CVD_OPTIONS, type CVDType } from '@/utils/cvd' +import { StepSelect } from './StepSelect' + +type Palette = { name: string; steps: string[] } + +/** A chart series colour. `step` is present only for palette-derived colours. */ +type ChartColor = { name: string; hex: string; step?: number } + +type ChartType = 'bar' | 'stacked-bar' | 'donut' | 'line' + +const CHART_TYPES: { key: ChartType; label: string }[] = [ + { key: 'bar', label: 'Bar' }, + { key: 'stacked-bar', label: 'Stacked bar' }, + { key: 'donut', label: 'Donut' }, + { key: 'line', label: 'Line' }, +] + +const BAR_DATA = [ + [82, 65, 48, 71, 55, 39, 60], + [18, 35, 52, 29, 45, 61, 40], +] + +const DONUT_SEGMENTS = [35, 25, 20, 12, 8] + +/** + * Pick black or white text for a fill, whichever gives the higher APCA + * contrast (Lc). Used for labels sitting directly on a series colour so they + * stay legible on both light and dark fills instead of a fixed colour. + */ +function bestTextColor(bg: string): string { + const onBlack = Math.abs(parseFloat(calcContrast('#000000', bg).apca)) + const onWhite = Math.abs(parseFloat(calcContrast('#ffffff', bg).apca)) + return onBlack >= onWhite ? '#000000' : '#ffffff' +} + +function findGrayIndex(palettes: Palette[]) { + const idx = palettes.findIndex( + (p) => + p.name.toLowerCase().includes('gray') || + p.name.toLowerCase().includes('grey'), + ) + return idx >= 0 ? idx : 0 +} + +/** Monochromatic ordering following PatternFly: base(300→step8), 100, 500, 200, 400 */ +function monochromaticSteps(): number[] { + return [8, 2, 12, 5, 10] +} + +/** + * Translucent stripe overlays laid over a series colour so charts stay + * readable without relying on hue alone (the survey's #1 accessibility habit). + * Index 0 is solid; the rest cycle through distinct hatchings. + */ +const PATTERN_OVERLAYS: (string | undefined)[] = [ + undefined, + 'repeating-linear-gradient(45deg, rgba(255,255,255,.55) 0 2px, transparent 2px 8px)', + 'repeating-linear-gradient(-45deg, rgba(0,0,0,.4) 0 2px, transparent 2px 8px)', + 'repeating-linear-gradient(90deg, rgba(255,255,255,.55) 0 2px, transparent 2px 8px)', + 'repeating-linear-gradient(0deg, rgba(0,0,0,.4) 0 2px, transparent 2px 8px)', + 'repeating-linear-gradient(45deg, rgba(255,255,255,.6) 0 1px, transparent 1px 4px)', +] + +function overlayFor(index: number, on: boolean): string | undefined { + return on ? PATTERN_OVERLAYS[index % PATTERN_OVERLAYS.length] : undefined +} + +/** Line dash patterns keyed by series index (index 0 is solid). */ +const LINE_DASHES: (string | undefined)[] = [ + undefined, + '6 4', + '2 3', + '8 3 2 3', + '1 4', + '10 4', +] + +/** Point-marker shapes for line series, keyed by index. */ +const MARKER_SHAPES = ['circle', 'square', 'triangle', 'diamond'] as const + +/* ---------------------------------------------------------------------- */ +/* Chart renderers */ +/* ---------------------------------------------------------------------- */ + +type ChartProps = { + colors: { name: string; hex: string }[] + bgHex: string + textHex: string + showPatterns?: boolean +} + +function BarChart({ colors, bgHex, textHex, showPatterns }: ChartProps) { + return ( +
+
+ {colors.map((pc, i) => { + const h = BAR_DATA[0][i % BAR_DATA[0].length] + return ( +
+ + {h} + +
+
+ ) + })} +
+ +
+ ) +} + +function StackedBarChart({ colors, bgHex, textHex, showPatterns }: ChartProps) { + const groups = ['Q1', 'Q2', 'Q3', 'Q4'] + return ( +
+
+ {groups.map((label, gi) => ( +
+ {colors.map((pc, ci) => { + const val = (20 + ((gi * 13 + ci * 17) % 30)) + return ( +
+ ) + })} +
+ ))} +
+
+ {groups.map((g) => ( +
+ {g} +
+ ))} +
+
+ ) +} + +function DonutChart({ colors, bgHex, textHex, showPatterns }: ChartProps) { + const total = DONUT_SEGMENTS.slice(0, colors.length).reduce((a, b) => a + b, 0) + const pcts = colors.map( + (_, i) => (DONUT_SEGMENTS[i % DONUT_SEGMENTS.length] / total) * 100, + ) + const segments = colors.map((pc, i) => ({ + name: pc.name, + hex: pc.hex, + pct: pcts[i], + offset: 25 - pcts.slice(0, i).reduce((a, b) => a + b, 0), + })) + + return ( +
+
+ + {showPatterns && ( + + {segments.map(({ name, hex }, i) => ( + + + + + ))} + + )} + {segments.map(({ name, hex, pct, offset }, i) => ( + + ))} + +
+ {total}% +
+
+
+ ) +} + +function Marker({ + shape, + cx, + cy, + color, +}: { + shape: (typeof MARKER_SHAPES)[number] + cx: number + cy: number + color: string +}) { + const s = 3 + if (shape === 'square') + return + if (shape === 'triangle') + return ( + + ) + if (shape === 'diamond') + return ( + + ) + return +} + +function LineChart({ colors, bgHex, textHex, showPatterns }: ChartProps) { + const points = [20, 45, 35, 60, 50, 75, 65] + const w = 280 + const h = 120 + const px = (i: number) => (i / (points.length - 1)) * w + const py = (v: number, offset: number) => + h - ((v + offset) / 100) * h + + return ( +
+ + {colors.map((pc, ci) => { + const offset = ci * 5 + const d = points + .map((v, i) => `${i === 0 ? 'M' : 'L'}${px(i)},${py(v, offset)}`) + .join(' ') + return ( + + + {showPatterns && + points.map((v, i) => ( + + ))} + + ) + })} + +
+ {['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map((d) => ( +
+ {d} +
+ ))} +
+
+ ) +} + +function XAxis({ + colors, + textHex, +}: { + colors: { name: string; hex: string }[] + textHex: string +}) { + return ( +
+ {colors.map((pc) => ( +
+ {pc.name} +
+ ))} +
+ ) +} + +/* ---------------------------------------------------------------------- */ +/* Legend */ +/* ---------------------------------------------------------------------- */ + +function Legend({ colors, bgHex }: { colors: ChartColor[]; bgHex: string }) { + return ( +
+ {colors.map((pc) => ( +
+ + + {pc.name} + {pc.step != null && ( + <> + {' '} + + ({STEP_ROLES[pc.step]}) + + + )} + +
+ ))} +
+ ) +} + +/* ---------------------------------------------------------------------- */ +/* Main component */ +/* ---------------------------------------------------------------------- */ + +type DataColorChartProps = { + /** Palette-derived mode (theme builder): colours are sliced from these. */ + palettes?: Palette[] + /** Direct mode (data-viz route): render exactly these generated colours. */ + colors?: ChartColor[] + /** Chart background / text hex — used in direct mode. */ + bgHex?: string + textHex?: string + /** + * Show the pairwise "every pair ≥ 3:1" matrix. Meaningful for categorical + * series; misleading for sequential/diverging scales whose steps are + * intentionally similar, so the panel turns it off for those. + */ + pairwiseCheck?: boolean +} + +export function DataColorChart({ + palettes, + colors: externalColors, + bgHex: externalBg, + textHex: externalText, + pairwiseCheck = true, +}: DataColorChartProps) { + const [bgStep, setBgStep] = useState(0) + const [textStep, setTextStep] = useState(11) + const [chartType, setChartType] = useState('bar') + const [mono, setMono] = useState(false) + const [monoPaletteIdx, setMonoPaletteIdx] = useState(0) + const [cvd, setCvd] = useState('none') + const [showPatterns, setShowPatterns] = useState(false) + + // Palette mode drives the derivation + step selectors; direct mode renders + // the passed colours and hides the palette-only controls. + const paletteMode = !externalColors + const safePalettes = useMemo(() => palettes ?? [], [palettes]) + const neutralIdx = findGrayIndex(safePalettes) + const paletteBg = safePalettes.length + ? safePalettes[neutralIdx].steps[bgStep] + : '#ffffff' + const paletteText = safePalettes.length + ? safePalettes[neutralIdx].steps[textStep] + : '#1a1a1a' + const bgHex = externalBg ?? paletteBg + const textHex = externalText ?? paletteText + + /** + * Multi-chromatic with lightness spread: each palette uses a different step + * so colors are distinguishable by lightness alone (achromatopsia-friendly). + * Center step is 8 (fill/emphasis), then spreads ±1 per palette. + */ + const multiColors = useMemo(() => { + const center = 8 + const count = safePalettes.length + const startStep = Math.max(0, center - Math.floor((count - 1) / 2)) + return safePalettes.map((p, i) => { + const step = Math.min(14, startStep + i) + return { name: p.name, hex: p.steps[step], step } + }) + }, [safePalettes]) + + // Monochromatic: multiple shades from a single palette + const monoColors = useMemo(() => { + if (!safePalettes.length) return [] + const p = safePalettes[monoPaletteIdx] ?? safePalettes[0] + return monochromaticSteps().map((step) => ({ + name: `${p.name}/${step + 1}`, + hex: p.steps[step], + step, + })) + }, [safePalettes, monoPaletteIdx]) + + const derivedColors = mono ? monoColors : multiColors + const colors: ChartColor[] = externalColors ?? derivedColors + + const ChartComponent = { + bar: BarChart, + 'stacked-bar': StackedBarChart, + donut: DonutChart, + line: LineChart, + }[chartType] + + return ( +
+
+

+ Data color chart +

+

+ {paletteMode + ? 'Preview palette colors in chart contexts — multi-chromatic (one color per palette) or monochromatic (shades from a single palette)' + : 'Preview the generated data-viz palette in chart contexts, then check it under colour-vision-deficiency simulation.'} +

+
+ + {/* Controls row 1: chart type + mode */} +
+
+ {CHART_TYPES.map((ct) => ( + + ))} +
+ + {paletteMode && ( + + )} + + {paletteMode && mono && ( + + )} +
+ + {/* Controls row 2: bg + text + accessibility */} +
+ {paletteMode && ( + <> + + + + )} + + + + +
+ + {/* SVG filter definitions */} + + + {/* Filtered preview area */} +
+ {/* Chart */} + + + {/* Legend */} +
+ +
+ + {/* Chips / Badges */} +
+

+ Chips & badges +

+
+ {colors.map((pc, i) => ( + + {pc.name} + + ))} +
+
+
+ + {/* Contrast table (outside filter — shows real values) */} +
+

+ Contrast on chart background +

+
+ {colors.map((pc) => { + const result = calcContrast(pc.hex, bgHex) + return ( +
+ + + {pc.name} + + + {result.wcag} + + + +
+ ) + })} +
+
+ + {/* Inter-color contrast matrix */} + {pairwiseCheck && colors.length > 1 && ( +
+

+ Color-to-color contrast +

+

+ Each pair of data colors must be distinguishable — 3:1 minimum for + adjacent chart elements +

+
+ + + + + ))} + + + + {colors.map((row, ri) => ( + + + {colors.map((col, ci) => { + if (ci <= ri) { + return ( + + ) + } + const result = calcContrast(row.hex, col.hex) + const ratio = parseFloat(result.wcag) + const pass3 = ratio >= 3 + return ( + + ) + })} + + ))} + +
+ {colors.map((pc) => ( + +
+ + + {pc.name} + +
+
+
+ + + {row.name} + +
+
+ {ci === ri ? '—' : ''} + + {result.wcag} +
+
+
+ )} +
+ ) +} diff --git a/apps/eds-color-palette-generator/src/components/themebuilder/contrast/InteractivePicker.tsx b/apps/eds-color-palette-generator/src/components/themebuilder/contrast/InteractivePicker.tsx new file mode 100644 index 0000000000..519b27dcba --- /dev/null +++ b/apps/eds-color-palette-generator/src/components/themebuilder/contrast/InteractivePicker.tsx @@ -0,0 +1,76 @@ +'use client' + +import { useState } from 'react' +import { STEP_ROLES } from '@/utils/palette' +import { ContrastCard } from './ContrastCard' + +type Palette = { name: string; steps: string[] } + +export function InteractivePicker({ palettes }: { palettes: Palette[] }) { + const [fgStep, setFgStep] = useState(12) + const [bgStep, setBgStep] = useState(0) + + return ( +
+
+

+ Data color picker +

+

+ Test any fg/bg combination for chips, badges, graphs — all palettes + side by side +

+
+ +
+ + + +
+ +
+ {palettes.map((p) => ( + + ))} +
+
+ ) +} diff --git a/apps/eds-color-palette-generator/src/components/themebuilder/contrast/SamePalettePairings.tsx b/apps/eds-color-palette-generator/src/components/themebuilder/contrast/SamePalettePairings.tsx new file mode 100644 index 0000000000..e6ef9e94a7 --- /dev/null +++ b/apps/eds-color-palette-generator/src/components/themebuilder/contrast/SamePalettePairings.tsx @@ -0,0 +1,107 @@ +'use client' + +import { useState } from 'react' +import { STEP_ROLES } from '@/utils/palette' +import { ContrastCard } from './ContrastCard' +import { StepSelect } from './StepSelect' + +type Palette = { name: string; steps: string[] } + +const DEFAULT_PAIRINGS = [ + { fg: 11, bg: 0, label: 'Text on canvas' }, + { fg: 11, bg: 1, label: 'Text on surface' }, + { fg: 14, bg: 8, label: 'White on emphasis' }, + { fg: 6, bg: 0, label: 'Border on canvas' }, +] + +export function SamePalettePairings({ + palettes, +}: { + palettes: Palette[] +}) { + const [pairings, setPairings] = useState(DEFAULT_PAIRINGS) + + const update = ( + idx: number, + field: 'fg' | 'bg', + value: number, + ) => { + setPairings((prev) => + prev.map((p, i) => (i === idx ? { ...p, [field]: value } : p)), + ) + } + + const addPairing = () => { + setPairings((prev) => [...prev, { fg: 11, bg: 0, label: '' }]) + } + + const removePairing = (idx: number) => { + if (pairings.length <= 1) return + setPairings((prev) => prev.filter((_, i) => i !== idx)) + } + + return ( +
+
+

+ Same-palette pairings +

+

+ Test text & background from the same palette — useful for + components where accent colors carry both roles +

+
+ + {pairings.map(({ fg, bg }, idx) => ( +
+
+ update(idx, 'fg', v)} + /> + update(idx, 'bg', v)} + /> + {pairings.length > 1 && ( + + )} +
+
+ {palettes.map((p) => ( + + ))} +
+
+ ))} + + +
+ ) +} diff --git a/apps/eds-color-palette-generator/src/components/themebuilder/contrast/SemanticPairings.tsx b/apps/eds-color-palette-generator/src/components/themebuilder/contrast/SemanticPairings.tsx new file mode 100644 index 0000000000..9f7d54a185 --- /dev/null +++ b/apps/eds-color-palette-generator/src/components/themebuilder/contrast/SemanticPairings.tsx @@ -0,0 +1,175 @@ +'use client' + +import { useMemo } from 'react' +import { calcContrast, getApcaFontBreakdown } from '@/utils/palette' +import { Badge } from '@/components/shared/Badge' + +type Palette = { name: string; steps: string[] } + +/** + * Figma semantic pairings — text/border always from Gray, bg from accent. + * These are fixed combinations defined by the design system variables. + */ +const SEMANTIC_PAIRINGS = [ + { + title: 'Text on surfaces', + pairings: [ + { grayStep: 11, accentStep: 0, label: 'text/primary on bg/canvas' }, + { grayStep: 11, accentStep: 1, label: 'text/primary on bg/surface' }, + { grayStep: 11, accentStep: 2, label: 'text/primary on bg/surface-hover' }, + ], + }, + { + title: 'Text on emphasis', + pairings: [ + { grayStep: 14, accentStep: 8, label: 'bg/card on fill/emphasis', invert: true }, + { grayStep: 14, accentStep: 9, label: 'bg/card on fill/emphasis-hover', invert: true }, + { grayStep: 14, accentStep: 10, label: 'bg/card on fill/emphasis-pressed', invert: true }, + { grayStep: 13, accentStep: 8, label: 'fg/on-emphasis on fill/emphasis' }, + ], + }, + { + title: 'Borders on surfaces', + pairings: [ + { grayStep: 6, accentStep: 0, label: 'border/subtle on bg/canvas' }, + { grayStep: 7, accentStep: 0, label: 'border/default on bg/canvas' }, + { grayStep: 6, accentStep: 1, label: 'border/subtle on bg/surface' }, + { grayStep: 7, accentStep: 1, label: 'border/default on bg/surface' }, + ], + }, +] as const + +function PairingCard({ + fgHex, + bgHex, + label, + paletteName, + previewType = 'text', +}: { + fgHex: string + bgHex: string + label: string + paletteName: string + previewType?: 'text' | 'border' +}) { + const result = useMemo(() => calcContrast(fgHex, bgHex), [fgHex, bgHex]) + const absLc = Math.abs(parseFloat(result.apca)) + const fontBreakdown = useMemo(() => getApcaFontBreakdown(absLc), [absLc]) + + return ( +
+
+ {previewType === 'text' ? ( + + Aa + + ) : ( +
+ )} +
+
+
{paletteName}
+
{label}
+
+ {result.wcag} + + +
+
+ APCA Lc {result.apca} +
+
+ {fontBreakdown.map(({ size, minWeight }) => { + const passes = minWeight !== null + return ( + + {size}px + + ) + })} +
+
+
+ ) +} + +export function SemanticPairings({ palettes }: { palettes: Palette[] }) { + // Find Gray/neutral palette + const grayIdx = palettes.findIndex( + (p) => p.name.toLowerCase().includes('gray') || p.name.toLowerCase().includes('grey'), + ) + const neutralIdx = grayIdx >= 0 ? grayIdx : 0 + const gray = palettes[neutralIdx] + + // Accent palettes = everything except Gray + const accentPalettes = palettes.filter((_, i) => i !== neutralIdx) + + if (accentPalettes.length === 0) return null + + return ( +
+
+

+ Semantic pairings +

+

+ Fixed combinations from the design system — text & borders from{' '} + {gray.name}, backgrounds from each accent palette +

+
+ + {SEMANTIC_PAIRINGS.map((group) => ( +
+

{group.title}

+ + {group.pairings.map((pairing) => { + const isBorder = group.title.toLowerCase().includes('border') + return ( +
+
{pairing.label}
+
+ {accentPalettes.map((accent) => ( + + ))} +
+
+ ) + })} +
+ ))} +
+ ) +} diff --git a/apps/eds-color-palette-generator/src/components/themebuilder/contrast/StepSelect.tsx b/apps/eds-color-palette-generator/src/components/themebuilder/contrast/StepSelect.tsx new file mode 100644 index 0000000000..c1fb9ef486 --- /dev/null +++ b/apps/eds-color-palette-generator/src/components/themebuilder/contrast/StepSelect.tsx @@ -0,0 +1,79 @@ +'use client' + +import { useMemo } from 'react' +import { STEP_ROLES } from '@/utils/palette' + +type StepSelectProps = { + label: string + value: number + onChange: (v: number) => void + /** Step indices to show as "Recommended" at the top */ + recommended?: number[] + /** When set, only show steps matching this category prefix (e.g. 'bg/', 'fg/', 'text/') */ + only?: string[] +} + +export function StepSelect({ + label, + value, + onChange, + recommended, + only, +}: StepSelectProps) { + const allSteps = useMemo(() => { + if (!only || only.length === 0) return STEP_ROLES.map((_, i) => i) + return STEP_ROLES + .map((role, i) => ({ role, i })) + .filter(({ role }) => only.some((prefix) => role.includes(`· ${prefix}`))) + .map(({ i }) => i) + }, [only]) + + const { recSteps, otherSteps } = useMemo(() => { + if (!recommended || recommended.length === 0) { + return { recSteps: [], otherSteps: allSteps } + } + const recSet = new Set(recommended) + return { + recSteps: recommended.filter((i) => allSteps.includes(i)), + otherSteps: allSteps.filter((i) => !recSet.has(i)), + } + }, [recommended, allSteps]) + + const hasGroups = recSteps.length > 0 + + return ( + + ) +} diff --git a/apps/eds-color-palette-generator/src/components/themebuilder/contrast/SurfacePreview.tsx b/apps/eds-color-palette-generator/src/components/themebuilder/contrast/SurfacePreview.tsx new file mode 100644 index 0000000000..861e589f41 --- /dev/null +++ b/apps/eds-color-palette-generator/src/components/themebuilder/contrast/SurfacePreview.tsx @@ -0,0 +1,136 @@ +'use client' + +import { useState, useMemo } from 'react' +import { calcContrast } from '@/utils/palette' +import { Badge } from '@/components/shared/Badge' +import { StepSelect } from './StepSelect' + +type Palette = { name: string; steps: string[] } + +function ContrastPair({ + label, + fgHex, + bgHex, +}: { + label: string + fgHex: string + bgHex: string +}) { + const result = useMemo(() => calcContrast(fgHex, bgHex), [fgHex, bgHex]) + return ( +
+ {label} + + {result.wcag} + + + +
+ ) +} + +export function SurfacePreview({ palettes }: { palettes: Palette[] }) { + const [page, setPage] = useState(0) + const [panel, setPanel] = useState(1) + const [cardRow, setCardRow] = useState(2) + const [card, setCard] = useState(0) + const [border, setBorder] = useState(6) + const [text, setText] = useState(11) + + return ( +
+
+

+ Surface preview +

+

+ Visualize nested surface layers per palette — adjust roles to test + combinations +

+
+ + {/* Dropdowns */} +
+ + + + + + +
+ + {/* Per-palette preview */} +
+ {palettes.map((p) => ( +
+

+ {p.name} +

+ + {/* Nested box layout */} +
+
+
+ {[0, 1, 2].map((i) => ( +
+ Card {i + 1} +
+ ))} +
+
+
+ + {/* Contrast ratios between adjacent layers */} +
+ + + + + +
+
+ ))} +
+
+ ) +} diff --git a/apps/eds-color-palette-generator/src/components/themebuilder/dataviz/DataVizPanel.tsx b/apps/eds-color-palette-generator/src/components/themebuilder/dataviz/DataVizPanel.tsx new file mode 100644 index 0000000000..15933d8232 --- /dev/null +++ b/apps/eds-color-palette-generator/src/components/themebuilder/dataviz/DataVizPanel.tsx @@ -0,0 +1,273 @@ +'use client' + +import { useMemo, useState } from 'react' +import { useColorScheme } from '@/context/ColorSchemeContext' +import { getSemanticColors } from '@/config/semanticColors' +import { + generateCategoricalPalette, + generateSequentialScale, + generateDivergingScale, +} from '@/utils/dataviz' +import { auditCategorical } from '@/utils/dataviz-a11y' +import { + DEFAULT_CATEGORICAL, + DEFAULT_SEQUENTIAL, + DEFAULT_DIVERGING, + DATAVIZ_HINTS, + EDS_CATEGORICAL_SEEDS, +} from '@/config/dataviz-defaults' +import type { DatavizKind } from '@/config/dataviz-types' +import { + toHexArray, + toCssVars, + toW3CTokens, + downloadText, +} from '@/utils/dataviz-export' +import { Badge } from '@/components/shared/Badge' +import { DataColorChart } from '@/components/themebuilder/contrast/DataColorChart' + +const KINDS: { key: DatavizKind; label: string }[] = [ + { key: 'categorical', label: 'Categorical' }, + { key: 'sequential', label: 'Sequential' }, + { key: 'diverging', label: 'Diverging' }, +] + +export function DataVizPanel() { + const { colorScheme } = useColorScheme() + const mode = colorScheme + + const [kind, setKind] = useState('categorical') + + // Categorical controls + const [count, setCount] = useState(DEFAULT_CATEGORICAL.count) + + // Sequential / diverging controls + const [seqSteps, setSeqSteps] = useState(DEFAULT_SEQUENTIAL.steps) + const [seqHue, setSeqHue] = useState(DEFAULT_SEQUENTIAL.hue as number) + const [divSteps, setDivSteps] = useState(DEFAULT_DIVERGING.steps) + + const [copied, setCopied] = useState(null) + const copy = (label: string, text: string) => { + navigator.clipboard?.writeText(text).then( + () => { + setCopied(label) + setTimeout(() => setCopied(null), 2000) + }, + () => {}, + ) + } + + // CVD-aware spacing is always on — accessible output is the point of the tool. + const categoricalCfg = { ...DEFAULT_CATEGORICAL, count, enforceCVD: true } + const sequentialCfg = { ...DEFAULT_SEQUENTIAL, steps: seqSteps, hue: seqHue } + const divergingCfg = { ...DEFAULT_DIVERGING, steps: divSteps } + + const colors = useMemo(() => { + if (kind === 'categorical') + return generateCategoricalPalette(categoricalCfg, mode) + if (kind === 'sequential') + return generateSequentialScale(sequentialCfg, mode) + return generateDivergingScale(divergingCfg, mode) + // eslint-disable-next-line react-hooks/exhaustive-deps -- configs are derived from the primitive state below + }, [kind, count, seqSteps, seqHue, divSteps, mode]) + + const audit = useMemo( + () => + kind === 'categorical' ? auditCategorical(colors, categoricalCfg) : null, + // eslint-disable-next-line react-hooks/exhaustive-deps -- categoricalCfg is derived from count + [kind, colors, count], + ) + + const semantic = getSemanticColors(mode) + const chartBg = semantic['bg-neutral-surface'] + const chartText = semantic['text-neutral-strong'] + + const hint = DATAVIZ_HINTS[kind] + + return ( +
+ {/* Family sub-tabs */} +
+
+ {KINDS.map((k) => ( + + ))} +
+
+ + {/* Family-specific controls */} +
+ {kind === 'categorical' && ( + + )} + + {kind === 'sequential' && ( + <> + + + + )} + + {kind === 'diverging' && ( + + )} +
+ + {/* Hint */} +

{hint}

+ + {/* Swatches */} +
+ {colors.map((c, i) => ( +
+ + {c.hex} +
+ ))} +
+ + {/* Export */} +
+ Export: + + + +
+ + {/* Categorical accessibility audit */} + {audit && ( +
+

+ Accessibility audit +

+
+ + Distinctness (ΔE {audit.minDeltaE.toFixed(3)}) + + + {audit.cvd.map((s) => { + const floor = categoricalCfg.minDeltaE * 0.6 + return ( + + {s.type} (ΔE {s.minDeltaE.toFixed(3)}) + = floor} + label={s.minDeltaE >= floor ? 'OK' : 'LOW'} + /> + + ) + })} +
+ {audit.failures.length > 0 && ( +
    + {audit.failures.map((f, i) => ( +
  • {f}
  • + ))} +
+ )} +

+ {DATAVIZ_HINTS.accessibility} +

+
+ )} + + {/* Chart preview (reuses the theme-builder chart + CVD sim) */} + +
+ ) +} diff --git a/apps/eds-color-palette-generator/src/config/categories.ts b/apps/eds-color-palette-generator/src/config/categories.ts new file mode 100644 index 0000000000..2e4421730a --- /dev/null +++ b/apps/eds-color-palette-generator/src/config/categories.ts @@ -0,0 +1,39 @@ +export const CATEGORY_GROUPS = [ + { label: 'Background', span: 2 }, + { label: 'Background Fill Muted', span: 3 }, + { label: 'Border', span: 3 }, + { label: 'Background Fill Emphasis', span: 3 }, + { label: 'Text', span: 4 }, +] as const + +/** + * EDS semantic token name per step (array is 0-indexed, steps are 1-indexed), + * matching the Contrast Table vocabulary. `sub` is the interaction state for + * tokens that have default/hover/pressed variants. + */ +export const STEP_TOKENS: ReadonlyArray<{ name: string; sub?: string }> = [ + { name: 'subtle', sub: 'default' }, + { name: 'subtle', sub: 'hover' }, + { name: 'subtle', sub: 'pressed' }, + { name: 'selected', sub: 'default' }, + { name: 'selected', sub: 'hover' }, + { name: 'selected', sub: 'pressed' }, + { name: 'muted' }, + { name: 'secondary' }, + { name: 'accent', sub: 'default' }, + { name: 'accent', sub: 'hover' }, + { name: 'accent', sub: 'pressed' }, + { name: 'primary' }, + { name: 'strong' }, + { name: 'on-accent' }, + { name: 'color on dark' }, +] + +/** Category groups aligned with the EDS token families (Token Matrix). */ +export const TOKEN_CATEGORY_GROUPS = [ + { label: 'Surface · subtle', span: 3 }, + { label: 'Surface · selected', span: 3 }, + { label: 'Neutral text', span: 2 }, + { label: 'Accent', span: 3 }, + { label: 'Text', span: 4 }, +] as const diff --git a/apps/eds-color-palette-generator/src/config/config.ts b/apps/eds-color-palette-generator/src/config/config.ts index 44503c1c5d..b38815bbd8 100644 --- a/apps/eds-color-palette-generator/src/config/config.ts +++ b/apps/eds-color-palette-generator/src/config/config.ts @@ -18,8 +18,12 @@ export const BG_CANVAS: StepDefinition = { id: 'bg-canvas', name: 'Background Canvas', category: 'Background', - lightValue: 0.97, - darkValue: 0.15, + // Light: lighter than surface (0.94) for clear separation, below the + // pure-white card step (1.0). Dark: 0.19 → #141414 (canvas), per the Figma + // "surfaces" artboard (node 136:26170). Chroma still follows the gaussian + // bell curve. + lightValue: 0.98, + darkValue: 0.19, } export const BG_SURFACE: StepDefinition = { @@ -27,8 +31,10 @@ export const BG_SURFACE: StepDefinition = { name: 'Background Surface', category: 'Background', variant: 'surface', - lightValue: 0.999, - darkValue: 0.25, + // Dark: 0.226 → #1c1c1c, the elevated card surface from the Figma "surfaces" + // artboard (node 136:26170), lifting subtly off the #141414 canvas. + lightValue: 0.94, + darkValue: 0.226, } export const BG_FILL_MUTED_DEFAULT: StepDefinition = { @@ -84,7 +90,7 @@ export const BORDER_SUBTLE: StepDefinition = { name: 'Border Subtle', category: 'Border', variant: 'subtle', - lightValue: 0.87, + lightValue: 0.77, darkValue: 0.47, contrastWith: [ { @@ -105,7 +111,7 @@ export const BORDER_MEDIUM: StepDefinition = { name: 'Border Medium', category: 'Border', variant: 'medium', - lightValue: 0.75, + lightValue: 0.72, darkValue: 0.61, contrastWith: [ { @@ -131,7 +137,7 @@ export const BORDER_STRONG: StepDefinition = { name: 'Border Strong', category: 'Border', variant: 'strong', - lightValue: 0.52, + lightValue: 0.62, darkValue: 0.76, contrastWith: [ { @@ -167,7 +173,7 @@ export const BG_FILL_EMPHASIS_DEFAULT: StepDefinition = { name: 'Background Fill Emphasis Default', category: 'Background Fill Emphasis', variant: 'default', - lightValue: 0.5, + lightValue: 0.52, darkValue: 0.82, contrastWith: [ { @@ -188,7 +194,7 @@ export const BG_FILL_EMPHASIS_HOVER: StepDefinition = { name: 'Background Fill Emphasis Hover', category: 'Background Fill Emphasis', variant: 'hover', - lightValue: 0.44, + lightValue: 0.47, darkValue: 0.88, contrastWith: [ { @@ -230,7 +236,7 @@ export const TEXT_SUBTLE: StepDefinition = { name: 'Text Subtle', category: 'Text', variant: 'subtle', - lightValue: 0.46, + lightValue: 0.37, darkValue: 0.91, contrastWith: [ { @@ -256,7 +262,7 @@ export const TEXT_STRONG: StepDefinition = { name: 'Text Strong', category: 'Text', variant: 'strong', - lightValue: 0.23, + lightValue: 0.32, darkValue: 0.99, contrastWith: [ { diff --git a/apps/eds-color-palette-generator/src/config/dataviz-defaults.ts b/apps/eds-color-palette-generator/src/config/dataviz-defaults.ts new file mode 100644 index 0000000000..ce2f082f32 --- /dev/null +++ b/apps/eds-color-palette-generator/src/config/dataviz-defaults.ts @@ -0,0 +1,69 @@ +import type { + CategoricalConfig, + SequentialConfig, + DivergingConfig, +} from './dataviz-types' + +/** + * OKLCH hues (deg) of the EDS seed colours, so categorical output starts + * EDS-branded before the generator extends past them to reach the target + * count. Achromatic Gray is excluded (it has no hue). Values are the OKLCH + * hue of each seed in src/config/palette.ts (verified via colorjs.io). + */ +export const EDS_CATEGORICAL_SEEDS: { name: string; hue: number }[] = [ + { name: 'Moss Green', hue: 204.9 }, + { name: 'Red', hue: 21.1 }, + { name: 'Orange', hue: 58.7 }, + { name: 'Green', hue: 143.0 }, + { name: 'Blue', hue: 240.7 }, + { name: 'North sea', hue: 252.5 }, +] + +export const DEFAULT_CATEGORICAL: CategoricalConfig = { + kind: 'categorical', + count: 8, + lightnessLight: 0.62, + lightnessDark: 0.72, + chroma: 0.14, + minContrastRatio: 3, + minDeltaE: 0.1, + enforceCVD: true, +} + +export const DEFAULT_SEQUENTIAL: SequentialConfig = { + kind: 'sequential', + steps: 7, + hue: 204.9, // Moss Green — the brand hue + lightnessRange: [0.96, 0.34], + chromaPeak: 0.13, +} + +export const DEFAULT_DIVERGING: DivergingConfig = { + kind: 'diverging', + steps: 9, + // Blue ↔ Orange: a CVD-safe pair (never red↔green, which protan/deuteranopia + // collapse). Blue anchors to North sea, orange to the Orange seed. + hueLow: 252.5, + hueHigh: 58.7, + neutralLightness: 0.95, + endLightness: 0.5, + chroma: 0.14, +} + +/** + * Inline UI reminders — NOT a maintained canonical palette or formal guidance + * doc (out of scope per the plan). Just enough to steer usage in the tool. + */ +export const DATAVIZ_HINTS: Record< + 'categorical' | 'sequential' | 'diverging' | 'accessibility', + string +> = { + categorical: + 'Distinct categories with no order. Keep to ~8 or fewer where you can — beyond that, colours get hard to tell apart even when CVD-safe; consider grouping or a sequential scale instead.', + sequential: + 'Ordered values low → high (heatmaps, gradients, magnitude). Read by lightness, so it stays legible in greyscale and for achromatopsia.', + diverging: + 'Values above / below a meaningful midpoint (e.g. ±, over / under target). Uses a CVD-safe hue pair (blue ↔ orange) through a light neutral.', + accessibility: + 'Colour alone is never enough — pair it with icons, direct labels, or patterns. 85% of surveyed teams already do.', +} diff --git a/apps/eds-color-palette-generator/src/config/dataviz-types.ts b/apps/eds-color-palette-generator/src/config/dataviz-types.ts new file mode 100644 index 0000000000..6027162750 --- /dev/null +++ b/apps/eds-color-palette-generator/src/config/dataviz-types.ts @@ -0,0 +1,71 @@ +/** + * Data-visualisation palette model. + * + * This lives ALONGSIDE the 15-step UI-token model (config.ts / types.ts) and + * never references StepDefinition, so the UI-token engine cannot regress. The + * three families cover the survey's three usage buckets: categorical (distinct + * categories), sequential (ordered data values), and diverging (± around a + * midpoint). + */ + +export type DatavizKind = 'categorical' | 'sequential' | 'diverging' + +export type ColorMode = 'light' | 'dark' + +export type CategoricalConfig = { + kind: 'categorical' + /** Target number of distinct series colours (supports 15+). */ + count: number + /** Optional explicit OKLCH hue anchors (deg). Defaults to the EDS seed hues. */ + seedHues?: number[] + /** OKLCH lightness of the series in light / dark mode. */ + lightnessLight: number + lightnessDark: number + /** OKLCH chroma of the series. */ + chroma: number + /** Minimum WCAG contrast ratio between the closest pair (default 3). */ + minContrastRatio: number + /** Minimum OKLab ΔE between any pair (default ~0.1). */ + minDeltaE: number + /** Enforce CVD-simulated separation (protan / deuter / tritan). */ + enforceCVD: boolean +} + +export type SequentialConfig = { + kind: 'sequential' + /** Number of stops — decoupled from the 15 UI steps. */ + steps: number + /** Hue in degrees, or a colour string to read the hue from. */ + hue: number | string + /** [lightEnd, darkEnd] OKLCH lightness. */ + lightnessRange: [number, number] + /** Peak chroma (shaped by a Gaussian across the ramp). */ + chromaPeak: number +} + +export type DivergingConfig = { + kind: 'diverging' + /** Number of stops — odd keeps a single neutral midpoint. */ + steps: number + /** Low-end hue (deg). Must stay CVD-distinguishable from hueHigh. */ + hueLow: number + /** High-end hue (deg). */ + hueHigh: number + /** OKLCH lightness of the neutral midpoint. */ + neutralLightness: number + /** OKLCH lightness of the two saturated ends. */ + endLightness: number + /** Peak chroma of each half-ramp. */ + chroma: number +} + +export type DatavizConfig = + | CategoricalConfig + | SequentialConfig + | DivergingConfig + +/** A generated data-viz colour ready to render as a swatch / chart series. */ +export type SwatchColor = { + name: string + hex: string +} diff --git a/apps/eds-color-palette-generator/src/config/palette.ts b/apps/eds-color-palette-generator/src/config/palette-config.ts similarity index 53% rename from apps/eds-color-palette-generator/src/config/palette.ts rename to apps/eds-color-palette-generator/src/config/palette-config.ts index a891958e96..d564c0963b 100644 --- a/apps/eds-color-palette-generator/src/config/palette.ts +++ b/apps/eds-color-palette-generator/src/config/palette-config.ts @@ -7,14 +7,14 @@ export const paletteConfig: PaletteConfig = { meanDark: 0.7, stdDevDark: 2, colors: [ - { - name: 'Moss Green', - anchors: [ - { value: 'oklch(0.5915 0.0731 184.63)', step: 6 }, - { value: 'oklch(0.4973 0.084851 204.553)', step: 9 }, - ], - }, - { name: 'Gray', value: 'oklch(0.4091 0 0)' }, + // Accent — the fixed Equinor brand colour. Always #206F77. Kept as a single + // colour (not anchors) so the accent/default (step 9) is pinned in light and + // inverts to a light tint in dark (Model 1 / accessible), not pinned dark. + { name: 'Moss Green', value: '#206F77' }, + // Neutral — the fixed Gray ramp (achromatic). Base is #696969 so the step-9 + // pin lands on Figma's Gray/9 value; every other step is achromatic and + // unaffected by the base, so the ramp matches the Figma Light/Gray primitive. + { name: 'Gray', value: '#696969' }, { name: 'North sea', anchors: [ diff --git a/apps/eds-color-palette-generator/src/config/semanticColors.ts b/apps/eds-color-palette-generator/src/config/semanticColors.ts new file mode 100644 index 0000000000..a712f0ee77 --- /dev/null +++ b/apps/eds-color-palette-generator/src/config/semanticColors.ts @@ -0,0 +1,145 @@ +/** + * Canonical EDS semantic colour values for the preview components. + * + * LIGHT is transcribed from the Figma "Color Map Light" (node 73:314) and + * matches the generator's own OKLCH ramp output. + * + * DARK uses tonal inversion — the generator's OKLCH ramp output in dark mode. + * Accent/concept hues are LIGHTENED (not reused at their light-mode hex) so + * fills pop on dark surfaces and take dark text; neutrals stay pure-gray. This + * is the documented rule (plan/light-dark-parity-comparison.md §4a), chosen over + * the fixed-#206f77 Figma dark board (node 132:23906) for accessibility (WCAG + * fill contrast) and consistency with the generator engine. + * + * A few light-mode accent-emphasis values aren't emitted by the Figma light map + * and use the known brand values (marked below). + */ + +export type ColorMode = 'light' | 'dark' + +export type SemanticColors = Record + +const LIGHT: SemanticColors = { + // neutral surfaces + 'bg-neutral-canvas': '#f5f5f5', + 'bg-neutral-surface': '#ffffff', + 'bg-neutral-fill-muted-default': '#e1e1e1', + 'bg-neutral-fill-muted-hover': '#d4d4d4', + 'bg-neutral-fill-muted-active': '#c4c4c4', + 'bg-neutral-fill-emphasis-default': '#636363', + 'bg-neutral-fill-emphasis-hover': '#525252', + 'bg-neutral-fill-emphasis-active': '#4d4d4d', + 'bg-floating': '#ffffff', + 'bg-backdrop': '#aeaeae', + // accent surfaces + 'bg-accent-canvas': '#eaf8fa', + 'bg-accent-surface': '#f6ffff', + 'bg-accent-fill-muted-default': '#cfe7e9', + 'bg-accent-fill-muted-hover': '#bbdbdf', + 'bg-accent-fill-muted-active': '#a2cdd2', + 'bg-accent-fill-emphasis-default': '#206f77', // brand (not in MCP light dump) + 'bg-accent-fill-emphasis-hover': '#205c62', + 'bg-accent-fill-emphasis-active': '#1c5157', // derived (not in MCP light dump) + // borders + 'border-accent-subtle': '#bbdbdf', + 'border-accent-medium': '#7cbac1', + 'border-accent-strong': '#21767e', + 'border-neutral-subtle': '#d4d4d4', + 'border-neutral-medium': '#aeaeae', + 'border-neutral-strong': '#696969', + 'border-focus': '#6fb6e9', + // text + 'text-neutral-subtle': '#585858', + 'text-neutral-strong': '#333333', // (not in MCP light dump; Gray/13) + 'text-neutral-subtle-on-emphasis': '#dedede', + 'text-neutral-strong-on-emphasis': '#ffffff', + 'text-accent-subtle': '#1f6369', + 'text-accent-strong': '#141f20', + 'text-accent-subtle-on-emphasis': '#cae4e7', + 'text-accent-strong-on-emphasis': '#ffffff', + 'text-link': '#0070a9', + // concept — success / info / warning / danger + 'bg-success-fill-muted-default': '#cfeacc', + 'bg-success-fill-emphasis-default': '#207720', + 'text-success-subtle': '#20691f', + 'border-success-subtle': '#bbe0b8', + 'bg-info-fill-muted-default': '#cae6fa', + 'bg-info-fill-emphasis-default': '#006aa0', + 'text-info-subtle': '#015e8d', + 'border-info-subtle': '#b5daf5', + 'bg-warning-fill-muted-default': '#fbdac1', + 'bg-warning-fill-emphasis-default': '#9b4900', + 'text-warning-subtle': '#8a4100', + 'border-warning-subtle': '#f6caaa', + 'bg-danger-fill-muted-default': '#ffd0ce', + 'bg-danger-fill-emphasis-default': '#bc002a', + 'text-danger-subtle': '#a50827', + 'border-danger-subtle': '#ffbcba', +} + +const DARK: SemanticColors = { + // Tonal-inversion dark palette — the generator's OKLCH ramp output (dark mode). + // Neutrals use the pure-gray "Gray" ramp; accent uses the "Moss Green" ramp + // LIGHTENED (emphasis fill #8cd2da, not #206f77) so it pops on dark surfaces + // and takes dark text. See plan/light-dark-parity-comparison.md §4a. + // + // neutral surfaces (Gray ramp) + 'bg-neutral-canvas': '#0b0b0b', // Gray/1 + 'bg-neutral-surface': '#222222', // Gray/2 — brighter than canvas (elevation) + 'bg-neutral-fill-muted-default': '#5b5b5b', // Gray/3 + 'bg-neutral-fill-muted-hover': '#696969', // Gray/4 + 'bg-neutral-fill-muted-active': '#7a7a7a', // Gray/5 + 'bg-neutral-fill-emphasis-default': '#c4c4c4', // Gray/9 + 'bg-neutral-fill-emphasis-hover': '#d7d7d7', // Gray/10 + 'bg-neutral-fill-emphasis-active': '#e8e8e8', // Gray/11 + 'bg-floating': '#222222', // = surface + 'bg-backdrop': '#0b0b0b', // near-black modal scrim + // accent surfaces (Moss Green ramp) + 'bg-accent-canvas': '#0a0b0b', // Moss Green/1 + 'bg-accent-surface': '#1e2323', // Moss Green/2 + 'bg-accent-fill-muted-default': '#3c6266', // Moss Green/3 + 'bg-accent-fill-muted-hover': '#3e7378', // Moss Green/4 + 'bg-accent-fill-muted-active': '#41878e', // Moss Green/5 + 'bg-accent-fill-emphasis-default': '#8cd2da', // Moss Green/9 — light teal (tonal) + 'bg-accent-fill-emphasis-hover': '#ace3e9', // Moss Green/10 + 'bg-accent-fill-emphasis-active': '#c7f1f6', // Moss Green/11 + // borders + 'border-accent-subtle': '#3c6266', // Moss Green/6 + 'border-accent-medium': '#439199', // Moss Green/7 + 'border-accent-strong': '#6ec0c9', // Moss Green/8 + 'border-neutral-subtle': '#5b5b5b', // Gray/6 + 'border-neutral-medium': '#838383', // Gray/7 + 'border-neutral-strong': '#b1b1b1', // Gray/8 + 'border-focus': '#2d8bc5', // Blue/7 + // text + 'text-neutral-subtle': '#e1e1e1', // Gray/12 + 'text-neutral-strong': '#fcfcfc', // Gray/13 + 'text-neutral-subtle-on-emphasis': '#353535', // Gray/14 — dark text on light fill + 'text-neutral-strong-on-emphasis': '#030303', // Gray/15 — dark text on light fill + 'text-accent-subtle': '#bcebf1', // Moss Green/12 + 'text-accent-strong': '#e6ffff', // Moss Green/13 + 'text-accent-subtle-on-emphasis': '#2c3839', // Moss Green/14 — dark text on light fill + 'text-accent-strong-on-emphasis': '#030303', // Moss Green/15 — dark text on light accent fill + 'text-link': '#5abbfb', // Blue/8 + // concept — success / info / warning / danger (seed ramps, dark — already tonal) + 'bg-success-fill-muted-default': '#3c673a', + 'bg-success-fill-emphasis-default': '#8cdb87', + 'text-success-subtle': '#bcf2b8', + 'border-success-subtle': '#3c673a', + 'bg-info-fill-muted-default': '#33607e', + 'bg-info-fill-emphasis-default': '#7dceff', + 'text-info-subtle': '#b7e8ff', + 'border-info-subtle': '#33607e', + 'bg-warning-fill-muted-default': '#7e4e25', + 'bg-warning-fill-emphasis-default': '#ffad63', + 'text-warning-subtle': '#ffd4aa', + 'border-warning-subtle': '#7e4e25', + 'bg-danger-fill-muted-default': '#923a3c', + 'bg-danger-fill-emphasis-default': '#ffa3a1', + 'text-danger-subtle': '#ffd0ce', + 'border-danger-subtle': '#923a3c', +} + +export function getSemanticColors(mode: ColorMode): SemanticColors { + return mode === 'dark' ? DARK : LIGHT +} diff --git a/apps/eds-color-palette-generator/src/context/ColorSchemeContext.tsx b/apps/eds-color-palette-generator/src/context/ColorSchemeContext.tsx index d8c61c5efe..4229b1ca80 100644 --- a/apps/eds-color-palette-generator/src/context/ColorSchemeContext.tsx +++ b/apps/eds-color-palette-generator/src/context/ColorSchemeContext.tsx @@ -19,19 +19,27 @@ export function ColorSchemeProvider({ }: { children: React.ReactNode }) { - const [colorScheme, setColorScheme] = useState(() => { - // Check for saved preference first, then system preference - if (typeof window !== 'undefined') { - const savedScheme = localStorageUtils.getColorScheme('light') - if (savedScheme) { - return savedScheme - } - // Check system preference on mount if no saved preference - const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') - return mediaQuery.matches ? 'dark' : 'light' - } - return 'light' - }) + // Always initialise to 'light' so the server-rendered HTML and the first + // client render agree — reading localStorage/system preference in the + // useState initializer runs only on the client and causes a hydration + // mismatch (server 'light' vs client 'dark'), which can throw a hydration + // error and leave the page in a broken state. The saved / system preference + // is applied on mount in the effect below instead. + const [colorScheme, setColorScheme] = useState('light') + + useEffect(() => { + // Apply the saved preference, or fall back to the system preference. This + // runs once on the client after hydration, so the first render still + // matches the server ('light') and there is no hydration mismatch. + const savedScheme = localStorageUtils.getColorScheme('light') + const next: ColorScheme = savedScheme + ? savedScheme + : window.matchMedia('(prefers-color-scheme: dark)').matches + ? 'dark' + : 'light' + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional client-only preference sync on mount; server renders 'light' to avoid a hydration mismatch + setColorScheme(next) + }, []) useEffect(() => { // Listen for system changes (but don't override saved preference automatically) diff --git a/apps/eds-color-palette-generator/src/utils/color.ts b/apps/eds-color-palette-generator/src/utils/color.ts index 6bfe9df6b3..a9b68e75ae 100644 --- a/apps/eds-color-palette-generator/src/utils/color.ts +++ b/apps/eds-color-palette-generator/src/utils/color.ts @@ -208,21 +208,25 @@ export function generateColorScaleWithInterpolation( interpolatedColor = range(t) } - // Convert to OKLCH to extract and modify values - const oklchColor = interpolatedColor.to('oklch') - - // Get the interpolated hue and base chroma - const interpolatedHue = oklchColor.h == null || isNaN(oklchColor.h) ? 0 : oklchColor.h - const baseChroma = oklchColor.c ?? 0 - - // Create the final color with Gaussian-adjusted chroma - const finalColor = createColorWithGaussianChroma( - targetLightness, - baseChroma, - interpolatedHue, - mean, - stdDev, - ) + let finalColor: Color + + if (exactAnchor) { + // Exact anchor step — preserve the anchor color as-is + finalColor = interpolatedColor.to('oklch') + } else { + // Interpolated step — apply Gaussian chroma and target lightness + const oklchColor = interpolatedColor.to('oklch') + const interpolatedHue = oklchColor.h == null || isNaN(oklchColor.h) ? 0 : oklchColor.h + const baseChroma = oklchColor.c ?? 0 + + finalColor = createColorWithGaussianChroma( + targetLightness, + baseChroma, + interpolatedHue, + mean, + stdDev, + ) + } // Format and add to array colors.push(formatColorAsString(finalColor, format)) @@ -335,3 +339,28 @@ export function contrast({ return '0' } } + +/** + * Perceptual colour difference between two colours. Used to keep categorical + * data-viz series visually distinct (not just contrasty). + * @param a - First colour (any colorjs.io-supported format) + * @param b - Second colour + * @param method - 'OK' (OKLab ΔE, ~0-1 scale) or '2000' (CIEDE2000) + * @param silent - Whether to suppress error logging (useful for tests) + * @returns ΔE as a number (0 = identical), or 0 on error + */ +export function deltaE( + a: string, + b: string, + method: '2000' | 'OK' = 'OK', + silent = false, +): number { + try { + return new Color(a).deltaE(new Color(b), method) + } catch (error) { + if (!silent) { + console.error('Error calculating deltaE:', error) + } + return 0 + } +} diff --git a/apps/eds-color-palette-generator/src/utils/contrastPageData.ts b/apps/eds-color-palette-generator/src/utils/contrastPageData.ts new file mode 100644 index 0000000000..4e3a1ff762 --- /dev/null +++ b/apps/eds-color-palette-generator/src/utils/contrastPageData.ts @@ -0,0 +1,193 @@ +import Color from 'colorjs.io' +import { + STEP_ROLES, + PALETTES, + calcContrast, + type ContrastResult, +} from '@/utils/palette' + +export function getLightness(hex: string): number { + try { + return new Color(hex).to('oklch').l ?? 0 + } catch { + return 0 + } +} + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +export type TextChoice = ContrastResult & { color: string; label: string } + +export type StepData = { + step: number + role: string + hex: string + recommended: TextChoice + paletteText: TextChoice +} + +export type SortOrder = 'semantic' | 'gradient' + +export type ViewMode = 'semantic' | 'gradient' | 'combined' + +/* ------------------------------------------------------------------ */ +/* Combined view — real cross-palette interaction patterns */ +/* ------------------------------------------------------------------ */ + +export type Pairing = { + state: string + type: 'text' | 'border' | 'fill' + fg: { label: string; hex: string } + bg: { label: string; hex: string } + contrast: ContrastResult +} + +export type PatternGroup = { + title: string + description: string + pairings: Pairing[] +} + +export function buildPatternGroups(): PatternGroup[] { + const g = PALETTES[1].steps // Gray + const a = PALETTES[0].steps // Moss Green (Accent) + + function p( + state: string, + fgLabel: string, + fgHex: string, + bgLabel: string, + bgHex: string, + type: 'text' | 'border' | 'fill' = 'text', + ): Pairing { + return { + state, + type, + fg: { label: fgLabel, hex: fgHex }, + bg: { label: bgLabel, hex: bgHex }, + contrast: calcContrast(fgHex, bgHex), + } + } + + return [ + { + title: 'Border: Neutral → Accent on selection', + description: 'Border starts Gray, switches to Accent on focus/selected', + pairings: [ + p('Default', `Gray/6 border-subtle`, g[5], `Gray/1 bg-canvas`, g[0], 'border'), + p('Hover', `Gray/7 border-medium`, g[6], `Gray/1 bg-canvas`, g[0], 'border'), + p('Selected', `Accent/8 border-strong`, a[7], `Gray/1 bg-canvas`, g[0], 'border'), + p('Default', `Gray/6 border-subtle`, g[5], `Gray/2 bg-surface`, g[1], 'border'), + p('Hover', `Gray/7 border-medium`, g[6], `Gray/2 bg-surface`, g[1], 'border'), + p('Selected', `Accent/8 border-strong`, a[7], `Gray/2 bg-surface`, g[1], 'border'), + ], + }, + { + title: 'Accent text on Neutral backgrounds', + description: 'Accent-colored text and icons on Gray surfaces', + pairings: [ + p('Subtle', `Accent/12 text-subtle`, a[11], `Gray/1 bg-canvas`, g[0]), + p('Strong', `Accent/13 text-strong`, a[12], `Gray/1 bg-canvas`, g[0]), + p('Subtle', `Accent/12 text-subtle`, a[11], `Gray/2 bg-surface`, g[1]), + p('Strong', `Accent/13 text-strong`, a[12], `Gray/2 bg-surface`, g[1]), + p('Subtle', `Accent/12 text-subtle`, a[11], `Gray/3 bg-fill-muted`, g[2]), + p('Strong', `Accent/13 text-strong`, a[12], `Gray/3 bg-fill-muted`, g[2]), + ], + }, + { + title: 'Accent fills on Neutral canvas', + description: 'Accent muted/emphasis fills visible on Gray backgrounds', + pairings: [ + p('Muted', `Accent/3 fill-muted`, a[2], `Gray/1 bg-canvas`, g[0], 'fill'), + p('Muted', `Accent/3 fill-muted`, a[2], `Gray/2 bg-surface`, g[1], 'fill'), + p('Emphasis', `Accent/9 fill-emphasis`, a[8], `Gray/1 bg-canvas`, g[0], 'fill'), + p('Emphasis', `Accent/9 fill-emphasis`, a[8], `Gray/2 bg-surface`, g[1], 'fill'), + ], + }, + { + title: 'Text on Accent emphasis fills', + description: 'Readable text on Accent dark backgrounds', + pairings: [ + p('White', `White`, '#ffffff', `Accent/9 emphasis-default`, a[8]), + p('On-emphasis', `Accent/15 strong-on-emphasis`, a[14], `Accent/9 emphasis-default`, a[8]), + p('On-emphasis', `Accent/14 subtle-on-emphasis`, a[13], `Accent/9 emphasis-default`, a[8]), + p('Gray strong', `Gray/13 text-strong`, g[12], `Accent/9 emphasis-default`, a[8]), + ], + }, + { + title: 'Neutral text on Accent muted fills', + description: 'Text readability on light Accent backgrounds', + pairings: [ + p('Gray strong', `Gray/13 text-strong`, g[12], `Accent/3 fill-muted-default`, a[2]), + p('Accent strong', `Accent/13 text-strong`, a[12], `Accent/3 fill-muted-default`, a[2]), + p('Gray strong', `Gray/13 text-strong`, g[12], `Accent/5 fill-muted-active`, a[4]), + p('Accent strong', `Accent/13 text-strong`, a[12], `Accent/5 fill-muted-active`, a[4]), + ], + }, + ] +} + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +export function findBestPaletteText(bgIndex: number, steps: string[]): TextChoice { + const textStepIndices = [11, 12, 13, 14] + const candidates = steps + .map((hex, idx) => { + if (idx === bgIndex) return null + const result = calcContrast(hex, steps[bgIndex]) + return { + idx, + hex, + result, + wcagNum: parseFloat(result.wcag), + isTextStep: textStepIndices.includes(idx), + } + }) + .filter((c): c is NonNullable => c !== null) + + const find = (filter: (c: (typeof candidates)[0]) => boolean) => + candidates.filter(filter).sort((a, b) => b.wcagNum - a.wcagNum)[0] + + const pick = + find((c) => c.isTextStep && c.wcagNum >= 7) ?? + find((c) => c.wcagNum >= 7) ?? + find((c) => c.isTextStep && c.wcagNum >= 4.5) ?? + find((c) => c.wcagNum >= 4.5) ?? + [...candidates].sort((a, b) => b.wcagNum - a.wcagNum)[0] + + return { + ...pick.result, + color: pick.hex, + label: `Step ${pick.idx + 1} · ${STEP_ROLES[pick.idx]} (${pick.hex})`, + } +} + +export function buildStepData( + steps: string[], + mode: SortOrder, + primitiveIndices?: number[], +): StepData[] { + return steps.map((hex, index) => { + const white = calcContrast('#ffffff', hex) + const black = calcContrast('#000000', hex) + const useWhite = parseFloat(white.wcag) >= parseFloat(black.wcag) + const recommended: TextChoice = useWhite + ? { ...white, color: '#ffffff', label: 'White (#ffffff)' } + : { ...black, color: '#000000', label: 'Black (#000000)' } + + const paletteText = findBestPaletteText(index, steps) + const stepNumber = primitiveIndices ? primitiveIndices[index] : index + 1 + + return { + step: stepNumber, + role: mode === 'semantic' ? STEP_ROLES[index] : '', + hex, + recommended, + paletteText, + } + }) +} diff --git a/apps/eds-color-palette-generator/src/utils/customPalettesStore.ts b/apps/eds-color-palette-generator/src/utils/customPalettesStore.ts new file mode 100644 index 0000000000..d7b60e50c7 --- /dev/null +++ b/apps/eds-color-palette-generator/src/utils/customPalettesStore.ts @@ -0,0 +1,42 @@ +import { useSyncExternalStore } from 'react' +import { getSimulationPalettes, type TokenPalette } from '@/utils/palette' + +/* ------------------------------------------------------------------ */ +/* Custom-palette store — SSR-safe mirror of the localStorage bridge */ +/* filled by the Palette Editor. useSyncExternalStore renders [] on */ +/* the server + first client paint, then the stored value after */ +/* hydration; refreshCustomPalettes() re-reads on demand. */ +/* ------------------------------------------------------------------ */ + +const SIMULATION_REFRESH_EVENT = 'eds-simulation-palettes-refresh' +const EMPTY_PALETTES: TokenPalette[] = [] +let cachedRaw: string | null = null +let cachedPalettes: TokenPalette[] = EMPTY_PALETTES + +function getSnapshot(): TokenPalette[] { + const next = getSimulationPalettes() // [] during SSR (window guard) + const raw = JSON.stringify(next) // stable ref unless content changed + if (raw === cachedRaw) return cachedPalettes + cachedRaw = raw + cachedPalettes = next + return cachedPalettes +} + +function getServerSnapshot(): TokenPalette[] { + return EMPTY_PALETTES +} + +function subscribe(callback: () => void): () => void { + window.addEventListener(SIMULATION_REFRESH_EVENT, callback) + return () => window.removeEventListener(SIMULATION_REFRESH_EVENT, callback) +} + +/** Subscribe to the custom palettes saved by the Palette Editor. */ +export function useCustomPalettes(): TokenPalette[] { + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) +} + +/** Re-read the stored custom palettes on demand (e.g. a Refresh button). */ +export function refreshCustomPalettes(): void { + window.dispatchEvent(new Event(SIMULATION_REFRESH_EVENT)) +} diff --git a/apps/eds-color-palette-generator/src/utils/cvd.ts b/apps/eds-color-palette-generator/src/utils/cvd.ts new file mode 100644 index 0000000000..65e9e3bdf6 --- /dev/null +++ b/apps/eds-color-palette-generator/src/utils/cvd.ts @@ -0,0 +1,75 @@ +import Color from 'colorjs.io' + +export type CVDType = + | 'none' + | 'protanopia' + | 'deuteranopia' + | 'tritanopia' + | 'achromatopsia' + +export const CVD_OPTIONS: { key: CVDType; label: string }[] = [ + { key: 'none', label: 'Normal vision' }, + { key: 'protanopia', label: 'Protanopia (no red)' }, + { key: 'deuteranopia', label: 'Deuteranopia (no green)' }, + { key: 'tritanopia', label: 'Tritanopia (no blue)' }, + { key: 'achromatopsia', label: 'Achromatopsia (grayscale)' }, +] + +/** The three dichromacies used for generation-time distinctness scoring. */ +export const CVD_DICHROMACIES = [ + 'protanopia', + 'deuteranopia', + 'tritanopia', +] as const + +/** + * SVG feColorMatrix values (4×5) for colour-vision-deficiency simulation. + * Shared by the on-screen SVG filter (CVDFilter) and the numeric simulateCVD + * below, so the preview and the headless accessibility scoring use identical + * maths. + */ +export const CVD_MATRICES: Record, string> = { + protanopia: + '0.567 0.433 0 0 0 0.558 0.442 0 0 0 0 0.242 0.758 0 0 0 0 0 1 0', + deuteranopia: + '0.625 0.375 0 0 0 0.7 0.3 0 0 0 0 0.3 0.7 0 0 0 0 0 1 0', + tritanopia: + '0.95 0.05 0 0 0 0 0.433 0.567 0 0 0 0.475 0.525 0 0 0 0 0 1 0', + achromatopsia: + '0.299 0.587 0.114 0 0 0.299 0.587 0.114 0 0 0.299 0.587 0.114 0 0 0 0 0 1 0', +} + +function parseMatrix(values: string): number[] { + return values.trim().split(/\s+/).map(Number) +} + +const clamp01 = (n: number) => Math.min(1, Math.max(0, n)) + +/** + * Numerically simulate how a colour appears under a given CVD type, so + * distinguishability can be scored without a DOM. Applies the same + * feColorMatrix used for the on-screen SVG filter, in (gamma-encoded) sRGB. + * @param input - Colour string (any colorjs.io-supported format) + * @param type - CVD type; 'none' returns the input unchanged + * @returns a hex string; the input unchanged for 'none' or on error + */ +export function simulateCVD(input: string, type: CVDType): string { + if (type === 'none') return input + try { + const m = parseMatrix(CVD_MATRICES[type]) + const [r, g, b] = new Color(input) + .to('srgb') + .coords.map((c) => clamp01(c ?? 0)) + // feColorMatrix rows: out = m[row*5+0]*r + *1*g + *2*b + *3*a + *4(offset). + // Alpha is fixed at 1, so the alpha column (index 3) drops out. + const apply = (row: number) => + clamp01( + m[row * 5] * r + m[row * 5 + 1] * g + m[row * 5 + 2] * b + m[row * 5 + 4], + ) + const out = new Color('srgb', [apply(0), apply(1), apply(2)]) + out.alpha = 1 + return out.toString({ format: 'hex' }) + } catch { + return input + } +} diff --git a/apps/eds-color-palette-generator/src/utils/dataviz-a11y.test.ts b/apps/eds-color-palette-generator/src/utils/dataviz-a11y.test.ts new file mode 100644 index 0000000000..d4647ca04c --- /dev/null +++ b/apps/eds-color-palette-generator/src/utils/dataviz-a11y.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from 'vitest' +import { simulateCVD } from './cvd' +import { minPairContrast, minPairDeltaE, scoreCVDSafety } from './dataviz-a11y' + +describe('simulateCVD', () => { + test('returns the input unchanged for "none"', () => { + expect(simulateCVD('#3d7bff', 'none')).toBe('#3d7bff') + }) + + test('returns a valid hex for each dichromacy', () => { + for (const type of ['protanopia', 'deuteranopia', 'tritanopia'] as const) { + expect(simulateCVD('#cc0000', type)).toMatch(/^#[0-9a-f]{6}$/) + } + }) + + test('collapses a red/green pair under deuteranopia', () => { + const before = minPairDeltaE(['#cc0000', '#00aa00']) + const after = minPairDeltaE([ + simulateCVD('#cc0000', 'deuteranopia'), + simulateCVD('#00aa00', 'deuteranopia'), + ]) + expect(after).toBeLessThan(before) + }) +}) + +describe('minPair helpers', () => { + test('minPairContrast of black + white is 21', () => { + expect(minPairContrast(['#000000', '#ffffff'])).toBeCloseTo(21, 0) + }) + + test('single-colour input yields Infinity (no pairs)', () => { + expect(minPairContrast(['#123456'])).toBe(Infinity) + expect(minPairDeltaE(['#123456'])).toBe(Infinity) + }) +}) + +describe('scoreCVDSafety', () => { + test('scores all three dichromacies with a worst-pair index', () => { + const scores = scoreCVDSafety(['#cc0000', '#00aa00', '#0000cc']) + expect(scores.map((s) => s.type)).toEqual([ + 'protanopia', + 'deuteranopia', + 'tritanopia', + ]) + for (const s of scores) { + expect(s.worstPair).toHaveLength(2) + expect(s.minDeltaE).toBeGreaterThanOrEqual(0) + } + }) +}) diff --git a/apps/eds-color-palette-generator/src/utils/dataviz-a11y.ts b/apps/eds-color-palette-generator/src/utils/dataviz-a11y.ts new file mode 100644 index 0000000000..0dcc5c17cb --- /dev/null +++ b/apps/eds-color-palette-generator/src/utils/dataviz-a11y.ts @@ -0,0 +1,139 @@ +import { contrast, deltaE } from './color' +import { simulateCVD, CVD_DICHROMACIES } from './cvd' +import type { CategoricalConfig, SwatchColor } from '@/config/dataviz-types' + +/** + * Fraction of the normal-vision ΔE floor that must survive under each + * dichromacy. CVD always compresses separation, so we allow some loss: a + * well-formed small palette stays comfortably above this, while over-crowded + * palettes fall below it and are reported as unsafe. + */ +const CVD_TOLERANCE = 0.6 + +/** Minimum WCAG21 contrast ratio across every unordered colour pair. */ +export function minPairContrast(colors: string[]): number { + if (colors.length < 2) return Infinity + let min = Infinity + for (let i = 0; i < colors.length; i++) { + for (let j = i + 1; j < colors.length; j++) { + const ratio = Number( + contrast({ + foreground: colors[i], + background: colors[j], + algorithm: 'WCAG21', + silent: true, + }), + ) + if (ratio < min) min = ratio + } + } + return min +} + +/** Minimum perceptual ΔE across every unordered colour pair. */ +export function minPairDeltaE( + colors: string[], + method: '2000' | 'OK' = 'OK', +): number { + if (colors.length < 2) return Infinity + let min = Infinity + for (let i = 0; i < colors.length; i++) { + for (let j = i + 1; j < colors.length; j++) { + const d = deltaE(colors[i], colors[j], method, true) + if (d < min) min = d + } + } + return min +} + +export type CVDScore = { + type: (typeof CVD_DICHROMACIES)[number] + minDeltaE: number + worstPair: [number, number] +} + +/** + * For each dichromacy, simulate all colours and find the least-separated pair. + * A low minDeltaE means two series would look alike to that viewer. + */ +export function scoreCVDSafety(colors: string[]): CVDScore[] { + return CVD_DICHROMACIES.map((type) => { + const simulated = colors.map((c) => simulateCVD(c, type)) + let minDeltaE = Infinity + let worstPair: [number, number] = [0, 0] + for (let i = 0; i < simulated.length; i++) { + for (let j = i + 1; j < simulated.length; j++) { + const d = deltaE(simulated[i], simulated[j], 'OK', true) + if (d < minDeltaE) { + minDeltaE = d + worstPair = [i, j] + } + } + } + return { type, minDeltaE, worstPair } + }) +} + +export type CategoricalAudit = { + /** Closest-pair perceptual ΔE meets cfg.minDeltaE (authoritative gate). */ + passesDeltaE: boolean + /** Every dichromacy keeps pairs distinguishable (authoritative gate). */ + cvdSafe: boolean + minDeltaE: number + cvd: CVDScore[] + /** + * Closest-pair WCAG ratio — INFORMATIONAL only. Categorical series are + * distinguished by hue, so iso-luminant colours legitimately sit near 1:1; + * luminance contrast is the wrong distinctness metric here (it belongs to + * colour-vs-background checks, WCAG 1.4.11), so it never fails the audit. + */ + minContrast: number + /** Human-readable descriptions of any failed gate. */ + failures: string[] +} + +/** + * Audit a generated categorical palette against its distinctness gates: + * closest-pair perceptual ΔE and CVD-simulated separation across + * protan/deuter/tritan. Returns a structured report rather than throwing, so + * the UI can show exactly which gate failed and by how much — at high counts + * perfect separation is impossible, and surfacing that beats hiding it. + */ +export function auditCategorical( + colors: SwatchColor[], + cfg: CategoricalConfig, +): CategoricalAudit { + const hexes = colors.map((c) => c.hex) + const minContrast = minPairContrast(hexes) + const minDeltaE = minPairDeltaE(hexes) + const cvd = scoreCVDSafety(hexes) + + const cvdFloor = cfg.minDeltaE * CVD_TOLERANCE + const passesDeltaE = minDeltaE >= cfg.minDeltaE + const cvdSafe = !cfg.enforceCVD || cvd.every((s) => s.minDeltaE >= cvdFloor) + + const failures: string[] = [] + if (!passesDeltaE) { + failures.push( + `Closest pair ΔE ${minDeltaE.toFixed(3)} is below the ${cfg.minDeltaE} distinctness floor — colours may be hard to tell apart.`, + ) + } + if (cfg.enforceCVD) { + cvd + .filter((s) => s.minDeltaE < cvdFloor) + .forEach((s) => { + failures.push( + `${s.type}: colours ${s.worstPair[0] + 1} and ${s.worstPair[1] + 1} collapse (ΔE ${s.minDeltaE.toFixed(3)}).`, + ) + }) + } + + return { + passesDeltaE, + cvdSafe, + minDeltaE, + cvd, + minContrast, + failures, + } +} diff --git a/apps/eds-color-palette-generator/src/utils/dataviz-export.ts b/apps/eds-color-palette-generator/src/utils/dataviz-export.ts new file mode 100644 index 0000000000..a2c9f1d7dc --- /dev/null +++ b/apps/eds-color-palette-generator/src/utils/dataviz-export.ts @@ -0,0 +1,53 @@ +import { formatColorsAsTokens } from './tokenFormatter' +import type { DatavizKind, SwatchColor } from '@/config/dataviz-types' + +const KIND_PREFIX: Record = { + categorical: 'cat', + sequential: 'seq', + diverging: 'div', +} + +/** A plain hex array — drop-in for Recharts / Chart.js / ECharts / Nivo. */ +export function toHexArray(colors: SwatchColor[]): string { + return JSON.stringify( + colors.map((c) => c.hex), + null, + 2, + ) +} + +/** CSS custom properties, e.g. `--eds-dataviz-cat-1: #...;`. */ +export function toCssVars(colors: SwatchColor[], kind: DatavizKind): string { + const prefix = KIND_PREFIX[kind] + const lines = colors.map( + (c, i) => ` --eds-dataviz-${prefix}-${i + 1}: ${c.hex};`, + ) + return `:root {\n${lines.join('\n')}\n}\n` +} + +/** + * W3C Design Tokens Community Group JSON, grouped under the family name. + * Reuses the same token shape as the UI-token export (tokenFormatter) — this + * is an ad-hoc export of whatever the user generated, not a maintained set. + */ +export function toW3CTokens(colors: SwatchColor[], kind: DatavizKind): string { + return formatColorsAsTokens({ [kind]: colors.map((c) => c.hex) }) +} + +/** Trigger a browser download of `content` as `filename`. */ +export function downloadText( + filename: string, + content: string, + mime = 'text/plain', +): void { + if (typeof document === 'undefined') return + const blob = new Blob([content], { type: mime }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) +} diff --git a/apps/eds-color-palette-generator/src/utils/dataviz.test.ts b/apps/eds-color-palette-generator/src/utils/dataviz.test.ts new file mode 100644 index 0000000000..9b503f4c7b --- /dev/null +++ b/apps/eds-color-palette-generator/src/utils/dataviz.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, test } from 'vitest' +import Color from 'colorjs.io' +import { + generateCategoricalPalette, + generateSequentialScale, + generateDivergingScale, +} from './dataviz' +import { minPairContrast, minPairDeltaE, auditCategorical } from './dataviz-a11y' +import { + DEFAULT_CATEGORICAL, + DEFAULT_SEQUENTIAL, + DEFAULT_DIVERGING, +} from '@/config/dataviz-defaults' +import type { CategoricalConfig } from '@/config/dataviz-types' + +const lightnessOf = (hex: string) => new Color(hex).to('oklch').l ?? 0 + +describe('generateCategoricalPalette', () => { + test('returns exactly `count` colours', () => { + for (const count of [3, 6, 8, 12, 15, 20]) { + const cfg: CategoricalConfig = { ...DEFAULT_CATEGORICAL, count } + expect(generateCategoricalPalette(cfg, 'light')).toHaveLength(count) + } + }) + + test('every colour is a valid 6-digit hex', () => { + const colors = generateCategoricalPalette( + { ...DEFAULT_CATEGORICAL, count: 10 }, + 'light', + ) + for (const c of colors) { + expect(c.hex).toMatch(/^#[0-9a-f]{6}$/) + } + }) + + test('small counts keep pairs perceptually separated (ΔE)', () => { + const colors = generateCategoricalPalette( + { ...DEFAULT_CATEGORICAL, count: 6 }, + 'light', + ) + const hexes = colors.map((c) => c.hex) + // Categorical distinctness is perceptual (ΔE), not luminance contrast — + // iso-luminant hues legitimately have low WCAG contrast with each other. + expect(minPairDeltaE(hexes)).toBeGreaterThanOrEqual( + DEFAULT_CATEGORICAL.minDeltaE, + ) + expect(minPairContrast(hexes)).toBeGreaterThan(1) + }) + + test('dark mode produces different (lighter) colours than light mode', () => { + const cfg: CategoricalConfig = { ...DEFAULT_CATEGORICAL, count: 6 } + const light = generateCategoricalPalette(cfg, 'light') + const dark = generateCategoricalPalette(cfg, 'dark') + expect(light.map((c) => c.hex)).not.toEqual(dark.map((c) => c.hex)) + }) +}) + +describe('generateSequentialScale', () => { + test('lightness is strictly monotonic', () => { + const scale = generateSequentialScale( + { ...DEFAULT_SEQUENTIAL, steps: 9 }, + 'light', + ) + const ls = scale.map((s) => lightnessOf(s.hex)) + for (let i = 1; i < ls.length; i++) { + // light mode goes light → dark, so lightness strictly decreases + expect(ls[i]).toBeLessThan(ls[i - 1]) + } + }) + + test('dark mode reverses direction (strictly increasing lightness)', () => { + const scale = generateSequentialScale( + { ...DEFAULT_SEQUENTIAL, steps: 9 }, + 'dark', + ) + const ls = scale.map((s) => lightnessOf(s.hex)) + for (let i = 1; i < ls.length; i++) { + expect(ls[i]).toBeGreaterThan(ls[i - 1]) + } + }) + + test('honours the requested step count', () => { + expect(generateSequentialScale({ ...DEFAULT_SEQUENTIAL, steps: 5 }, 'light')) + .toHaveLength(5) + }) +}) + +describe('generateDivergingScale', () => { + test('is symmetric about the neutral midpoint (mirrored lightness)', () => { + const scale = generateDivergingScale( + { ...DEFAULT_DIVERGING, steps: 9 }, + 'light', + ) + const ls = scale.map((s) => lightnessOf(s.hex)) + const n = ls.length + for (let i = 0; i < Math.floor(n / 2); i++) { + expect(ls[i]).toBeCloseTo(ls[n - 1 - i], 2) + } + }) + + test('centre stop is the least saturated (near-neutral)', () => { + const scale = generateDivergingScale( + { ...DEFAULT_DIVERGING, steps: 9 }, + 'light', + ) + const chroma = scale.map((s) => new Color(s.hex).to('oklch').c ?? 0) + const mid = Math.floor(scale.length / 2) + for (let i = 0; i < scale.length; i++) { + if (i !== mid) expect(chroma[mid]).toBeLessThanOrEqual(chroma[i] + 1e-6) + } + }) +}) + +describe('auditCategorical', () => { + test('reports pass gates for a well-separated small palette', () => { + const cfg: CategoricalConfig = { ...DEFAULT_CATEGORICAL, count: 5 } + const colors = generateCategoricalPalette(cfg, 'light') + const audit = auditCategorical(colors, cfg) + expect(audit.passesDeltaE).toBe(true) + expect(audit.cvdSafe).toBe(true) + expect(audit.cvd).toHaveLength(3) + }) + + test('collects failures when the distinctness floor is unreachable', () => { + // An impossible ΔE floor forces a failure to be reported (not thrown). + const cfg: CategoricalConfig = { + ...DEFAULT_CATEGORICAL, + count: 12, + minDeltaE: 0.9, + } + const colors = generateCategoricalPalette(cfg, 'light') + const audit = auditCategorical(colors, cfg) + expect(audit.passesDeltaE).toBe(false) + expect(audit.failures.length).toBeGreaterThan(0) + }) +}) diff --git a/apps/eds-color-palette-generator/src/utils/dataviz.ts b/apps/eds-color-palette-generator/src/utils/dataviz.ts new file mode 100644 index 0000000000..33a01d7951 --- /dev/null +++ b/apps/eds-color-palette-generator/src/utils/dataviz.ts @@ -0,0 +1,249 @@ +import Color from 'colorjs.io' +import { gaussian, deltaE } from './color' +import { simulateCVD, CVD_DICHROMACIES } from './cvd' +import { + minPairContrast, + minPairDeltaE, + scoreCVDSafety, + auditCategorical, +} from './dataviz-a11y' +import { EDS_CATEGORICAL_SEEDS } from '@/config/dataviz-defaults' +import type { + CategoricalConfig, + SequentialConfig, + DivergingConfig, + ColorMode, + SwatchColor, +} from '@/config/dataviz-types' + +/* ------------------------------------------------------------------ */ +/* Shared helpers */ +/* ------------------------------------------------------------------ */ + +const clamp = (n: number, min: number, max: number) => + Math.min(max, Math.max(min, n)) + +/** + * Build a hex from OKLCH, gamut-mapping into sRGB (CSS Color 4 method) so + * out-of-gamut hue/chroma combinations reduce chroma cleanly instead of + * clipping to a muddy colour. + */ +function oklchToHex(l: number, c: number, h: number): string { + try { + const mapped = new Color('oklch', [l, c, h]).toGamut({ + space: 'srgb', + method: 'css', + }) + const srgb = mapped.to('srgb') + srgb.alpha = 1 + return srgb.toString({ format: 'hex' }) + } catch { + return '#808080' + } +} + +function resolveHue(hue: number | string): number { + if (typeof hue === 'number') return hue + try { + const o = new Color(hue).to('oklch') + return o.h == null || isNaN(o.h) ? 0 : o.h + } catch { + return 0 + } +} + +/* ------------------------------------------------------------------ */ +/* Categorical (qualitative) */ +/* ------------------------------------------------------------------ */ + +type Candidate = { name: string; hue: number } + +/** + * Distribute the target hues. Small counts stay on the exact EDS seed hues + * (branded); larger counts extend to evenly spaced hues around the OKLCH + * wheel, phase-aligned to the first seed hue, so we can reach 15+. + */ +function categoricalHues(cfg: CategoricalConfig): number[] { + const base = + cfg.seedHues && cfg.seedHues.length + ? cfg.seedHues + : EDS_CATEGORICAL_SEEDS.map((s) => s.hue) + if (cfg.count <= base.length) return base.slice(0, cfg.count) + const phase = base[0] + return Array.from( + { length: cfg.count }, + (_, i) => (phase + (360 / cfg.count) * i) % 360, + ) +} + +/** + * Each colour represented as [normal, protan, deuter, tritan] hexes. The + * "worst-case" distance between two colours is the minimum ΔE across these — + * so a red/green pair that collapses under deuteranopia scores as *close*, + * even though it looks far apart to normal vision. + */ +function cvdReps(hex: string): string[] { + return [hex, ...CVD_DICHROMACIES.map((t) => simulateCVD(hex, t))] +} + +function worstCaseDistance(repsA: string[], repsB: string[]): number { + let min = Infinity + for (let k = 0; k < repsA.length; k++) { + const d = deltaE(repsA[k], repsB[k], 'OK', true) + if (d < min) min = d + } + return min +} + +/** + * Greedy farthest-point ordering under the worst-case-CVD metric. Colours that + * collapse under some dichromacy get pushed maximally far apart in the + * sequence, so the subsequent lightness ramp gives them the biggest luminance + * gap. `refHex` is the flat-lightness colour used only for the distance metric. + */ +function orderForCVD(cands: Candidate[], refHex: (c: Candidate) => string) { + if (cands.length <= 2) return cands + const reps = cands.map((c) => cvdReps(refHex(c))) + const remaining = cands.map((_, i) => i) + const order = [remaining.shift() as number] + while (remaining.length) { + let bestPos = 0 + let bestScore = -Infinity + remaining.forEach((idx, pos) => { + const minDist = Math.min( + ...order.map((o) => worstCaseDistance(reps[idx], reps[o])), + ) + if (minDist > bestScore) { + bestScore = minDist + bestPos = pos + } + }) + order.push(remaining.splice(bestPos, 1)[0]) + } + return order.map((i) => cands[i]) +} + +function buildCategorical( + cands: Candidate[], + baseL: number, + chroma: number, + amplitude: number, +): SwatchColor[] { + const n = cands.length + // Linear lightness ramp across the CVD-ordered sequence: collapse-prone + // pairs (now far apart in order) land at opposite ends → maximal luminance + // separation. This also makes the palette legible in greyscale + // (achromatopsia), which colour rotation alone cannot achieve. + return cands.map((c, i) => { + const t = n === 1 ? 0.5 : i / (n - 1) + const l = clamp(baseL + amplitude * (t - 0.5) * 2, 0.35, 0.85) + return { name: c.name, hex: oklchToHex(l, chroma, c.hue) } + }) +} + +/** + * Generate a categorical (qualitative) palette of `cfg.count` distinct, + * CVD-aware colours for the given mode. Hues rotate around the OKLCH wheel + * (starting EDS-branded); when `enforceCVD` is set, colours are ordered by + * worst-case CVD distance and a lightness ramp is tuned so collapse-prone + * pairs stay separable under protan/deuter/tritan and in greyscale. + * Distinctness is *reported* by {@link auditCategorical}, not thrown — at high + * counts perfect separation is impossible, and surfacing that trade-off is + * more honest than hiding it. + */ +export function generateCategoricalPalette( + cfg: CategoricalConfig, + mode: ColorMode, +): SwatchColor[] { + const baseL = mode === 'dark' ? cfg.lightnessDark : cfg.lightnessLight + const hues = categoricalHues(cfg) + const usingSeeds = !cfg.seedHues && cfg.count <= EDS_CATEGORICAL_SEEDS.length + const cands: Candidate[] = hues.map((hue, i) => ({ + name: usingSeeds ? EDS_CATEGORICAL_SEEDS[i].name : `Series ${i + 1}`, + hue, + })) + + const flatHex = (c: Candidate) => oklchToHex(baseL, cfg.chroma, c.hue) + + if (!cfg.enforceCVD) { + return buildCategorical(cands, baseL, cfg.chroma, 0.06) + } + + const ordered = orderForCVD(cands, flatHex) + // Pick the lightness-ramp amplitude that maximises the worst CVD-simulated + // separation without over-darkening; larger amplitudes help but eventually + // clip against the gamut / lightness bounds. + const best = [0.1, 0.16, 0.22, 0.28] + .map((amplitude) => { + const colors = buildCategorical(ordered, baseL, cfg.chroma, amplitude) + const worstCvd = Math.min( + ...scoreCVDSafety(colors.map((c) => c.hex)).map((s) => s.minDeltaE), + ) + return { colors, worstCvd } + }) + .reduce((a, b) => (b.worstCvd > a.worstCvd ? b : a)) + return best.colors +} + +/* ------------------------------------------------------------------ */ +/* Sequential (ordered) */ +/* ------------------------------------------------------------------ */ + +/** + * Generate a single-hue sequential scale (low → high) with a monotonic + * lightness progression and Gaussian-shaped chroma (peaking mid-ramp, matching + * the app's UI ramps). Direction flips by mode so low values sit near the + * background: light → dark in light mode, dark → light in dark mode. + */ +export function generateSequentialScale( + cfg: SequentialConfig, + mode: ColorMode, +): SwatchColor[] { + const h = resolveHue(cfg.hue) + const steps = Math.max(2, Math.floor(cfg.steps)) + const [lightEnd, darkEnd] = cfg.lightnessRange + const startL = mode === 'dark' ? 0.3 : lightEnd + const endL = mode === 'dark' ? 0.92 : darkEnd + const chromaMean = mode === 'dark' ? 0.7 : 0.6 + + return Array.from({ length: steps }, (_, i) => { + const t = steps === 1 ? 0 : i / (steps - 1) + const l = startL + (endL - startL) * t + const c = cfg.chromaPeak * gaussian(l, chromaMean, 2) + return { name: `${i + 1}`, hex: oklchToHex(l, c, h) } + }) +} + +/* ------------------------------------------------------------------ */ +/* Diverging (± around a midpoint) */ +/* ------------------------------------------------------------------ */ + +/** + * Generate a diverging scale: two saturated hue ends meeting at a light + * neutral midpoint. Uses a CVD-safe hue pair (blue ↔ orange by default). The + * ramp is symmetric about the neutral (equal lightness/chroma either side, + * mirrored hue). Neutral sits near the background per mode. + */ +export function generateDivergingScale( + cfg: DivergingConfig, + mode: ColorMode, +): SwatchColor[] { + const steps = Math.max(3, Math.floor(cfg.steps)) + const neutralL = mode === 'dark' ? 0.22 : cfg.neutralLightness + const endL = mode === 'dark' ? 0.68 : cfg.endLightness + + return Array.from({ length: steps }, (_, i) => { + const p = steps === 1 ? 0.5 : i / (steps - 1) + const d = Math.abs(p - 0.5) * 2 // 0 at centre, 1 at the ends + const hue = p <= 0.5 ? cfg.hueLow : cfg.hueHigh + const l = neutralL + (endL - neutralL) * d + const c = cfg.chroma * d + return { name: `${i + 1}`, hex: oklchToHex(l, c, hue) } + }) +} + +/* ------------------------------------------------------------------ */ +/* Accessibility scoring (re-exported for a single import surface) */ +/* ------------------------------------------------------------------ */ + +export { minPairContrast, minPairDeltaE, scoreCVDSafety, auditCategorical } diff --git a/apps/eds-color-palette-generator/src/utils/generatorImport.ts b/apps/eds-color-palette-generator/src/utils/generatorImport.ts new file mode 100644 index 0000000000..babf7723ee --- /dev/null +++ b/apps/eds-color-palette-generator/src/utils/generatorImport.ts @@ -0,0 +1,28 @@ +import { generateColorScale } from '@/utils/color' +import type { TokenPalette } from '@/utils/palette' +import { localStorageUtils } from '@/utils/localStorage' +import { paletteConfig } from '@/config/palette-config' +import { getLightnessValues } from '@/config/helpers' +import { PALETTE_STEPS } from '@/config/config' +import type { ColorDefinition } from '@/types' + +/** + * Build token palettes from the colours + Gaussian parameters the generator + * stored in localStorage (falling back to the defaults in `paletteConfig`). + * Client-only — reads localStorage, so call it from effects / event handlers, + * never during render or SSR. + */ +export function generatePalettesFromGenerator(): TokenPalette[] { + const colors = localStorageUtils.getColors(paletteConfig.colors) + const mean = localStorageUtils.getMeanLight(paletteConfig.meanLight) + const stdDev = localStorageUtils.getStdDevLight(paletteConfig.stdDevLight) + const lightnessValues = localStorageUtils.getLightModeValues( + getLightnessValues('light')(PALETTE_STEPS), + ) + + return colors.map((c: ColorDefinition) => { + const baseColor = 'anchors' in c ? c.anchors : c.value + const steps = generateColorScale(baseColor, lightnessValues, mean, stdDev, 'HEX') + return { name: c.name, steps } + }) +} diff --git a/apps/eds-color-palette-generator/src/utils/palette.ts b/apps/eds-color-palette-generator/src/utils/palette.ts new file mode 100644 index 0000000000..40f396a200 --- /dev/null +++ b/apps/eds-color-palette-generator/src/utils/palette.ts @@ -0,0 +1,176 @@ +import { contrast } from '@/utils/color' + +/* ------------------------------------------------------------------ */ +/* EDS token palettes — Primitive values from Figma */ +/* ------------------------------------------------------------------ */ + +/** + * Primitive step labels (1-indexed in Figma, 0-indexed here). + * + * Named after primary Figma semantic usage — a single step can serve + * multiple roles depending on context (palette, mode, component). + */ +export const STEP_ROLES = [ + '1 · bg/canvas', + '2 · bg/surface', + '3 · bg/surface-hover', + '4 · bg/surface-pressed', + '5 · bg/interactive', + '6 · bg/interactive-hover', + '7 · border/subtle', + '8 · border/default', + '9 · fill/emphasis', + '10 · fill/emphasis-hover', + '11 · fill/emphasis-pressed', + '12 · fg/strong', + '13 · fg/stronger', + '14 · fg/on-emphasis', + '15 · bg/card, sheet, popover', +] as const + +export type StepRole = (typeof STEP_ROLES)[number] + +export type TokenPalette = { + name: string + steps: string[] +} + +export const PALETTES: TokenPalette[] = [ + { + name: 'Moss Green', + steps: [ + '#ffffff', '#f8ffff', '#f4feff', '#f0fbfc', '#daecee', + '#cde3e5', '#bedade', '#bedade', '#84b9bf', '#30747c', + '#2d6e75', '#2b6167', '#2a5b61', '#28555a', '#254346', + ], + }, + { + name: 'Gray', + steps: [ + '#f5f5f5', '#ebebeb', '#e1e1e1', '#d4d4d4', '#c4c4c4', + '#b4b4b4', '#a4a4a4', '#868686', '#696969', '#5b5b5b', + '#4d4d4d', '#404040', '#333333', '#dedede', '#ffffff', + ], + }, +] + +/* ------------------------------------------------------------------ */ +/* APCA font lookup table */ +/* ------------------------------------------------------------------ */ + +export const APCA_FONT_ROWS: Array<{ + size: number + weights: Array<{ weight: number; name: string; minLc: number }> +}> = [ + { + size: 12, + weights: [ + { weight: 400, name: 'Regular', minLc: Infinity }, + { weight: 500, name: 'Medium', minLc: Infinity }, + { weight: 700, name: 'Bold', minLc: 100 }, + ], + }, + { + size: 14, + weights: [ + { weight: 400, name: 'Regular', minLc: 90 }, + { weight: 500, name: 'Medium', minLc: 75 }, + { weight: 700, name: 'Bold', minLc: 60 }, + ], + }, + { + size: 16, + weights: [ + { weight: 400, name: 'Regular', minLc: 75 }, + { weight: 500, name: 'Medium', minLc: 60 }, + { weight: 700, name: 'Bold', minLc: 45 }, + ], + }, + { + size: 18, + weights: [ + { weight: 400, name: 'Regular', minLc: 60 }, + { weight: 500, name: 'Medium', minLc: 55 }, + { weight: 700, name: 'Bold', minLc: 45 }, + ], + }, + { + size: 24, + weights: [ + { weight: 400, name: 'Regular', minLc: 45 }, + { weight: 500, name: 'Medium', minLc: 40 }, + { weight: 700, name: 'Bold', minLc: 30 }, + ], + }, +] + +export function getApcaFontBreakdown(lc: number) { + return APCA_FONT_ROWS.map(({ size, weights }) => { + const passing = weights.find((w) => lc >= w.minLc) + return { + size, + minWeight: passing?.weight ?? null, + minWeightName: passing?.name ?? null, + } + }) +} + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +export type ContrastResult = { + wcag: string + apca: string + aa: boolean + aaa: boolean +} + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +export function calcContrast(fg: string, bg: string): ContrastResult { + const wcag = String( + contrast({ foreground: fg, background: bg, algorithm: 'WCAG21', silent: true }), + ) + const apca = String( + contrast({ foreground: fg, background: bg, algorithm: 'APCA', silent: true }), + ) + const wn = parseFloat(wcag) + return { wcag, apca, aa: wn >= 4.5, aaa: wn >= 7 } +} + +export function roleIndex(role: StepRole): number { + return STEP_ROLES.indexOf(role) +} + +/* ------------------------------------------------------------------ */ +/* Simulation palettes — localStorage bridge for palette ↔ example */ +/* ------------------------------------------------------------------ */ + +const SIMULATION_PALETTES_KEY = 'colorPalette_simulationPalettes' + +export function getSimulationPalettes(): TokenPalette[] { + if (typeof window === 'undefined') return [] + try { + const item = localStorage.getItem(SIMULATION_PALETTES_KEY) + return item ? JSON.parse(item) : [] + } catch { + return [] + } +} + +export function setSimulationPalettes(palettes: TokenPalette[]): void { + if (typeof window === 'undefined') return + try { + localStorage.setItem(SIMULATION_PALETTES_KEY, JSON.stringify(palettes)) + } catch { + /* ignore */ + } +} + +export function clearSimulationPalettes(): void { + if (typeof window === 'undefined') return + localStorage.removeItem(SIMULATION_PALETTES_KEY) +} diff --git a/apps/eds-color-palette-generator/src/utils/urlState.ts b/apps/eds-color-palette-generator/src/utils/urlState.ts new file mode 100644 index 0000000000..97f9e95a9b --- /dev/null +++ b/apps/eds-color-palette-generator/src/utils/urlState.ts @@ -0,0 +1,142 @@ +import type { ColorAnchor } from '@/types' + +export type PaletteInput = { + name: string + baseColor: string + anchors?: ColorAnchor[] +} + +type ThemeBuilderState = { + palettes: PaletteInput[] + activeTab: 'fargesystem' | 'eksempler' | 'kontrast' + mode: 'light' | 'dark' + advancedMode: boolean +} + +/** + * URL format: + * + * Simple palette (single color): + * Name:hex + * e.g. Gray:4a4a4a + * + * Anchor palette (OKLCH anchors): + * Name:a@step1=oklch(...)@step2=oklch(...) + * e.g. Moss+Green:a@6=oklch(0.5915+0.0731+184.63)@9=oklch(0.4973+0.084851+204.553) + * + * The "a" after the first colon signals anchor mode. + * Spaces in OKLCH are encoded as "+" (URLSearchParams handles this). + */ + +function serializePalette(p: PaletteInput): string { + if (p.anchors && p.anchors.length > 0) { + const anchorParts = p.anchors + .map((a) => `${a.step}=${a.value.replace(/ /g, '+')}`) + .join('@') + return `${p.name}:a@${anchorParts}` + } + return `${p.name}:${p.baseColor.replace('#', '')}` +} + +function deserializePalette(entry: string): PaletteInput { + const firstColon = entry.indexOf(':') + if (firstColon === -1) { + return { name: entry, baseColor: '808080' } + } + + const name = decodeURIComponent(entry.slice(0, firstColon)) + const rest = entry.slice(firstColon + 1) + + // Check for anchor format: starts with "a@" + if (rest.startsWith('a@')) { + const anchorStr = rest.slice(2) // strip "a@" + const parts = anchorStr.split('@') + const anchors: ColorAnchor[] = parts + .map((part) => { + const eqIdx = part.indexOf('=') + if (eqIdx === -1) return null + const step = parseInt(part.slice(0, eqIdx), 10) + const value = part.slice(eqIdx + 1).replace(/\+/g, ' ') + if (isNaN(step)) return null + return { step, value } + }) + .filter((a): a is ColorAnchor => a !== null) + + if (anchors.length > 0) { + return { name, baseColor: '', anchors } + } + return { name, baseColor: '808080' } + } + + // Simple hex format + return { name, baseColor: rest } +} + +export function serializeState(state: Partial): string { + const params = new URLSearchParams() + + if (state.palettes && state.palettes.length > 0) { + const encoded = state.palettes.map(serializePalette).join(',') + params.set('p', encoded) + } + + if (state.activeTab && state.activeTab !== 'fargesystem') { + params.set('tab', state.activeTab) + } + + if (state.mode && state.mode !== 'light') { + params.set('mode', state.mode) + } + + if (state.advancedMode) { + params.set('adv', '1') + } + + return params.toString() +} + +export function deserializeState( + searchParams: URLSearchParams, +): Partial { + const result: Partial = {} + + const p = searchParams.get('p') + if (p) { + const palettes = p.split(',').map(deserializePalette) + if (palettes.length > 0) { + result.palettes = palettes + } + } + + const tab = searchParams.get('tab') + if (tab === 'fargesystem' || tab === 'eksempler' || tab === 'kontrast') { + result.activeTab = tab + } + + const mode = searchParams.get('mode') + if (mode === 'light' || mode === 'dark') { + result.mode = mode + } + + if (searchParams.has('adv')) { + result.advancedMode = true + } + + return result +} + +let debounceTimer: ReturnType | null = null + +export function updateURL(state: Partial): void { + if (typeof window === 'undefined') return + + if (debounceTimer) clearTimeout(debounceTimer) + + debounceTimer = setTimeout(() => { + const qs = serializeState(state) + const newURL = qs + ? `${window.location.pathname}?${qs}` + : window.location.pathname + window.history.replaceState(null, '', newURL) + }, 300) +} diff --git a/apps/eds-color-palette-generator/tests/e2e/anchor-color.spec.ts b/apps/eds-color-palette-generator/tests/e2e/anchor-color.spec.ts index 44869d7d82..80c51c3a5d 100644 --- a/apps/eds-color-palette-generator/tests/e2e/anchor-color.spec.ts +++ b/apps/eds-color-palette-generator/tests/e2e/anchor-color.spec.ts @@ -3,7 +3,7 @@ import 'dotenv/config' // Clear localStorage before each test to ensure consistent state test.beforeEach(async ({ page }) => { - await page.goto(process.env.PLAYWRIGHT_URL || 'http://localhost:3000/') + await page.goto(process.env.PLAYWRIGHT_URL || 'http://localhost:3000/old') await page.evaluate(() => localStorage.clear()) await page.reload() }) diff --git a/apps/eds-color-palette-generator/tests/e2e/anchor-error-messages.spec.ts b/apps/eds-color-palette-generator/tests/e2e/anchor-error-messages.spec.ts index 2c664cf7f2..3ac762aff4 100644 --- a/apps/eds-color-palette-generator/tests/e2e/anchor-error-messages.spec.ts +++ b/apps/eds-color-palette-generator/tests/e2e/anchor-error-messages.spec.ts @@ -3,7 +3,7 @@ import 'dotenv/config' // Clear localStorage before each test to ensure consistent state test.beforeEach(async ({ page }) => { - await page.goto(process.env.PLAYWRIGHT_URL || 'http://localhost:3000/') + await page.goto(process.env.PLAYWRIGHT_URL || 'http://localhost:3000/old') await page.evaluate(() => localStorage.clear()) await page.reload() }) diff --git a/apps/eds-color-palette-generator/tests/e2e/change-color.spec.ts b/apps/eds-color-palette-generator/tests/e2e/change-color.spec.ts index 25e3b0ec43..16eec99b36 100644 --- a/apps/eds-color-palette-generator/tests/e2e/change-color.spec.ts +++ b/apps/eds-color-palette-generator/tests/e2e/change-color.spec.ts @@ -3,7 +3,7 @@ import 'dotenv/config' // Clear localStorage before each test to ensure consistent state test.beforeEach(async ({ page }) => { - await page.goto(process.env.PLAYWRIGHT_URL || 'http://localhost:3000/') + await page.goto(process.env.PLAYWRIGHT_URL || 'http://localhost:3000/old') await page.evaluate(() => localStorage.clear()) await page.reload() }) diff --git a/apps/eds-color-palette-generator/tests/e2e/dataviz.spec.ts b/apps/eds-color-palette-generator/tests/e2e/dataviz.spec.ts new file mode 100644 index 0000000000..d9d63b7333 --- /dev/null +++ b/apps/eds-color-palette-generator/tests/e2e/dataviz.spec.ts @@ -0,0 +1,61 @@ +import { test, expect } from '@playwright/test' +import 'dotenv/config' + +const BASE = process.env.PLAYWRIGHT_URL?.replace(/\/old$/, '') || 'http://localhost:3000' + +test.beforeEach(async ({ page }) => { + await page.goto(`${BASE}/dataviz`) +}) + +test.describe('Data visualisation palettes', () => { + test('renders the categorical family by default with 8 swatches', async ({ + page, + }) => { + await expect( + page.getByRole('heading', { name: 'EDS Data Visualisation Palettes' }), + ).toBeVisible() + await expect(page.getByTestId('dataviz-swatch')).toHaveCount(8) + await expect( + page.getByRole('heading', { name: 'Accessibility audit' }), + ).toBeVisible() + }) + + test('switches families and updates the swatch count', async ({ page }) => { + await page.getByRole('button', { name: 'Sequential' }).click() + await expect(page.getByTestId('dataviz-swatch')).toHaveCount(7) + + await page.getByRole('button', { name: 'Diverging' }).click() + await expect(page.getByTestId('dataviz-swatch')).toHaveCount(9) + }) + + test('changes the categorical count via the slider', async ({ page }) => { + const slider = page.locator('input[type="range"]').first() + await slider.fill('12') + await expect(page.getByTestId('dataviz-swatch')).toHaveCount(12) + }) + + test('applies a CVD simulation and shows the patterns toggle', async ({ + page, + }) => { + await page.getByLabel('Vision').selectOption({ label: 'Deuteranopia (no green)' }) + const patterns = page.getByRole('checkbox', { name: 'Show patterns' }) + await patterns.check() + await expect(patterns).toBeChecked() + // the pairwise distinguishability matrix is shown for categorical + await expect( + page.getByRole('heading', { name: 'Color-to-color contrast' }), + ).toBeVisible() + }) + + test('offers export actions', async ({ page }) => { + await expect( + page.getByRole('button', { name: 'Copy hex array' }), + ).toBeVisible() + await expect( + page.getByRole('button', { name: 'Copy CSS variables' }), + ).toBeVisible() + await expect( + page.getByRole('button', { name: 'Download tokens' }), + ).toBeVisible() + }) +}) diff --git a/apps/eds-color-palette-generator/tests/e2e/general-features.spec.ts b/apps/eds-color-palette-generator/tests/e2e/general-features.spec.ts index 079342a352..af37522ef7 100644 --- a/apps/eds-color-palette-generator/tests/e2e/general-features.spec.ts +++ b/apps/eds-color-palette-generator/tests/e2e/general-features.spec.ts @@ -5,7 +5,7 @@ import 'dotenv/config' // Clear localStorage before each test to ensure consistent state test.beforeEach(async ({ page }) => { - await page.goto(process.env.PLAYWRIGHT_URL || 'http://localhost:3000/') + await page.goto(process.env.PLAYWRIGHT_URL || 'http://localhost:3000/old') await page.evaluate(() => localStorage.clear()) await page.reload() }) diff --git a/apps/eds-color-palette-generator/tests/e2e/step-dropdown-accessibility.spec.ts b/apps/eds-color-palette-generator/tests/e2e/step-dropdown-accessibility.spec.ts index c6d457e3e6..d5572eafb0 100644 --- a/apps/eds-color-palette-generator/tests/e2e/step-dropdown-accessibility.spec.ts +++ b/apps/eds-color-palette-generator/tests/e2e/step-dropdown-accessibility.spec.ts @@ -3,7 +3,7 @@ import 'dotenv/config' // Clear localStorage before each test to ensure consistent state test.beforeEach(async ({ page }) => { - await page.goto(process.env.PLAYWRIGHT_URL || 'http://localhost:3000/') + await page.goto(process.env.PLAYWRIGHT_URL || 'http://localhost:3000/old') await page.evaluate(() => localStorage.clear()) await page.reload() }) diff --git a/apps/eds-color-palette-generator/vite.config.ts b/apps/eds-color-palette-generator/vite.config.ts index ef164a845e..a0c48c2ad0 100644 --- a/apps/eds-color-palette-generator/vite.config.ts +++ b/apps/eds-color-palette-generator/vite.config.ts @@ -12,12 +12,10 @@ export default defineConfig({ }, rolldownOptions: { external: [ - /^node:/, - 'fs', - 'path', - 'os', - 'crypto', - 'process', + 'node:fs', + 'node:fs/promises', + 'node:path', + 'node:process', 'colorjs.io', 'colorjs.io/fn', ], @@ -32,5 +30,5 @@ export default defineConfig({ }, outDir: 'dist', }, - plugins: [dts({ bundleTypes: true })], + plugins: [dts({ rollupTypes: true })], }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e8b9075e07..8715d99070 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -188,32 +188,29 @@ importers: specifier: ^0.6.1 version: 0.6.1 lucide-react: - specifier: ^1.21.0 + specifier: ^1.12.0 version: 1.21.0(react@19.2.7) next: - specifier: 16.2.9 - version: 16.2.9(@babel/core@7.29.7)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + specifier: 16.2.4 + version: 16.2.4(@babel/core@7.29.7)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: - specifier: ^19.2.6 + specifier: ^19.2.4 version: 19.2.7 react-dom: - specifier: ^19.2.6 + specifier: ^19.2.4 version: 19.2.7(react@19.2.7) devDependencies: '@eslint/eslintrc': specifier: ^3.3.5 version: 3.3.5 - '@microsoft/api-extractor': - specifier: ^7.58.9 - version: 7.58.9(@types/node@25.6.0) '@next/eslint-plugin-next': - specifier: 16.2.9 - version: 16.2.9 + specifier: 16.2.4 + version: 16.2.4 '@playwright/test': - specifier: ^1.61.1 + specifier: ^1.59.1 version: 1.61.1 '@tailwindcss/postcss': - specifier: ^4.3.1 + specifier: ^4.2.4 version: 4.3.1 '@types/node': specifier: ^25.5.2 @@ -231,25 +228,25 @@ importers: specifier: ^9.38.0 version: 9.39.4(jiti@2.7.0) eslint-config-next: - specifier: 16.2.9 - version: 16.2.9(@typescript-eslint/parser@8.62.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + specifier: 16.2.4 + version: 16.2.4(@typescript-eslint/parser@8.62.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) tailwindcss: - specifier: ^4.3.1 + specifier: ^4.2.4 version: 4.3.1 tsx: - specifier: ^4.22.4 + specifier: ^4.21.0 version: 4.22.4 typescript: specifier: ^5.9.3 version: 5.9.3 vite: - specifier: ^8.1.0 + specifier: ^8.0.10 version: 8.1.0(@types/node@25.6.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) vite-plugin-dts: - specifier: ^5.0.3 - version: 5.0.3(@microsoft/api-extractor@7.58.9(@types/node@25.6.0))(esbuild@0.28.1)(rolldown@1.1.3)(rollup@4.62.2)(typescript@5.9.3)(vite@8.1.0(@types/node@25.6.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(webpack@5.107.2(esbuild@0.28.1)(postcss@8.5.16)) + specifier: ^4.5.4 + version: 4.5.4(@types/node@25.6.0)(rollup@4.62.2)(typescript@5.9.3)(vite@8.1.0(@types/node@25.6.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) vitest: - specifier: ^4.1.9 + specifier: ^4.1.5 version: 4.1.9(@types/node@25.6.0)(jsdom@26.1.0)(vite@8.1.0(@types/node@25.6.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) apps/eds-demo: @@ -698,13 +695,13 @@ importers: version: 10.4.6(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)) '@storybook/addon-docs': specifier: ^10.4.6 - version: 10.4.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.1)(rollup@4.62.2)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vite@8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(webpack@5.107.2(esbuild@0.28.1)(postcss@8.5.16)) + version: 10.4.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.1)(rollup@4.62.2)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vite@8.1.0(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(webpack@5.107.2(esbuild@0.28.1)(postcss@8.5.16)) '@storybook/addon-links': specifier: ^10.4.6 version: 10.4.6(@types/react@19.2.17)(react@19.2.7)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)) '@storybook/react-vite': specifier: ^10.4.6 - version: 10.4.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(rollup@4.62.2)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@5.9.3)(vite@8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(webpack@5.107.2(esbuild@0.28.1)(postcss@8.5.16)) + version: 10.4.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(rollup@4.62.2)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@5.9.3)(vite@8.1.0(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(webpack@5.107.2(esbuild@0.28.1)(postcss@8.5.16)) '@testing-library/dom': specifier: ^10.4.1 version: 10.4.1 @@ -740,7 +737,7 @@ importers: version: 10.4.6(eslint@9.39.4(jiti@2.7.0))(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@5.9.3) jest: specifier: ^30.4.2 - version: 30.4.2(@types/node@25.9.3)(ts-node@10.9.2(@types/node@25.9.3)(typescript@5.9.3)) + version: 30.4.2(@types/node@25.9.4)(ts-node@10.9.2(@types/node@25.9.4)(typescript@5.9.3)) jest-environment-jsdom: specifier: ^30.4.1 version: 30.4.1 @@ -776,7 +773,7 @@ importers: version: 3.0.2(rollup@4.62.2) rollup-plugin-postcss: specifier: ^4.0.2 - version: 4.0.2(postcss@8.5.16)(ts-node@10.9.2(@types/node@25.9.3)(typescript@5.9.3)) + version: 4.0.2(postcss@8.5.16)(ts-node@10.9.2(@types/node@25.9.4)(typescript@5.9.3)) storybook: specifier: 10.4.6 version: 10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -3831,9 +3828,15 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@next/env@16.2.4': + resolution: {integrity: sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw==} + '@next/env@16.2.9': resolution: {integrity: sha512-ki5VxxXfzD/9TDe13wyeTKIjQTAwBVpnr8KhRDUr8ltMUq1/NBpWNT5tiPoxiGl+PHM4X2ahSOiPk6iAimIzPg==} + '@next/eslint-plugin-next@16.2.4': + resolution: {integrity: sha512-tOX826JJ96gYK/go18sPUgMq9FK1tqxBFfUCEufJb5XIkWFFmpgU7mahJANKGkHs7F41ir3tReJ3Lv5La0RvhA==} + '@next/eslint-plugin-next@16.2.9': resolution: {integrity: sha512-UZi8+YT/MLgTC9nrrn2Xd4lBYv1B7lVmtWHfPcthAI5Tt/C1LuDe6DfmtCtJ+WQod3ksY4VrKSvk3oMVAnL7qw==} @@ -3848,48 +3851,96 @@ packages: '@mdx-js/react': optional: true + '@next/swc-darwin-arm64@16.2.4': + resolution: {integrity: sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + '@next/swc-darwin-arm64@16.2.9': resolution: {integrity: sha512-HkfxNYUCmcct0Xsqib5KxqMSHV4AHJq857BNRchyBDs4YS19aHzVfn1kDuBYKqLLQBjXgnkIsjV2Kd4d2wzYhw==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] + '@next/swc-darwin-x64@16.2.4': + resolution: {integrity: sha512-XhpVnUfmYWvD3YrXu55XdcAkQtOnvaI6wtQa8fuF5fGoKoxIUZ0kWPtcOfqJEWngFF/lOS9l3+O9CcownhiQxQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + '@next/swc-darwin-x64@16.2.9': resolution: {integrity: sha512-7IAtK4MeybpqRV9GRABWEhJ62mOS+rzWOzOTFie4cSEtm12xsoOMJRcECoZx3FHPzFAqN/IJtHqWAFOLfl152w==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] + '@next/swc-linux-arm64-gnu@16.2.4': + resolution: {integrity: sha512-Mx/tjlNA3G8kg14QvuGAJ4xBwPk1tUHq56JxZ8CXnZwz1Etz714soCEzGQQzVMz4bEnGPowzkV6Xrp6wAkEWOQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + '@next/swc-linux-arm64-gnu@16.2.9': resolution: {integrity: sha512-hBD75iWpUtkL9SmQmcRhmLomn9jgkPzCEkbOcLgHymPEKzv+6ONy13RRiIEz/iEObjkS2Jlb5gYS2XGoS3X4rw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + '@next/swc-linux-arm64-musl@16.2.4': + resolution: {integrity: sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + '@next/swc-linux-arm64-musl@16.2.9': resolution: {integrity: sha512-qZTI3pf9SGc/obr8NkQAekBxmp1QK+kVm+VAf3BALLfFAj+1kUhkTxmrWpVos9R/UYIA8AWX2p6cGI5WdwzVUA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + '@next/swc-linux-x64-gnu@16.2.4': + resolution: {integrity: sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + '@next/swc-linux-x64-gnu@16.2.9': resolution: {integrity: sha512-xm0HfRNX+UkH4R3c18ynswjj5o5uEj/7iI9p9omdtTSIsRCzQqkGMA+10nzJ4EHnYC3as65IMhbbl5fWRUWHYg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + '@next/swc-linux-x64-musl@16.2.4': + resolution: {integrity: sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + '@next/swc-linux-x64-musl@16.2.9': resolution: {integrity: sha512-QumimHkGEG6vM3PfEDWKyKen03NcqLOkeKB1EfcPe7VxzmEiCa4jNnMyBn/US5zcd/VE1CI+O8Ovb3lfjVHfGw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + '@next/swc-win32-arm64-msvc@16.2.4': + resolution: {integrity: sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + '@next/swc-win32-arm64-msvc@16.2.9': resolution: {integrity: sha512-hzQpKZvw8rAwI6A2uQh6SacCSvNAXaIkPNsWwzqqfRiIMiXMfH936skDhz1OO6KpvdKkJrgHHtqQOq5PIXOvdQ==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] + '@next/swc-win32-x64-msvc@16.2.4': + resolution: {integrity: sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + '@next/swc-win32-x64-msvc@16.2.9': resolution: {integrity: sha512-qr2VL3Ce5QrwgO2yh1ujSBawrimjVKX8FGF/cOynmdYKJY0BdHpGVNIRK1tqONB10Vkm25Ub1BD2bkjWs4+96w==} engines: {node: '>= 10'} @@ -5736,6 +5787,26 @@ packages: '@volar/typescript@2.4.28': resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==} + '@vue/compiler-core@3.5.39': + resolution: {integrity: sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==} + + '@vue/compiler-dom@3.5.39': + resolution: {integrity: sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg==} + + '@vue/compiler-vue2@2.7.16': + resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==} + + '@vue/language-core@2.2.0': + resolution: {integrity: sha512-O1ZZFaaBGkKbsRfnVH1ifOK1/1BUkyK+3SQsfnh6PmMmD4qJcTU8godCeA96jjDRTL6zgnK7YzCHfaUlH2r0Mw==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@vue/shared@3.5.39': + resolution: {integrity: sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==} + '@webassemblyjs/ast@1.14.1': resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} @@ -5910,6 +5981,9 @@ packages: resolution: {integrity: sha512-fHA8+kXTbjagw3jkLiaS7KKrH8qe2DyOsiUhGlN4cdT77PEsfqXZl7ewDk1hsg+pJnPlnE50XtLxjR91iJOpmg==} engines: {node: '>= 14.0.0'} + alien-signals@0.4.14: + resolution: {integrity: sha512-itUAVzhczTmP2U5yX67xVpsbbOiquusbWVyA9N+sy6+r6YVbFkahXvNCeEPWEOMhwDYwbVbGHFkVL03N9I5g+Q==} + anser@1.4.10: resolution: {integrity: sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==} @@ -6956,6 +7030,9 @@ packages: date-fns@4.4.0: resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} + de-indent@1.0.2: + resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} + debounce@1.2.1: resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==} @@ -7336,6 +7413,15 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} + eslint-config-next@16.2.4: + resolution: {integrity: sha512-A6ekXYFj/YQxBPMl45g3e+U8zJo+X2+ZQwcz34pPKjpc/3S4roBA2Rd9xWB4FKuSxhofo1/95WjzmUY+wHrOhg==} + peerDependencies: + eslint: '>=9.0.0' + typescript: '>=3.3.1' + peerDependenciesMeta: + typescript: + optional: true + eslint-config-next@16.2.9: resolution: {integrity: sha512-olGtBrs07bQchpaJWeqbk9GaMoU0oGmN/pYNEBXSbfgKngb5uHnPe37X6tVeh6DJfaWFQildvinGEOrolo5fmw==} peerDependencies: @@ -9806,6 +9892,9 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + multicast-dns@7.2.5: resolution: {integrity: sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==} hasBin: true @@ -9813,11 +9902,6 @@ packages: multitars@1.0.0: resolution: {integrity: sha512-H/J4fMLedtudftaYMOg7ajzLYgT3/rwbWVJbqr/iUgB8DQztn38ys5HOqI1CzSxx8QhXXwOOnnBvd4v3jG5+Mg==} - nanoid@3.3.12: - resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - nanoid@3.3.15: resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -9846,6 +9930,27 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + next@16.2.4: + resolution: {integrity: sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q==} + engines: {node: '>=20.9.0'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + next@16.2.9: resolution: {integrity: sha512-MEOJiq/UvuezAdqVSceHbqDgZt1kDw2tpGVOlsdIoJsQdbN2JY2hpVG4xnXGkbdJUOEWhnRfiu/O4Hpc9Juwww==} engines: {node: '>=20.9.0'} @@ -12686,6 +12791,15 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite-plugin-dts@4.5.4: + resolution: {integrity: sha512-d4sOM8M/8z7vRXHHq/ebbblfaxENjogAAekcfcDCCwAyvGqnPrc7f4NZbvItS+g4WTgerW0xDwSz5qz11JT3vg==} + peerDependencies: + typescript: '*' + vite: '*' + peerDependenciesMeta: + vite: + optional: true + vite-plugin-dts@5.0.3: resolution: {integrity: sha512-gIth6NdCEHWPiiRMCK3N6C8WjvdsrtEQrmsiG8h6Ov+lFP+b07Y+wcs9H0H7n146l0PDTYK4cQN1vgeG1pMdRQ==} peerDependencies: @@ -16934,42 +17048,6 @@ snapshots: - supports-color - ts-node - '@jest/core@30.4.2(ts-node@10.9.2(@types/node@25.9.3)(typescript@5.9.3))': - dependencies: - '@jest/console': 30.4.1 - '@jest/pattern': 30.4.0 - '@jest/reporters': 30.4.1 - '@jest/test-result': 30.4.1 - '@jest/transform': 30.4.1 - '@jest/types': 30.4.1 - '@types/node': 25.9.3 - ansi-escapes: 4.3.2 - chalk: 4.1.2 - ci-info: 4.4.0 - exit-x: 0.2.2 - fast-json-stable-stringify: 2.1.0 - graceful-fs: 4.2.11 - jest-changed-files: 30.4.1 - jest-config: 30.4.2(@types/node@25.9.3)(ts-node@10.9.2(@types/node@25.9.3)(typescript@5.9.3)) - jest-haste-map: 30.4.1 - jest-message-util: 30.4.1 - jest-regex-util: 30.4.0 - jest-resolve: 30.4.1 - jest-resolve-dependencies: 30.4.2 - jest-runner: 30.4.2 - jest-runtime: 30.4.2 - jest-snapshot: 30.4.1 - jest-util: 30.4.1 - jest-validate: 30.4.1 - jest-watcher: 30.4.1 - pretty-format: 30.4.1 - slash: 3.0.0 - transitivePeerDependencies: - - babel-plugin-macros - - esbuild-register - - supports-color - - ts-node - '@jest/core@30.4.2(ts-node@10.9.2(@types/node@25.9.4)(typescript@5.9.3))': dependencies: '@jest/console': 30.4.1 @@ -17236,11 +17314,11 @@ snapshots: optionalDependencies: typescript: 5.9.3 - '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@5.9.3)(vite@8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))': + '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@5.9.3)(vite@8.1.0(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: glob: 13.0.6 react-docgen-typescript: 2.4.0(typescript@5.9.3) - vite: 8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 8.1.0(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) optionalDependencies: typescript: 5.9.3 @@ -17581,8 +17659,14 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true + '@next/env@16.2.4': {} + '@next/env@16.2.9': {} + '@next/eslint-plugin-next@16.2.4': + dependencies: + fast-glob: 3.3.1 + '@next/eslint-plugin-next@16.2.9': dependencies: fast-glob: 3.3.1 @@ -17594,27 +17678,51 @@ snapshots: '@mdx-js/loader': 3.1.1(webpack@5.107.2(postcss@8.5.16)) '@mdx-js/react': 3.1.1(@types/react@19.2.17)(react@19.2.7) + '@next/swc-darwin-arm64@16.2.4': + optional: true + '@next/swc-darwin-arm64@16.2.9': optional: true + '@next/swc-darwin-x64@16.2.4': + optional: true + '@next/swc-darwin-x64@16.2.9': optional: true + '@next/swc-linux-arm64-gnu@16.2.4': + optional: true + '@next/swc-linux-arm64-gnu@16.2.9': optional: true + '@next/swc-linux-arm64-musl@16.2.4': + optional: true + '@next/swc-linux-arm64-musl@16.2.9': optional: true + '@next/swc-linux-x64-gnu@16.2.4': + optional: true + '@next/swc-linux-x64-gnu@16.2.9': optional: true + '@next/swc-linux-x64-musl@16.2.4': + optional: true + '@next/swc-linux-x64-musl@16.2.9': optional: true + '@next/swc-win32-arm64-msvc@16.2.4': + optional: true + '@next/swc-win32-arm64-msvc@16.2.9': optional: true + '@next/swc-win32-x64-msvc@16.2.4': + optional: true + '@next/swc-win32-x64-msvc@16.2.9': optional: true @@ -18440,10 +18548,10 @@ snapshots: - vite - webpack - '@storybook/addon-docs@10.4.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.1)(rollup@4.62.2)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vite@8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(webpack@5.107.2(esbuild@0.28.1)(postcss@8.5.16))': + '@storybook/addon-docs@10.4.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.1)(rollup@4.62.2)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vite@8.1.0(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(webpack@5.107.2(esbuild@0.28.1)(postcss@8.5.16))': dependencies: '@mdx-js/react': 3.1.1(@types/react@19.2.17)(react@19.2.7) - '@storybook/csf-plugin': 10.4.6(esbuild@0.28.1)(rollup@4.62.2)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vite@8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(webpack@5.107.2(esbuild@0.28.1)(postcss@8.5.16)) + '@storybook/csf-plugin': 10.4.6(esbuild@0.28.1)(rollup@4.62.2)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vite@8.1.0(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(webpack@5.107.2(esbuild@0.28.1)(postcss@8.5.16)) '@storybook/icons': 2.0.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@storybook/react-dom-shim': 10.4.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)) react: 19.2.7 @@ -18489,12 +18597,12 @@ snapshots: - rollup - webpack - '@storybook/builder-vite@10.4.6(esbuild@0.28.1)(rollup@4.62.2)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vite@8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(webpack@5.107.2(esbuild@0.28.1)(postcss@8.5.16))': + '@storybook/builder-vite@10.4.6(esbuild@0.28.1)(rollup@4.62.2)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vite@8.1.0(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(webpack@5.107.2(esbuild@0.28.1)(postcss@8.5.16))': dependencies: - '@storybook/csf-plugin': 10.4.6(esbuild@0.28.1)(rollup@4.62.2)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vite@8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(webpack@5.107.2(esbuild@0.28.1)(postcss@8.5.16)) + '@storybook/csf-plugin': 10.4.6(esbuild@0.28.1)(rollup@4.62.2)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vite@8.1.0(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(webpack@5.107.2(esbuild@0.28.1)(postcss@8.5.16)) storybook: 10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) ts-dedent: 2.3.0 - vite: 8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 8.1.0(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) transitivePeerDependencies: - esbuild - rollup @@ -18520,14 +18628,14 @@ snapshots: vite: 8.1.0(@types/node@25.6.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) webpack: 5.107.2(esbuild@0.28.1)(postcss@8.5.16) - '@storybook/csf-plugin@10.4.6(esbuild@0.28.1)(rollup@4.62.2)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vite@8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(webpack@5.107.2(esbuild@0.28.1)(postcss@8.5.16))': + '@storybook/csf-plugin@10.4.6(esbuild@0.28.1)(rollup@4.62.2)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vite@8.1.0(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(webpack@5.107.2(esbuild@0.28.1)(postcss@8.5.16))': dependencies: storybook: 10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) unplugin: 2.3.11 optionalDependencies: esbuild: 0.28.1 rollup: 4.62.2 - vite: 8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 8.1.0(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) webpack: 5.107.2(esbuild@0.28.1)(postcss@8.5.16) '@storybook/global@5.0.0': {} @@ -18594,11 +18702,11 @@ snapshots: - typescript - webpack - '@storybook/react-vite@10.4.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(rollup@4.62.2)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@5.9.3)(vite@8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(webpack@5.107.2(esbuild@0.28.1)(postcss@8.5.16))': + '@storybook/react-vite@10.4.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(rollup@4.62.2)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@5.9.3)(vite@8.1.0(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(webpack@5.107.2(esbuild@0.28.1)(postcss@8.5.16))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@5.9.3)(vite@8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@5.9.3)(vite@8.1.0(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) '@rollup/pluginutils': 5.4.0(rollup@4.62.2) - '@storybook/builder-vite': 10.4.6(esbuild@0.28.1)(rollup@4.62.2)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vite@8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(webpack@5.107.2(esbuild@0.28.1)(postcss@8.5.16)) + '@storybook/builder-vite': 10.4.6(esbuild@0.28.1)(rollup@4.62.2)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vite@8.1.0(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(webpack@5.107.2(esbuild@0.28.1)(postcss@8.5.16)) '@storybook/react': 10.4.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@5.9.3) empathic: 2.0.1 magic-string: 0.30.21 @@ -18608,7 +18716,7 @@ snapshots: resolve: 1.22.12 storybook: 10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) tsconfig-paths: 4.2.0 - vite: 8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 8.1.0(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) transitivePeerDependencies: - '@types/react' - '@types/react-dom' @@ -19576,6 +19684,39 @@ snapshots: path-browserify: 1.0.1 vscode-uri: 3.1.0 + '@vue/compiler-core@3.5.39': + dependencies: + '@babel/parser': 7.29.7 + '@vue/shared': 3.5.39 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.39': + dependencies: + '@vue/compiler-core': 3.5.39 + '@vue/shared': 3.5.39 + + '@vue/compiler-vue2@2.7.16': + dependencies: + de-indent: 1.0.2 + he: 1.2.0 + + '@vue/language-core@2.2.0(typescript@5.9.3)': + dependencies: + '@volar/language-core': 2.4.28 + '@vue/compiler-dom': 3.5.39 + '@vue/compiler-vue2': 2.7.16 + '@vue/shared': 3.5.39 + alien-signals: 0.4.14 + minimatch: 9.0.9 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + optionalDependencies: + typescript: 5.9.3 + + '@vue/shared@3.5.39': {} + '@webassemblyjs/ast@1.14.1': dependencies: '@webassemblyjs/helper-numbers': 1.13.2 @@ -19784,6 +19925,8 @@ snapshots: '@algolia/requester-fetch': 5.52.1 '@algolia/requester-node-http': 5.52.1 + alien-signals@0.4.14: {} + anser@1.4.10: {} ansi-align@3.0.1: @@ -19819,7 +19962,7 @@ snapshots: anymatch@3.1.3: dependencies: normalize-path: 3.0.0 - picomatch: 4.0.4 + picomatch: 4.0.5 arg@4.1.3: {} @@ -21013,6 +21156,8 @@ snapshots: date-fns@4.4.0: {} + de-indent@1.0.2: {} + debounce@1.2.1: {} debug@2.6.9: @@ -21438,6 +21583,26 @@ snapshots: escape-string-regexp@5.0.0: {} + eslint-config-next@16.2.4(@typescript-eslint/parser@8.62.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3): + dependencies: + '@next/eslint-plugin-next': 16.2.4 + eslint: 9.39.4(jiti@2.7.0) + eslint-import-resolver-node: 0.3.10 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.62.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.5(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)) + eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.7.0)) + eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.7.0)) + eslint-plugin-react-hooks: 7.1.1(eslint@9.39.4(jiti@2.7.0)) + globals: 16.4.0 + typescript-eslint: 8.62.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@typescript-eslint/parser' + - eslint-import-resolver-webpack + - eslint-plugin-import-x + - supports-color + eslint-config-next@16.2.9(@typescript-eslint/parser@8.62.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3): dependencies: '@next/eslint-plugin-next': 16.2.9 @@ -22003,6 +22168,10 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + feed@4.2.2: dependencies: xml-js: 1.6.11 @@ -23137,25 +23306,6 @@ snapshots: - supports-color - ts-node - jest-cli@30.4.2(@types/node@25.9.3)(ts-node@10.9.2(@types/node@25.9.3)(typescript@5.9.3)): - dependencies: - '@jest/core': 30.4.2(ts-node@10.9.2(@types/node@25.9.3)(typescript@5.9.3)) - '@jest/test-result': 30.4.1 - '@jest/types': 30.4.1 - chalk: 4.1.2 - exit-x: 0.2.2 - import-local: 3.2.0 - jest-config: 30.4.2(@types/node@25.9.3)(ts-node@10.9.2(@types/node@25.9.3)(typescript@5.9.3)) - jest-util: 30.4.1 - jest-validate: 30.4.1 - yargs: 17.7.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - esbuild-register - - supports-color - - ts-node - jest-cli@30.4.2(@types/node@25.9.4)(ts-node@10.9.2(@types/node@25.9.4)(typescript@5.9.3)): dependencies: '@jest/core': 30.4.2(ts-node@10.9.2(@types/node@25.9.4)(typescript@5.9.3)) @@ -23239,38 +23389,6 @@ snapshots: - babel-plugin-macros - supports-color - jest-config@30.4.2(@types/node@25.9.3)(ts-node@10.9.2(@types/node@25.9.3)(typescript@5.9.3)): - dependencies: - '@babel/core': 7.29.7 - '@jest/get-type': 30.1.0 - '@jest/pattern': 30.4.0 - '@jest/test-sequencer': 30.4.1 - '@jest/types': 30.4.1 - babel-jest: 30.4.1(@babel/core@7.29.7) - chalk: 4.1.2 - ci-info: 4.4.0 - deepmerge: 4.3.1 - glob: 10.5.0 - graceful-fs: 4.2.11 - jest-circus: 30.4.2 - jest-docblock: 30.4.0 - jest-environment-node: 30.4.1 - jest-regex-util: 30.4.0 - jest-resolve: 30.4.1 - jest-runner: 30.4.2 - jest-util: 30.4.1 - jest-validate: 30.4.1 - parse-json: 5.2.0 - pretty-format: 30.4.1 - slash: 3.0.0 - strip-json-comments: 3.1.1 - optionalDependencies: - '@types/node': 25.9.3 - ts-node: 10.9.2(@types/node@25.9.3)(typescript@5.9.3) - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - jest-config@30.4.2(@types/node@25.9.3)(ts-node@10.9.2(@types/node@25.9.4)(typescript@5.9.3)): dependencies: '@babel/core': 7.29.7 @@ -23714,19 +23832,6 @@ snapshots: - supports-color - ts-node - jest@30.4.2(@types/node@25.9.3)(ts-node@10.9.2(@types/node@25.9.3)(typescript@5.9.3)): - dependencies: - '@jest/core': 30.4.2(ts-node@10.9.2(@types/node@25.9.3)(typescript@5.9.3)) - '@jest/types': 30.4.1 - import-local: 3.2.0 - jest-cli: 30.4.2(@types/node@25.9.3)(ts-node@10.9.2(@types/node@25.9.3)(typescript@5.9.3)) - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - esbuild-register - - supports-color - - ts-node - jest@30.4.2(@types/node@25.9.4)(ts-node@10.9.2(@types/node@25.9.4)(typescript@5.9.3)): dependencies: '@jest/core': 30.4.2(ts-node@10.9.2(@types/node@25.9.4)(typescript@5.9.3)) @@ -25030,7 +25135,7 @@ snapshots: micromatch@4.0.8: dependencies: braces: 3.0.3 - picomatch: 4.0.4 + picomatch: 4.0.5 mime-db@1.33.0: {} @@ -25105,6 +25210,8 @@ snapshots: ms@2.1.3: {} + muggle-string@0.4.1: {} + multicast-dns@7.2.5: dependencies: dns-packet: 5.6.1 @@ -25112,8 +25219,6 @@ snapshots: multitars@1.0.0: {} - nanoid@3.3.12: {} - nanoid@3.3.15: {} napi-postinstall@0.3.4: {} @@ -25128,6 +25233,32 @@ snapshots: neo-async@2.6.2: {} + next@16.2.4(@babel/core@7.29.7)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@next/env': 16.2.4 + '@swc/helpers': 0.5.15 + baseline-browser-mapping: 2.10.40 + caniuse-lite: 1.0.30001799 + postcss: 8.4.31 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.7) + optionalDependencies: + '@next/swc-darwin-arm64': 16.2.4 + '@next/swc-darwin-x64': 16.2.4 + '@next/swc-linux-arm64-gnu': 16.2.4 + '@next/swc-linux-arm64-musl': 16.2.4 + '@next/swc-linux-x64-gnu': 16.2.4 + '@next/swc-linux-x64-musl': 16.2.4 + '@next/swc-win32-arm64-msvc': 16.2.4 + '@next/swc-win32-x64-msvc': 16.2.4 + '@playwright/test': 1.61.1 + babel-plugin-react-compiler: 1.0.0 + sharp: 0.34.5 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + next@16.2.9(@babel/core@7.29.7)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: '@next/env': 16.2.9 @@ -25825,13 +25956,13 @@ snapshots: postcss: 8.5.16 ts-node: 10.9.2(@types/node@25.6.0)(typescript@5.9.3) - postcss-load-config@3.1.4(postcss@8.5.16)(ts-node@10.9.2(@types/node@25.9.3)(typescript@5.9.3)): + postcss-load-config@3.1.4(postcss@8.5.16)(ts-node@10.9.2(@types/node@25.9.4)(typescript@5.9.3)): dependencies: lilconfig: 2.1.0 yaml: 1.10.3 optionalDependencies: postcss: 8.5.16 - ts-node: 10.9.2(@types/node@25.9.3)(typescript@5.9.3) + ts-node: 10.9.2(@types/node@25.9.4)(typescript@5.9.3) postcss-loader@7.3.4(postcss@8.5.16)(typescript@5.9.3)(webpack@5.106.2(postcss@8.5.16)): dependencies: @@ -26272,7 +26403,7 @@ snapshots: postcss@8.5.15: dependencies: - nanoid: 3.3.12 + nanoid: 3.3.15 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -26705,7 +26836,7 @@ snapshots: readdirp@3.6.0: dependencies: - picomatch: 4.0.4 + picomatch: 4.0.5 recast@0.23.11: dependencies: @@ -27016,7 +27147,7 @@ snapshots: transitivePeerDependencies: - ts-node - rollup-plugin-postcss@4.0.2(postcss@8.5.16)(ts-node@10.9.2(@types/node@25.9.3)(typescript@5.9.3)): + rollup-plugin-postcss@4.0.2(postcss@8.5.16)(ts-node@10.9.2(@types/node@25.9.4)(typescript@5.9.3)): dependencies: chalk: 4.1.2 concat-with-sourcemaps: 1.1.0 @@ -27025,7 +27156,7 @@ snapshots: p-queue: 6.6.2 pify: 5.0.0 postcss: 8.5.16 - postcss-load-config: 3.1.4(postcss@8.5.16)(ts-node@10.9.2(@types/node@25.9.3)(typescript@5.9.3)) + postcss-load-config: 3.1.4(postcss@8.5.16)(ts-node@10.9.2(@types/node@25.9.4)(typescript@5.9.3)) postcss-modules: 4.3.1(postcss@8.5.16) promise.series: 0.2.0 resolve: 1.22.12 @@ -27966,8 +28097,8 @@ snapshots: tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 tinypool@1.1.1: {} @@ -28068,25 +28199,6 @@ snapshots: v8-compile-cache-lib: 3.0.1 yn: 3.1.1 - ts-node@10.9.2(@types/node@25.9.3)(typescript@5.9.3): - dependencies: - '@cspotcode/source-map-support': 0.8.1 - '@tsconfig/node10': 1.0.12 - '@tsconfig/node12': 1.0.11 - '@tsconfig/node14': 1.0.3 - '@tsconfig/node16': 1.0.4 - '@types/node': 25.9.3 - acorn: 8.16.0 - acorn-walk: 8.3.5 - arg: 4.1.3 - create-require: 1.1.1 - diff: 9.0.0 - make-error: 1.3.6 - typescript: 5.9.3 - v8-compile-cache-lib: 3.0.1 - yn: 3.1.1 - optional: true - ts-node@10.9.2(@types/node@25.9.4)(typescript@5.9.3): dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -28516,6 +28628,25 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + vite-plugin-dts@4.5.4(@types/node@25.6.0)(rollup@4.62.2)(typescript@5.9.3)(vite@8.1.0(@types/node@25.6.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)): + dependencies: + '@microsoft/api-extractor': 7.58.9(@types/node@25.6.0) + '@rollup/pluginutils': 5.4.0(rollup@4.62.2) + '@volar/typescript': 2.4.28 + '@vue/language-core': 2.2.0(typescript@5.9.3) + compare-versions: 6.1.1 + debug: 4.4.3 + kolorist: 1.8.0 + local-pkg: 1.2.1 + magic-string: 0.30.21 + typescript: 5.9.3 + optionalDependencies: + vite: 8.1.0(@types/node@25.6.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + transitivePeerDependencies: + - '@types/node' + - rollup + - supports-color + vite-plugin-dts@5.0.3(@microsoft/api-extractor@7.58.9(@types/node@25.6.0))(esbuild@0.28.1)(rolldown@1.1.3)(rollup@4.62.2)(typescript@5.9.3)(vite@8.1.0(@types/node@25.6.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(webpack@5.107.2(esbuild@0.28.1)(postcss@8.5.16)): dependencies: unplugin-dts: 1.0.3(@microsoft/api-extractor@7.58.9(@types/node@25.6.0))(esbuild@0.28.1)(rolldown@1.1.3)(rollup@4.62.2)(typescript@5.9.3)(vite@8.1.0(@types/node@25.6.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(webpack@5.107.2(esbuild@0.28.1)(postcss@8.5.16)) @@ -28535,7 +28666,7 @@ snapshots: vite@8.1.0(@types/node@25.6.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 - picomatch: 4.0.4 + picomatch: 4.0.5 postcss: 8.5.16 rolldown: 1.1.3 tinyglobby: 0.2.17 @@ -28548,26 +28679,10 @@ snapshots: tsx: 4.22.4 yaml: 2.9.0 - vite@8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0): - dependencies: - lightningcss: 1.32.0 - picomatch: 4.0.4 - postcss: 8.5.16 - rolldown: 1.1.3 - tinyglobby: 0.2.17 - optionalDependencies: - '@types/node': 25.9.3 - esbuild: 0.28.1 - fsevents: 2.3.3 - jiti: 2.7.0 - terser: 5.48.0 - tsx: 4.22.4 - yaml: 2.9.0 - vite@8.1.0(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 - picomatch: 4.0.4 + picomatch: 4.0.5 postcss: 8.5.16 rolldown: 1.1.3 tinyglobby: 0.2.17 @@ -28594,7 +28709,7 @@ snapshots: magic-string: 0.30.21 obug: 2.1.3 pathe: 2.0.3 - picomatch: 4.0.4 + picomatch: 4.0.5 std-env: 4.1.0 tinybench: 2.9.0 tinyexec: 1.2.4 @@ -28622,7 +28737,7 @@ snapshots: magic-string: 0.30.21 obug: 2.1.3 pathe: 2.0.3 - picomatch: 4.0.4 + picomatch: 4.0.5 std-env: 4.1.0 tinybench: 2.9.0 tinyexec: 1.2.4