From a71f92b172a276ec897c48d69ad9ef314b334db2 Mon Sep 17 00:00:00 2001 From: Alex Louviers Date: Wed, 8 Jul 2026 14:47:49 +0200 Subject: [PATCH 1/4] feat: add theme builder, contrast checker, and palette routes to color palette generator --- apps/eds-color-palette-generator/package.json | 29 +- .../src/app/contrast/page.tsx | 795 ++++++++++++++ .../src/app/example/page.tsx | 982 ++++++++++++++++++ .../src/app/palette/page.tsx | 484 +++++++++ .../src/app/themebuilder/page.tsx | 154 +++ .../src/components/Badge.tsx | 33 + .../src/components/HeaderPanel.tsx | 34 +- .../components/themebuilder/CardPreview.tsx | 142 +++ .../themebuilder/ComponentPreviewPanel.tsx | 140 +++ .../components/themebuilder/ContrastTable.tsx | 191 ++++ .../themebuilder/ContrastTestPanel.tsx | 71 ++ .../themebuilder/DataTablePreview.tsx | 146 +++ .../themebuilder/LoginFormPreview.tsx | 181 ++++ .../themebuilder/PaletteInputPanel.tsx | 382 +++++++ .../themebuilder/SimpleColorPicker.tsx | 49 + .../themebuilder/ThemeBuilderHeader.tsx | 94 ++ .../components/themebuilder/TokenMatrix.tsx | 130 +++ .../contrast/BordersOnBackgrounds.tsx | 78 ++ .../themebuilder/contrast/ContrastCard.tsx | 157 +++ .../themebuilder/contrast/DataColorChart.tsx | 611 +++++++++++ .../contrast/InteractivePicker.tsx | 76 ++ .../contrast/SamePalettePairings.tsx | 107 ++ .../contrast/SemanticPairings.tsx | 175 ++++ .../themebuilder/contrast/StepSelect.tsx | 79 ++ .../themebuilder/contrast/SurfacePreview.tsx | 136 +++ .../contrast/TextOnEmphasisFills.tsx | 78 ++ .../contrast/TextOnInteractiveFills.tsx | 77 ++ .../src/config/categories.ts | 7 + .../src/config/config.ts | 16 +- .../src/utils/color.ts | 34 +- .../src/utils/palette.ts | 176 ++++ .../src/utils/urlState.ts | 142 +++ .../vite.config.ts | 12 +- 33 files changed, 5952 insertions(+), 46 deletions(-) create mode 100644 apps/eds-color-palette-generator/src/app/contrast/page.tsx create mode 100644 apps/eds-color-palette-generator/src/app/example/page.tsx create mode 100644 apps/eds-color-palette-generator/src/app/palette/page.tsx create mode 100644 apps/eds-color-palette-generator/src/app/themebuilder/page.tsx create mode 100644 apps/eds-color-palette-generator/src/components/Badge.tsx create mode 100644 apps/eds-color-palette-generator/src/components/themebuilder/CardPreview.tsx create mode 100644 apps/eds-color-palette-generator/src/components/themebuilder/ComponentPreviewPanel.tsx create mode 100644 apps/eds-color-palette-generator/src/components/themebuilder/ContrastTable.tsx create mode 100644 apps/eds-color-palette-generator/src/components/themebuilder/ContrastTestPanel.tsx create mode 100644 apps/eds-color-palette-generator/src/components/themebuilder/DataTablePreview.tsx create mode 100644 apps/eds-color-palette-generator/src/components/themebuilder/LoginFormPreview.tsx create mode 100644 apps/eds-color-palette-generator/src/components/themebuilder/PaletteInputPanel.tsx create mode 100644 apps/eds-color-palette-generator/src/components/themebuilder/SimpleColorPicker.tsx create mode 100644 apps/eds-color-palette-generator/src/components/themebuilder/ThemeBuilderHeader.tsx create mode 100644 apps/eds-color-palette-generator/src/components/themebuilder/TokenMatrix.tsx create mode 100644 apps/eds-color-palette-generator/src/components/themebuilder/contrast/BordersOnBackgrounds.tsx create mode 100644 apps/eds-color-palette-generator/src/components/themebuilder/contrast/ContrastCard.tsx create mode 100644 apps/eds-color-palette-generator/src/components/themebuilder/contrast/DataColorChart.tsx create mode 100644 apps/eds-color-palette-generator/src/components/themebuilder/contrast/InteractivePicker.tsx create mode 100644 apps/eds-color-palette-generator/src/components/themebuilder/contrast/SamePalettePairings.tsx create mode 100644 apps/eds-color-palette-generator/src/components/themebuilder/contrast/SemanticPairings.tsx create mode 100644 apps/eds-color-palette-generator/src/components/themebuilder/contrast/StepSelect.tsx create mode 100644 apps/eds-color-palette-generator/src/components/themebuilder/contrast/SurfacePreview.tsx create mode 100644 apps/eds-color-palette-generator/src/components/themebuilder/contrast/TextOnEmphasisFills.tsx create mode 100644 apps/eds-color-palette-generator/src/components/themebuilder/contrast/TextOnInteractiveFills.tsx create mode 100644 apps/eds-color-palette-generator/src/config/categories.ts create mode 100644 apps/eds-color-palette-generator/src/utils/palette.ts create mode 100644 apps/eds-color-palette-generator/src/utils/urlState.ts 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/src/app/contrast/page.tsx b/apps/eds-color-palette-generator/src/app/contrast/page.tsx new file mode 100644 index 0000000000..c09b067396 --- /dev/null +++ b/apps/eds-color-palette-generator/src/app/contrast/page.tsx @@ -0,0 +1,795 @@ +'use client' + +import { useState, useMemo, useCallback } from 'react' +import Color from 'colorjs.io' +import Link from 'next/link' +import { contrast } from '@/utils/color' +import { + STEP_ROLES, + PALETTES, + getApcaFontBreakdown, + calcContrast, +} from '@/utils/palette' +import type { ContrastResult } from '@/utils/palette' +import { Badge } from '@/components/Badge' +import { CATEGORY_GROUPS } from '@/config/categories' + +function getLightness(hex: string): number { + try { + return new Color(hex).to('oklch').l ?? 0 + } catch { + return 0 + } +} + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +type TextChoice = ContrastResult & { color: string; label: string } + +type StepData = { + step: number + role: string + hex: string + recommended: TextChoice + paletteText: TextChoice +} + +type SortOrder = 'semantic' | 'gradient' + +type ViewMode = 'semantic' | 'gradient' | 'combined' + +/* ------------------------------------------------------------------ */ +/* Combined view — real cross-palette interaction patterns */ +/* ------------------------------------------------------------------ */ + +type Pairing = { + state: string + type: 'text' | 'border' | 'fill' + fg: { label: string; hex: string } + bg: { label: string; hex: string } + contrast: ContrastResult +} + +type PatternGroup = { + title: string + description: string + pairings: Pairing[] +} + +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 */ +/* ------------------------------------------------------------------ */ + +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})`, + } +} + +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, + } + }) +} + +/* ------------------------------------------------------------------ */ +/* Sub-components */ +/* ------------------------------------------------------------------ */ + +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}` : ''} + + ))} +
+
+
+ ) +} + +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 ( + + ) +} + +/* ------------------------------------------------------------------ */ +/* Page */ +/* ------------------------------------------------------------------ */ + +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 + +
+
+
+ +
+ {/* ============================================================ */} + {/* COMBINED VIEW */} + {/* ============================================================ */} + {viewMode === 'combined' && ( +
+ {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}` : ''} + + ))} +
+
+
+ ) + })} +
+
+ ))} +
+ )} + + {/* ============================================================ */} + {/* SINGLE PALETTE VIEW (Curve / Gradient) */} + {/* ============================================================ */} + {viewMode !== 'combined' && ( + <> + {/* ---- Category legend + palette strip (shared 15-col grid) ---- */} +
+ {viewMode === '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/app/example/page.tsx b/apps/eds-color-palette-generator/src/app/example/page.tsx new file mode 100644 index 0000000000..c7f5d26c07 --- /dev/null +++ b/apps/eds-color-palette-generator/src/app/example/page.tsx @@ -0,0 +1,982 @@ +'use client' + +import { useState, useEffect, useMemo, useCallback } from 'react' +import Link from 'next/link' +import { + STEP_ROLES, + PALETTES, + getApcaFontBreakdown, + calcContrast, + roleIndex, + getSimulationPalettes, +} from '@/utils/palette' +import type { StepRole, TokenPalette } from '@/utils/palette' +import { Badge } from '@/components/Badge' + +/* ------------------------------------------------------------------ */ +/* Pre-defined example groups */ +/* ------------------------------------------------------------------ */ + +type ExamplePairing = { + fg: StepRole + bg: StepRole + type?: 'text' | 'border' +} + +type ExampleGroup = { + title: string + description: string + pairings: ExamplePairing[] +} + +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' }, + ], + }, +] + +/* ------------------------------------------------------------------ */ +/* Sub-components */ +/* ------------------------------------------------------------------ */ + +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}` : ''} + + ))} +
+
+
+ ) +} + +/* ------------------------------------------------------------------ */ +/* Page */ +/* ------------------------------------------------------------------ */ + +/* ------------------------------------------------------------------ */ +/* Surface layer roles for the layout preview */ +/* ------------------------------------------------------------------ */ + +const SURFACE_ROLES = STEP_ROLES.filter((r) => r.startsWith('bg-')) +const BORDER_ROLES = STEP_ROLES.filter((r) => r.startsWith('border-')) + +type SurfaceConfig = { + page: StepRole + panel: StepRole + cardRow: StepRole + card: StepRole + border: StepRole + text: StepRole +} + +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. */ +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} + + +
+ ) +} + +function SurfaceSelect({ + label, + value, + onChange, + options, + recommended, +}: { + label: string + value: StepRole + onChange: (v: StepRole) => void + options: readonly StepRole[] + recommended?: ReadonlySet +}) { + return ( + + ) +} + +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 + + + +
+
+ ))} +
+
+ ) +} + +export default function ExamplePage() { + const [activePalette, setActivePalette] = useState(0) + const [fgRole, setFgRole] = useState('12 · fg/strong') + const [bgRole, setBgRole] = useState('1 · bg/canvas') + const [surfaceConfig, setSurfaceConfig] = + useState(DEFAULT_SURFACE) + const [customPalettes, setCustomPalettes] = useState([]) + + useEffect(() => { + setCustomPalettes(getSimulationPalettes()) + }, []) + + const refreshCustomPalettes = useCallback(() => { + setCustomPalettes(getSimulationPalettes()) + }, []) + + const allPalettes = useMemo( + () => [...PALETTES, ...customPalettes], + [customPalettes], + ) + + const palette = allPalettes[activePalette] ?? allPalettes[0] + + const fgRoles = STEP_ROLES.filter((r) => + r.startsWith('text-') || r.startsWith('border-'), + ) + const bgRoles = STEP_ROLES.filter((r) => + r.startsWith('bg-'), + ) + + return ( +
+ {/* ---- Header ---- */} +
+
+

EDS Palette Examples

+ +
+ {/* Palette selector */} +
+ {allPalettes.map((p, i) => ( + + ))} + +
+ + {/* Nav links */} + + Contrast + + + Palette + +
+
+
+ +
+ {/* ============================================================ */} + {/* PRE-DEFINED COMBINATION GROUPS */} + {/* ============================================================ */} +
+ {EXAMPLE_GROUPS.map((group) => ( +
+

+ {group.title} +

+

+ {group.description} +

+ +
+ {group.pairings.map((p, i) => ( + + ))} +
+
+ ))} +
+ + {/* ============================================================ */} + {/* SURFACE PREVIEW */} + {/* ============================================================ */} +
+

+ 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} +
+ +
+ ))} +
+
+
+ + {/* ============================================================ */} + {/* INTERACTIVE PICKER */} + {/* ============================================================ */} +
+

+ 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/app/palette/page.tsx b/apps/eds-color-palette-generator/src/app/palette/page.tsx new file mode 100644 index 0000000000..1d3aa44fba --- /dev/null +++ b/apps/eds-color-palette-generator/src/app/palette/page.tsx @@ -0,0 +1,484 @@ +'use client' + +import { useState, useEffect, useMemo, useCallback } from 'react' +import Color from 'colorjs.io' +import Link from 'next/link' +import { contrast, generateColorScale } from '@/utils/color' +import { + STEP_ROLES, + setSimulationPalettes, +} from '@/utils/palette' +import type { TokenPalette } from '@/utils/palette' +import { localStorageUtils } from '@/utils/localStorage' +import { paletteConfig } from '@/config/palette' +import { getLightnessValues } from '@/config/helpers' +import { PALETTE_STEPS } from '@/config/config' +import type { ColorDefinition } from '@/types' + +/* ------------------------------------------------------------------ */ +/* Constants */ +/* ------------------------------------------------------------------ */ + +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 + +type ViewMode = 'curve' | 'gradient' + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +function getLightness(hex: string): number { + try { + return new Color(hex).to('oklch').l ?? 0 + } catch { + return 0 + } +} + +function labelColor(hex: string): string { + try { + const wcag = parseFloat( + String( + contrast({ + foreground: '#ffffff', + background: hex, + algorithm: 'WCAG21', + silent: true, + }), + ), + ) + return wcag >= 3 ? '#fff' : '#000' + } catch { + return '#000' + } +} + +function isValidHex(v: string): boolean { + return /^#[0-9a-fA-F]{6}$/.test(v) +} + +/* ------------------------------------------------------------------ */ +/* Page */ +/* ------------------------------------------------------------------ */ + +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 + setHasAutoImported(true) + + 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), + ) + + const generated: TokenPalette[] = 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 } + }) + + setPalettes(generated) + }, [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(() => { + 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), + ) + + const newPalettes: TokenPalette[] = 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 } + }) + + setPalettes(newPalettes) + }, []) + + /* ---- Sort for gradient view ---- */ + const getSortedSteps = useCallback( + (steps: string[]) => { + if (viewMode === 'curve') return steps.map((hex, i) => ({ hex, i })) + return steps + .map((hex, i) => ({ hex, i })) + .sort((a, b) => getLightness(b.hex) - getLightness(a.hex)) + }, + [viewMode], + ) + + return ( +
+ {/* ---- Header ---- */} +
+
+

Palette Editor

+ +
+ {/* View mode toggle */} +
+ {(['curve', 'gradient'] as const).map((mode) => ( + + ))} +
+ + {/* Nav links */} + + Contrast + + + Example + +
+
+
+ +
+ {/* ---- Actions ---- */} +
+ + +
+ + {/* ---- Palette list ---- */} +
+ {palettes.map((pal, palIdx) => { + const sorted = getSortedSteps(pal.steps) + + return ( +
+ {/* Palette header */} +
+ updatePaletteName(palIdx, 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 + updateStep(palIdx, 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/app/themebuilder/page.tsx b/apps/eds-color-palette-generator/src/app/themebuilder/page.tsx new file mode 100644 index 0000000000..efa2d43772 --- /dev/null +++ b/apps/eds-color-palette-generator/src/app/themebuilder/page.tsx @@ -0,0 +1,154 @@ +'use client' + +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 { + lightnessValuesInLightMode, + darknessValuesInDarkMode, +} from '@/config/config' +import { paletteConfig } from '@/config/palette' +import { deserializeState, updateURL } from '@/utils/urlState' +import type { PaletteInput } from '@/utils/urlState' +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', + } + }, +) + +function ThemeBuilderContent() { + const searchParams = useSearchParams() + const { colorScheme } = useColorScheme() + + // Initialize state from URL or defaults + const initialState = useMemo(() => { + if (!searchParams) return {} + return deserializeState(searchParams) + }, [searchParams]) + + const [palettes, setPalettes] = useState( + initialState.palettes ?? DEFAULT_PALETTES, + ) + const [activeTab, setActiveTab] = useState( + initialState.activeTab ?? 'fargesystem', + ) + const [contrastPaletteIndex, setContrastPaletteIndex] = useState(0) + + // Sync state to URL + useEffect(() => { + updateURL({ + palettes, + activeTab, + mode: colorScheme, + }) + }, [palettes, activeTab, colorScheme]) + + // Reset contrast index if palette list shrinks + useEffect(() => { + if (contrastPaletteIndex >= palettes.length) { + setContrastPaletteIndex(0) + } + }, [palettes.length, 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', + ) + } else { + // Simple hex color + const hex = p.baseColor.startsWith('#') + ? p.baseColor + : `#${p.baseColor}` + steps = generateColorScale(hex, lightnessValues, mean, stdDev, 'HEX') + } + return { name: p.name, steps } + }) + }, [palettes, lightnessValues, mean, stdDev]) + + const handlePalettesChange = useCallback((next: PaletteInput[]) => { + setPalettes(next) + }, []) + + return ( +
+ + +
+ {activeTab === 'fargesystem' ? ( +
+ + + + + +
+ ) : activeTab === 'kontrast' ? ( + + ) : ( + + )} +
+
+ ) +} + +export default function ThemeBuilderPage() { + return ( + + + + ) +} diff --git a/apps/eds-color-palette-generator/src/components/Badge.tsx b/apps/eds-color-palette-generator/src/components/Badge.tsx new file mode 100644 index 0000000000..30fb93e2bb --- /dev/null +++ b/apps/eds-color-palette-generator/src/components/Badge.tsx @@ -0,0 +1,33 @@ +type BadgeVariant = 'pass-fail' | 'level' + +type BadgeProps = { + pass: boolean + label: string + variant?: BadgeVariant +} + +export function Badge({ pass, label, variant = 'pass-fail' }: BadgeProps) { + const bgColor = pass ? '#dcfce7' : '#fee2e2' + const textColor = pass ? '#166534' : '#991b1b' + + // Level variant uses blue for informational badges (DECO, AA18) + const isInfo = variant === 'level' && pass + const finalBg = isInfo ? '#dbeafe' : bgColor + const finalColor = isInfo ? '#1e40af' : textColor + + return ( + + {label} + + ) +} diff --git a/apps/eds-color-palette-generator/src/components/HeaderPanel.tsx b/apps/eds-color-palette-generator/src/components/HeaderPanel.tsx index 0c1fa4693e..17dcc8cb33 100644 --- a/apps/eds-color-palette-generator/src/components/HeaderPanel.tsx +++ b/apps/eds-color-palette-generator/src/components/HeaderPanel.tsx @@ -1,6 +1,6 @@ import React from 'react' import { ThemeToggle } from './ThemeToggle' -import { Settings, Info } from 'lucide-react' +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 + + {cards.map((card, i) => { + const tagPalette = dataColors.length > 0 + ? dataColors[i % dataColors.length] + : null + + return ( +
+ {/* Accent bar */} +
+ +
+ {/* Tag — data color muted fill */} + + {card.tag} + + + {/* Title */} +
+ {card.title} +
+ + {/* Body */} +
+ {card.body} +
+ + {/* Link — accent text */} +
+ 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..d0bf14e6ac --- /dev/null +++ b/apps/eds-color-palette-generator/src/components/themebuilder/ComponentPreviewPanel.tsx @@ -0,0 +1,140 @@ +'use client' + +import { useState } from 'react' +import { LoginFormPreview } from './LoginFormPreview' +import { DataTablePreview } from './DataTablePreview' +import { CardPreview } from './CardPreview' + +type GeneratedPalette = { + name: string + steps: string[] +} + +type ComponentPreviewPanelProps = { + palettes: GeneratedPalette[] +} + +export function ComponentPreviewPanel({ + palettes, +}: ComponentPreviewPanelProps) { + if (palettes.length === 0) return null + + // Gray is always the neutral — find it by name + const grayIdx = palettes.findIndex( + (p) => + p.name.toLowerCase().includes('gray') || + p.name.toLowerCase().includes('grey'), + ) + const neutralIdx = grayIdx >= 0 ? grayIdx : 0 + 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) + + // Default accent: first non-gray palette + const [accentIdx, setAccentIdx] = useState( + accentCandidates.length > 0 ? accentCandidates[0].idx : neutralIdx, + ) + + const accent = palettes[accentIdx]?.steps ?? neutral + + // 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} + + ))} +
+ )} +
+
+ + {/* 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..70e18f7e33 --- /dev/null +++ b/apps/eds-color-palette-generator/src/components/themebuilder/ContrastTable.tsx @@ -0,0 +1,191 @@ +'use client' + +import { useMemo } from 'react' +import { calcContrast } from '@/utils/palette' +import { STEP_ROLES } from '@/utils/palette' +import { Badge } from '@/components/Badge' + +type GeneratedPalette = { + name: string + steps: string[] +} + +type ContrastTableProps = { + palettes: GeneratedPalette[] + activePaletteIndex: number + onActivePaletteChange: (index: number) => void +} + +// Background column indices (steps 1-5, 9-11 → 0-indexed) +const BG_INDICES = [0, 1, 2, 3, 4, 8, 9, 10] +// Foreground row indices (border 6-8, text 12-15 → 0-indexed) +const FG_INDICES = [5, 6, 7, 11, 12, 13, 14] + +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] + if (!palette) return null + + const grid = useMemo(() => { + return FG_INDICES.map((fgIdx) => ({ + fgIdx, + fgRole: STEP_ROLES[fgIdx], + fgHex: palette.steps[fgIdx], + cells: BG_INDICES.map((bgIdx) => { + const result = calcContrast(palette.steps[fgIdx], palette.steps[bgIdx]) + const wcagNum = parseFloat(result.wcag) + const level = getContrastLevel(wcagNum) + return { + bgIdx, + bgRole: STEP_ROLES[bgIdx], + wcag: result.wcag, + level, + } + }), + })) + }, [palette]) + + return ( +
+
+

Contrast Table

+ + {palettes.length > 1 && ( +
+ {palettes.map((p, i) => ( + + ))} +
+ )} +
+ +
+ + + + + {BG_INDICES.map((bgIdx) => ( + + ))} + + + + {grid.map((row) => ( + + + {row.cells.map((cell) => ( + + ))} + + ))} + +
+ fg \ bg + +
+ + {bgIdx + 1} +
+
+
+ + + {row.fgIdx + 1}. {row.fgRole} + +
+
+
+ + + {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..d41fa4726b --- /dev/null +++ b/apps/eds-color-palette-generator/src/components/themebuilder/DataTablePreview.tsx @@ -0,0 +1,146 @@ +'use client' + +import { useId, useState } from 'react' + +type GeneratedPalette = { + name: string + steps: string[] +} + +type DataTablePreviewProps = { + neutral: string[] + accent: string[] + dataColors: GeneratedPalette[] +} + +/** + * Figma semantic → primitive mapping: + * + * background/surface/default/default = Gray/1 → neutral[0] (default row) + * background/surface/default/hover = Gray/2 → neutral[1] (hover row) + * background/surface/default/pressed = Gray/3 → neutral[2] (pressed row) + * background/surface/default/selected = Accent/2 → accent[1] (selected row) + * border/default = Gray/4 → neutral[3] (row separator) + * + * text/primary → neutral[11] (primary text, Gray/12) + * text/subtle → neutral[8] (timestamps, Gray/9) + * text/link → accent[12] (name link) + * + * dataColor text/strong → dataColor[12] (status text) + */ +export function DataTablePreview({ + neutral, + accent, + dataColors, +}: DataTablePreviewProps) { + const uid = useId().replace(/:/g, '') + const [activeRow, setActiveRow] = useState(null) + + const greenPal = dataColors.find((p) => + p.name.toLowerCase().includes('green'), + ) + const orangePal = dataColors.find((p) => + p.name.toLowerCase().includes('orange'), + ) + const redPal = dataColors.find((p) => p.name.toLowerCase().includes('red')) + + const rows = [ + { name: 'Hywind Scotland', status: 'Online', statusColor: greenPal, value: '124 MW', time: '2 min ago' }, + { name: 'Hywind Scotland', status: 'Online', statusColor: greenPal, value: '124 MW', time: '2 min ago' }, + { name: 'Hywind Scotland', status: 'Pending', statusColor: orangePal, value: '124 MW', time: '2 min ago' }, + { name: 'Hywind Scotland', status: 'Offline', statusColor: redPal, value: '124 MW', time: '2 min ago' }, + { name: 'Hywind Scotland', status: 'Online', statusColor: greenPal, 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 ${neutral[3]}`, + }} + > +
+ {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..2c7ecc9a22 --- /dev/null +++ b/apps/eds-color-palette-generator/src/components/themebuilder/LoginFormPreview.tsx @@ -0,0 +1,181 @@ +'use client' + +import { useId } from 'react' + +type LoginFormPreviewProps = { + neutral: string[] + accent: string[] +} + +/** + * Figma semantic → primitive mapping: + * + * background/container/card/default → neutral[14] (card fill, Gray/15 = white) + * background/surface/default/default = Gray/1 → neutral[0] (input bg) + * border/default = Gray/4 → neutral[3] (input border default) + * border/hover = Gray/7 → neutral[6] (input border hover) + * border/strong = Gray/9 → neutral[8] (input border focus) + * + * border/accent/default = Accent/8 → accent[7] (focus ring) + * + * background/surface/accent/default/default = Accent/11 → accent[10] (button) + * background/surface/accent/default/hover = Accent/12 → accent[11] (button hover) + * background/surface/accent/default/pressed = Accent/13 → accent[12] (button pressed) + * + * text/primary → neutral[11] (labels, Gray/12) + * text/subtle → neutral[8] (placeholder, Gray/9) + * text/link → accent[12] (link text) + * text/on-emphasis → accent[14] (button text, L=1.00 = white) + */ +export function LoginFormPreview({ neutral, accent }: 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..82a0a89520 --- /dev/null +++ b/apps/eds-color-palette-generator/src/components/themebuilder/ThemeBuilderHeader.tsx @@ -0,0 +1,94 @@ +'use client' + +import { useState, useCallback } from 'react' +import { ThemeToggle } from '@/components/ThemeToggle' +import { Link2, Check } from 'lucide-react' + +type Tab = 'fargesystem' | 'eksempler' | 'kontrast' + +type ThemeBuilderHeaderProps = { + activeTab: Tab + onTabChange: (tab: Tab) => void +} + +export function ThemeBuilderHeader({ + activeTab, + onTabChange, +}: 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) => ( + + ))} +
+ +
+ + + +
+
+
+ ) +} 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..bf348cf7ab --- /dev/null +++ b/apps/eds-color-palette-generator/src/components/themebuilder/TokenMatrix.tsx @@ -0,0 +1,130 @@ +'use client' + +import { useMemo } from 'react' +import { contrast } from '@/utils/color' +import { STEP_ROLES } from '@/utils/palette' +import { 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 */} +
+ {CATEGORY_GROUPS.map((group) => ( +
+ {group.label} +
+
+ ))} + + {/* Step number headers */} +
+ {STEP_ROLES.map((role, i) => ( +
+ {i + 1} +
+ ))} + + {/* Palette rows */} + {palettes.map((palette) => ( + + ))} +
+
+
+ ) +} + +function PaletteRow({ palette }: { palette: GeneratedPalette }) { + const textColors = useMemo( + () => palette.steps.map(getTextColor), + [palette.steps], + ) + + return ( + <> +
+ {palette.name} +
+ {palette.steps.map((hex, i) => ( +
+ {i + 1} +
+ ))} + + ) +} diff --git a/apps/eds-color-palette-generator/src/components/themebuilder/contrast/BordersOnBackgrounds.tsx b/apps/eds-color-palette-generator/src/components/themebuilder/contrast/BordersOnBackgrounds.tsx new file mode 100644 index 0000000000..e494a2b984 --- /dev/null +++ b/apps/eds-color-palette-generator/src/components/themebuilder/contrast/BordersOnBackgrounds.tsx @@ -0,0 +1,78 @@ +'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: 5, bg: 0 }, + { fg: 7, bg: 1 }, +] + +export function BordersOnBackgrounds({ + 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)), + ) + } + + return ( +
+
+

+ Borders on backgrounds +

+

+ Border visibility against canvas and surface backgrounds +

+
+ + {pairings.map(({ fg, bg }, idx) => ( +
+
+ update(idx, 'fg', v)} + recommended={[1, 3, 4, 6, 7]} + /> + update(idx, 'bg', v)} + recommended={[0, 1, 2, 5, 14]} + /> +
+ +
+ {palettes.map((p) => ( + + ))} +
+
+ ))} +
+ ) +} 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..81ca7aefe8 --- /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/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..9e6a3bde60 --- /dev/null +++ b/apps/eds-color-palette-generator/src/components/themebuilder/contrast/DataColorChart.tsx @@ -0,0 +1,611 @@ +'use client' + +import { useState, useMemo } from 'react' +import { STEP_ROLES, calcContrast } from '@/utils/palette' +import { Badge } from '@/components/Badge' +import { StepSelect } from './StepSelect' + +type Palette = { name: string; steps: string[] } + +type CVDType = 'none' | 'protanopia' | 'deuteranopia' | 'tritanopia' | 'achromatopsia' + +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)' }, +] + +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', +} + +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] + +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] +} + +/* ---------------------------------------------------------------------- */ +/* Chart renderers */ +/* ---------------------------------------------------------------------- */ + +type ChartProps = { + colors: { name: string; hex: string }[] + bgHex: string + textHex: string +} + +function BarChart({ colors, bgHex, textHex }: ChartProps) { + return ( +
+
+ {colors.map((pc, i) => { + const h = BAR_DATA[0][i % BAR_DATA[0].length] + return ( +
+ + {h} + +
+
+ ) + })} +
+ +
+ ) +} + +function StackedBarChart({ colors, bgHex, textHex }: 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 }: ChartProps) { + const total = DONUT_SEGMENTS.slice(0, colors.length).reduce((a, b) => a + b, 0) + let cumulative = 0 + + return ( +
+
+ + {colors.map((pc, i) => { + const seg = DONUT_SEGMENTS[i % DONUT_SEGMENTS.length] + const pct = (seg / total) * 100 + const offset = 25 - cumulative + cumulative += pct + return ( + + ) + })} + +
+ {total}% +
+
+
+ ) +} + +function LineChart({ colors, bgHex, textHex }: 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 ( + + ) + })} + +
+ {['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: { name: string; hex: string; step: number }[] + bgHex: string +}) { + return ( +
+ {colors.map((pc) => ( +
+ + + {pc.name}{' '} + ({STEP_ROLES[pc.step]}) + +
+ ))} +
+ ) +} + +/* ---------------------------------------------------------------------- */ +/* Main component */ +/* ---------------------------------------------------------------------- */ + +export function DataColorChart({ palettes }: { palettes: Palette[] }) { + 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 neutralIdx = findGrayIndex(palettes) + const bgHex = palettes[neutralIdx].steps[bgStep] + const textHex = palettes[neutralIdx].steps[textStep] + + /** + * 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 = palettes.length + const startStep = Math.max(0, center - Math.floor((count - 1) / 2)) + return palettes.map((p, i) => { + const step = Math.min(14, startStep + i) + return { name: p.name, hex: p.steps[step], step } + }) + }, [palettes]) + + // Monochromatic: multiple shades from a single palette + const monoColors = useMemo(() => { + const p = palettes[monoPaletteIdx] ?? palettes[0] + return monochromaticSteps().map((step) => ({ + name: `${p.name}/${step + 1}`, + hex: p.steps[step], + step, + })) + }, [palettes, monoPaletteIdx]) + + const colors = mono ? monoColors : multiColors + + const ChartComponent = { + bar: BarChart, + 'stacked-bar': StackedBarChart, + donut: DonutChart, + line: LineChart, + }[chartType] + + return ( +
+
+

+ Data color chart +

+

+ Preview palette colors in chart contexts — multi-chromatic (one color + per palette) or monochromatic (shades from a single palette) +

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

+ Chips & badges +

+
+ {colors.map((pc) => ( + + {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 */} + {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..95b553c610 --- /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/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..b6e53cc703 --- /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/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/contrast/TextOnEmphasisFills.tsx b/apps/eds-color-palette-generator/src/components/themebuilder/contrast/TextOnEmphasisFills.tsx new file mode 100644 index 0000000000..8e3e2d23bb --- /dev/null +++ b/apps/eds-color-palette-generator/src/components/themebuilder/contrast/TextOnEmphasisFills.tsx @@ -0,0 +1,78 @@ +'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: 14, bg: 8 }, + { fg: 13, bg: 8 }, +] + +export function TextOnEmphasisFills({ + 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)), + ) + } + + return ( +
+
+

+ Text on emphasis fills +

+

+ Inverted text readability on emphasis backgrounds (default, hover, + active) +

+
+ + {pairings.map(({ fg, bg }, idx) => ( +
+
+ update(idx, 'fg', v)} + recommended={[0, 13, 14]} + /> + update(idx, 'bg', v)} + recommended={[8, 9, 10, 11, 12]} + /> +
+ +
+ {palettes.map((p) => ( + + ))} +
+
+ ))} +
+ ) +} diff --git a/apps/eds-color-palette-generator/src/components/themebuilder/contrast/TextOnInteractiveFills.tsx b/apps/eds-color-palette-generator/src/components/themebuilder/contrast/TextOnInteractiveFills.tsx new file mode 100644 index 0000000000..d0585a13dd --- /dev/null +++ b/apps/eds-color-palette-generator/src/components/themebuilder/contrast/TextOnInteractiveFills.tsx @@ -0,0 +1,77 @@ +'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: 12, bg: 2 }, + { fg: 12, bg: 3 }, +] + +export function TextOnInteractiveFills({ + 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)), + ) + } + + return ( +
+
+

+ Text on interactive fills +

+

+ Text readability on muted fill states (default, hover, active) +

+
+ + {pairings.map(({ fg, bg }, idx) => ( +
+
+ update(idx, 'fg', v)} + recommended={[6, 7, 11, 12]} + /> + update(idx, 'bg', v)} + recommended={[0, 1, 2, 5, 14]} + /> +
+ +
+ {palettes.map((p) => ( + + ))} +
+
+ ))} +
+ ) +} 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..08c14596a7 --- /dev/null +++ b/apps/eds-color-palette-generator/src/config/categories.ts @@ -0,0 +1,7 @@ +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 diff --git a/apps/eds-color-palette-generator/src/config/config.ts b/apps/eds-color-palette-generator/src/config/config.ts index 44503c1c5d..487e3ba176 100644 --- a/apps/eds-color-palette-generator/src/config/config.ts +++ b/apps/eds-color-palette-generator/src/config/config.ts @@ -27,7 +27,7 @@ export const BG_SURFACE: StepDefinition = { name: 'Background Surface', category: 'Background', variant: 'surface', - lightValue: 0.999, + lightValue: 0.94, darkValue: 0.25, } @@ -84,7 +84,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 +105,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 +131,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 +167,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 +188,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 +230,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 +256,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/utils/color.ts b/apps/eds-color-palette-generator/src/utils/color.ts index 6bfe9df6b3..183cb97db6 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)) 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/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 })], }) From 00aeec26d8cd136f2d418eb6097a0c36311e9c80 Mon Sep 17 00:00:00 2001 From: Alex Louviers Date: Thu, 9 Jul 2026 11:40:06 +0200 Subject: [PATCH 2/4] fix: satisfy react-hooks lint rules in color palette generator Resolve pre-existing ESLint errors surfaced by the theme-builder promotion: - ContrastTable/ComponentPreviewPanel: call hooks before early returns - DataColorChart: precompute donut segments instead of mutating during render - example/page.tsx: hydrate custom palettes via useSyncExternalStore (SSR-safe) - palette/page.tsx: annotate one-shot client-only hydration; drop unused import --- .../src/app/example/page.tsx | 46 +++++++++++++++---- .../src/app/palette/page.tsx | 3 +- .../themebuilder/ComponentPreviewPanel.tsx | 33 ++++++++----- .../components/themebuilder/ContrastTable.tsx | 6 ++- .../themebuilder/contrast/DataColorChart.tsx | 44 +++++++++--------- 5 files changed, 88 insertions(+), 44 deletions(-) diff --git a/apps/eds-color-palette-generator/src/app/example/page.tsx b/apps/eds-color-palette-generator/src/app/example/page.tsx index c7f5d26c07..011bb950a1 100644 --- a/apps/eds-color-palette-generator/src/app/example/page.tsx +++ b/apps/eds-color-palette-generator/src/app/example/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useEffect, useMemo, useCallback } from 'react' +import { useState, useMemo, useCallback, useSyncExternalStore } from 'react' import Link from 'next/link' import { STEP_ROLES, @@ -11,7 +11,7 @@ import { getSimulationPalettes, } from '@/utils/palette' import type { StepRole, TokenPalette } from '@/utils/palette' -import { Badge } from '@/components/Badge' +import { Badge } from '@/components/shared/Badge' /* ------------------------------------------------------------------ */ /* Pre-defined example groups */ @@ -548,20 +548,50 @@ function SurfacePreview({ ) } +/* ------------------------------------------------------------------ */ +/* 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; the Refresh button 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 getCustomPalettesSnapshot(): 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 getCustomPalettesServerSnapshot(): TokenPalette[] { + return EMPTY_PALETTES +} + +function subscribeCustomPalettes(callback: () => void): () => void { + window.addEventListener(SIMULATION_REFRESH_EVENT, callback) + return () => window.removeEventListener(SIMULATION_REFRESH_EVENT, callback) +} + export default function ExamplePage() { const [activePalette, setActivePalette] = useState(0) const [fgRole, setFgRole] = useState('12 · fg/strong') const [bgRole, setBgRole] = useState('1 · bg/canvas') const [surfaceConfig, setSurfaceConfig] = useState(DEFAULT_SURFACE) - const [customPalettes, setCustomPalettes] = useState([]) - - useEffect(() => { - setCustomPalettes(getSimulationPalettes()) - }, []) + const customPalettes = useSyncExternalStore( + subscribeCustomPalettes, + getCustomPalettesSnapshot, + getCustomPalettesServerSnapshot, + ) const refreshCustomPalettes = useCallback(() => { - setCustomPalettes(getSimulationPalettes()) + window.dispatchEvent(new Event(SIMULATION_REFRESH_EVENT)) }, []) const allPalettes = useMemo( diff --git a/apps/eds-color-palette-generator/src/app/palette/page.tsx b/apps/eds-color-palette-generator/src/app/palette/page.tsx index 1d3aa44fba..bb27839804 100644 --- a/apps/eds-color-palette-generator/src/app/palette/page.tsx +++ b/apps/eds-color-palette-generator/src/app/palette/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useEffect, useMemo, useCallback } from 'react' +import { useState, useEffect, useCallback } from 'react' import Color from 'colorjs.io' import Link from 'next/link' import { contrast, generateColorScale } from '@/utils/color' @@ -75,6 +75,7 @@ export default function PalettePage() { /* ---- 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) const colors = localStorageUtils.getColors(paletteConfig.colors) diff --git a/apps/eds-color-palette-generator/src/components/themebuilder/ComponentPreviewPanel.tsx b/apps/eds-color-palette-generator/src/components/themebuilder/ComponentPreviewPanel.tsx index d0bf14e6ac..3eabaaac81 100644 --- a/apps/eds-color-palette-generator/src/components/themebuilder/ComponentPreviewPanel.tsx +++ b/apps/eds-color-palette-generator/src/components/themebuilder/ComponentPreviewPanel.tsx @@ -14,18 +14,32 @@ type ComponentPreviewPanelProps = { palettes: GeneratedPalette[] } -export function ComponentPreviewPanel({ - palettes, -}: ComponentPreviewPanelProps) { - if (palettes.length === 0) return null - - // Gray is always the neutral — find it by name +// 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'), ) - const neutralIdx = grayIdx >= 0 ? grayIdx : 0 + 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)) + + if (palettes.length === 0) return null + + const neutralIdx = findNeutralIndex(palettes) const neutral = palettes[neutralIdx].steps // Non-gray palettes are candidates for accent @@ -33,11 +47,6 @@ export function ComponentPreviewPanel({ .map((p, i) => ({ ...p, idx: i })) .filter((_, i) => i !== neutralIdx) - // Default accent: first non-gray palette - const [accentIdx, setAccentIdx] = useState( - accentCandidates.length > 0 ? accentCandidates[0].idx : neutralIdx, - ) - const accent = palettes[accentIdx]?.steps ?? neutral // All palettes except Gray and the selected accent are data colors diff --git a/apps/eds-color-palette-generator/src/components/themebuilder/ContrastTable.tsx b/apps/eds-color-palette-generator/src/components/themebuilder/ContrastTable.tsx index 70e18f7e33..a0ba0b34b4 100644 --- a/apps/eds-color-palette-generator/src/components/themebuilder/ContrastTable.tsx +++ b/apps/eds-color-palette-generator/src/components/themebuilder/ContrastTable.tsx @@ -3,7 +3,7 @@ import { useMemo } from 'react' import { calcContrast } from '@/utils/palette' import { STEP_ROLES } from '@/utils/palette' -import { Badge } from '@/components/Badge' +import { Badge } from '@/components/shared/Badge' type GeneratedPalette = { name: string @@ -37,9 +37,9 @@ export function ContrastTable({ onActivePaletteChange, }: ContrastTableProps) { const palette = palettes[activePaletteIndex] - if (!palette) return null const grid = useMemo(() => { + if (!palette) return [] return FG_INDICES.map((fgIdx) => ({ fgIdx, fgRole: STEP_ROLES[fgIdx], @@ -58,6 +58,8 @@ export function ContrastTable({ })) }, [palette]) + if (!palette) return null + return (
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 index 9e6a3bde60..32e5cd824c 100644 --- a/apps/eds-color-palette-generator/src/components/themebuilder/contrast/DataColorChart.tsx +++ b/apps/eds-color-palette-generator/src/components/themebuilder/contrast/DataColorChart.tsx @@ -2,7 +2,7 @@ import { useState, useMemo } from 'react' import { STEP_ROLES, calcContrast } from '@/utils/palette' -import { Badge } from '@/components/Badge' +import { Badge } from '@/components/shared/Badge' import { StepSelect } from './StepSelect' type Palette = { name: string; steps: string[] } @@ -151,7 +151,15 @@ function StackedBarChart({ colors, bgHex, textHex }: ChartProps) { function DonutChart({ colors, bgHex, textHex }: ChartProps) { const total = DONUT_SEGMENTS.slice(0, colors.length).reduce((a, b) => a + b, 0) - let cumulative = 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 (
- {colors.map((pc, i) => { - const seg = DONUT_SEGMENTS[i % DONUT_SEGMENTS.length] - const pct = (seg / total) * 100 - const offset = 25 - cumulative - cumulative += pct - return ( - - ) - })} + {segments.map(({ name, hex, pct, offset }) => ( + + ))}
Date: Thu, 9 Jul 2026 11:56:27 +0200 Subject: [PATCH 3/4] refactor: restructure color palette generator layout and routes Promote the theme builder to the root route and move the legacy generator to /old. Reorganize components into docs/, old/, shared/, and themebuilder/ folders; adjust contrast/about/layout routes; drop unused contrast helpers; update e2e specs for the new routes. Includes the render-time clamp fix in the root page (safeContrastIndex), which depends on the promoted theme-builder code and so lands here. --- .../eds-color-palette-generator/ABOUT_PAGE.md | 16 +- apps/eds-color-palette-generator/README.md | 13 +- .../next.config.ts | 6 + .../src/app/about/page.tsx | 8 +- .../src/app/contrast/page.tsx | 2 +- .../src/app/layout.tsx | 5 +- .../src/app/old/page.tsx | 464 ++++++++++++++ .../src/app/page.tsx | 567 ++++-------------- .../src/app/themebuilder/page.tsx | 154 ----- .../{ => docs}/BellCurveVisualization.tsx | 0 .../{ => docs}/ChromaDistributionDemo.tsx | 0 .../{ => docs}/ContrastRequirementsTable.tsx | 0 .../components/{ => old}/AnchorColorInput.tsx | 0 .../src/components/{ => old}/ColorScale.tsx | 0 .../{ => old}/ColorScalesHeader.tsx | 0 .../{ => old}/DisplayOptionsPanel.tsx | 0 .../{ => old}/GaussianParametersPanel.tsx | 0 .../src/components/{ => old}/HeaderPanel.tsx | 4 +- .../{ => old}/LightnessValueInputs.tsx | 0 .../components/{ => old}/NameAndControls.tsx | 0 .../{ => old}/QuickActionsPopover.tsx | 0 .../src/components/{ => shared}/Badge.tsx | 0 .../components/{ => shared}/ThemeToggle.tsx | 0 .../themebuilder/ThemeBuilderHeader.tsx | 2 +- .../contrast/BordersOnBackgrounds.tsx | 78 --- .../themebuilder/contrast/ContrastCard.tsx | 2 +- .../contrast/SemanticPairings.tsx | 2 +- .../themebuilder/contrast/SurfacePreview.tsx | 2 +- .../contrast/TextOnEmphasisFills.tsx | 78 --- .../contrast/TextOnInteractiveFills.tsx | 77 --- .../tests/e2e/anchor-color.spec.ts | 2 +- .../tests/e2e/anchor-error-messages.spec.ts | 2 +- .../tests/e2e/change-color.spec.ts | 2 +- .../tests/e2e/general-features.spec.ts | 2 +- .../e2e/step-dropdown-accessibility.spec.ts | 2 +- 35 files changed, 634 insertions(+), 856 deletions(-) create mode 100644 apps/eds-color-palette-generator/src/app/old/page.tsx delete mode 100644 apps/eds-color-palette-generator/src/app/themebuilder/page.tsx rename apps/eds-color-palette-generator/src/components/{ => docs}/BellCurveVisualization.tsx (100%) rename apps/eds-color-palette-generator/src/components/{ => docs}/ChromaDistributionDemo.tsx (100%) rename apps/eds-color-palette-generator/src/components/{ => docs}/ContrastRequirementsTable.tsx (100%) rename apps/eds-color-palette-generator/src/components/{ => old}/AnchorColorInput.tsx (100%) rename apps/eds-color-palette-generator/src/components/{ => old}/ColorScale.tsx (100%) rename apps/eds-color-palette-generator/src/components/{ => old}/ColorScalesHeader.tsx (100%) rename apps/eds-color-palette-generator/src/components/{ => old}/DisplayOptionsPanel.tsx (100%) rename apps/eds-color-palette-generator/src/components/{ => old}/GaussianParametersPanel.tsx (100%) rename apps/eds-color-palette-generator/src/components/{ => old}/HeaderPanel.tsx (97%) rename apps/eds-color-palette-generator/src/components/{ => old}/LightnessValueInputs.tsx (100%) rename apps/eds-color-palette-generator/src/components/{ => old}/NameAndControls.tsx (100%) rename apps/eds-color-palette-generator/src/components/{ => old}/QuickActionsPopover.tsx (100%) rename apps/eds-color-palette-generator/src/components/{ => shared}/Badge.tsx (100%) rename apps/eds-color-palette-generator/src/components/{ => shared}/ThemeToggle.tsx (100%) delete mode 100644 apps/eds-color-palette-generator/src/components/themebuilder/contrast/BordersOnBackgrounds.tsx delete mode 100644 apps/eds-color-palette-generator/src/components/themebuilder/contrast/TextOnEmphasisFills.tsx delete mode 100644 apps/eds-color-palette-generator/src/components/themebuilder/contrast/TextOnInteractiveFills.tsx 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/src/app/about/page.tsx b/apps/eds-color-palette-generator/src/app/about/page.tsx index db11332dfb..e546757dcb 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,8 @@ 'use client' -import { BellCurveVisualization } from '@/components/BellCurveVisualization' -import { ChromaDistributionDemo } from '@/components/ChromaDistributionDemo' -import { ContrastRequirementsTable } from '@/components/ContrastRequirementsTable' +import { BellCurveVisualization } from '@/components/docs/BellCurveVisualization' +import { ChromaDistributionDemo } from '@/components/docs/ChromaDistributionDemo' +import { ContrastRequirementsTable } from '@/components/docs/ContrastRequirementsTable' import { ArrowLeft } from 'lucide-react' import Link from 'next/link' @@ -12,7 +12,7 @@ export default function AboutPage() {
diff --git a/apps/eds-color-palette-generator/src/app/contrast/page.tsx b/apps/eds-color-palette-generator/src/app/contrast/page.tsx index c09b067396..e3287a76fd 100644 --- a/apps/eds-color-palette-generator/src/app/contrast/page.tsx +++ b/apps/eds-color-palette-generator/src/app/contrast/page.tsx @@ -11,7 +11,7 @@ import { calcContrast, } from '@/utils/palette' import type { ContrastResult } from '@/utils/palette' -import { Badge } from '@/components/Badge' +import { Badge } from '@/components/shared/Badge' import { CATEGORY_GROUPS } from '@/config/categories' function getLightness(hex: string): number { 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..2546d5db50 --- /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' +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..5cca9d5d71 100644 --- a/apps/eds-color-palette-generator/src/app/page.tsx +++ b/apps/eds-color-palette-generator/src/app/page.tsx @@ -1,464 +1,151 @@ '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' +import { deserializeState, updateURL } from '@/utils/urlState' +import type { PaletteInput } from '@/utils/urlState' +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) - ) - }, [ - 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, + } else { + // Simple hex color + const hex = p.baseColor.startsWith('#') + ? p.baseColor + : `#${p.baseColor}` + steps = generateColorScale(hex, lightnessValues, mean, stdDev, 'HEX') + } + return { name: p.name, steps } }) - }, [currentScalesOnly, contrastMethod, showContrast, isMounted]) - - return ( -
-
- -
- -
- {/* Config Panel */} - {showConfigPanel && ( -
-
-
- + }, [palettes, lightnessValues, mean, stdDev]) - {showGaussianParameters && ( -
- -
- )} -
-
-
- )} + const handlePalettesChange = useCallback((next: PaletteInput[]) => { + setPalettes(next) + }, []) - {/* Full-width sticky subheader after display options */} -
-
- - {showLightnessInputs && ( - - )} + return ( +
+ + +
+ {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/themebuilder/page.tsx b/apps/eds-color-palette-generator/src/app/themebuilder/page.tsx deleted file mode 100644 index efa2d43772..0000000000 --- a/apps/eds-color-palette-generator/src/app/themebuilder/page.tsx +++ /dev/null @@ -1,154 +0,0 @@ -'use client' - -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 { - lightnessValuesInLightMode, - darknessValuesInDarkMode, -} from '@/config/config' -import { paletteConfig } from '@/config/palette' -import { deserializeState, updateURL } from '@/utils/urlState' -import type { PaletteInput } from '@/utils/urlState' -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', - } - }, -) - -function ThemeBuilderContent() { - const searchParams = useSearchParams() - const { colorScheme } = useColorScheme() - - // Initialize state from URL or defaults - const initialState = useMemo(() => { - if (!searchParams) return {} - return deserializeState(searchParams) - }, [searchParams]) - - const [palettes, setPalettes] = useState( - initialState.palettes ?? DEFAULT_PALETTES, - ) - const [activeTab, setActiveTab] = useState( - initialState.activeTab ?? 'fargesystem', - ) - const [contrastPaletteIndex, setContrastPaletteIndex] = useState(0) - - // Sync state to URL - useEffect(() => { - updateURL({ - palettes, - activeTab, - mode: colorScheme, - }) - }, [palettes, activeTab, colorScheme]) - - // Reset contrast index if palette list shrinks - useEffect(() => { - if (contrastPaletteIndex >= palettes.length) { - setContrastPaletteIndex(0) - } - }, [palettes.length, 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', - ) - } else { - // Simple hex color - const hex = p.baseColor.startsWith('#') - ? p.baseColor - : `#${p.baseColor}` - steps = generateColorScale(hex, lightnessValues, mean, stdDev, 'HEX') - } - return { name: p.name, steps } - }) - }, [palettes, lightnessValues, mean, stdDev]) - - const handlePalettesChange = useCallback((next: PaletteInput[]) => { - setPalettes(next) - }, []) - - return ( -
- - -
- {activeTab === 'fargesystem' ? ( -
- - - - - -
- ) : activeTab === 'kontrast' ? ( - - ) : ( - - )} -
-
- ) -} - -export default function ThemeBuilderPage() { - return ( - - - - ) -} 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/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 97% rename from apps/eds-color-palette-generator/src/components/HeaderPanel.tsx rename to apps/eds-color-palette-generator/src/components/old/HeaderPanel.tsx index 17dcc8cb33..d6a5a1575d 100644 --- a/apps/eds-color-palette-generator/src/components/HeaderPanel.tsx +++ b/apps/eds-color-palette-generator/src/components/old/HeaderPanel.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { ThemeToggle } from './ThemeToggle' +import { ThemeToggle } from '@/components/shared/ThemeToggle' import { Settings, Info, BarChart3, Palette, Layers, Paintbrush } from 'lucide-react' import Link from 'next/link' @@ -23,7 +23,7 @@ export const HeaderPanel = ({
diff --git a/apps/eds-color-palette-generator/src/components/LightnessValueInputs.tsx b/apps/eds-color-palette-generator/src/components/old/LightnessValueInputs.tsx similarity index 100% rename from apps/eds-color-palette-generator/src/components/LightnessValueInputs.tsx rename to apps/eds-color-palette-generator/src/components/old/LightnessValueInputs.tsx diff --git a/apps/eds-color-palette-generator/src/components/NameAndControls.tsx b/apps/eds-color-palette-generator/src/components/old/NameAndControls.tsx similarity index 100% rename from apps/eds-color-palette-generator/src/components/NameAndControls.tsx rename to apps/eds-color-palette-generator/src/components/old/NameAndControls.tsx diff --git a/apps/eds-color-palette-generator/src/components/QuickActionsPopover.tsx b/apps/eds-color-palette-generator/src/components/old/QuickActionsPopover.tsx similarity index 100% rename from apps/eds-color-palette-generator/src/components/QuickActionsPopover.tsx rename to apps/eds-color-palette-generator/src/components/old/QuickActionsPopover.tsx diff --git a/apps/eds-color-palette-generator/src/components/Badge.tsx b/apps/eds-color-palette-generator/src/components/shared/Badge.tsx similarity index 100% rename from apps/eds-color-palette-generator/src/components/Badge.tsx rename to apps/eds-color-palette-generator/src/components/shared/Badge.tsx 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/ThemeBuilderHeader.tsx b/apps/eds-color-palette-generator/src/components/themebuilder/ThemeBuilderHeader.tsx index 82a0a89520..409b663db4 100644 --- a/apps/eds-color-palette-generator/src/components/themebuilder/ThemeBuilderHeader.tsx +++ b/apps/eds-color-palette-generator/src/components/themebuilder/ThemeBuilderHeader.tsx @@ -1,7 +1,7 @@ 'use client' import { useState, useCallback } from 'react' -import { ThemeToggle } from '@/components/ThemeToggle' +import { ThemeToggle } from '@/components/shared/ThemeToggle' import { Link2, Check } from 'lucide-react' type Tab = 'fargesystem' | 'eksempler' | 'kontrast' diff --git a/apps/eds-color-palette-generator/src/components/themebuilder/contrast/BordersOnBackgrounds.tsx b/apps/eds-color-palette-generator/src/components/themebuilder/contrast/BordersOnBackgrounds.tsx deleted file mode 100644 index e494a2b984..0000000000 --- a/apps/eds-color-palette-generator/src/components/themebuilder/contrast/BordersOnBackgrounds.tsx +++ /dev/null @@ -1,78 +0,0 @@ -'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: 5, bg: 0 }, - { fg: 7, bg: 1 }, -] - -export function BordersOnBackgrounds({ - 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)), - ) - } - - return ( -
-
-

- Borders on backgrounds -

-

- Border visibility against canvas and surface backgrounds -

-
- - {pairings.map(({ fg, bg }, idx) => ( -
-
- update(idx, 'fg', v)} - recommended={[1, 3, 4, 6, 7]} - /> - update(idx, 'bg', v)} - recommended={[0, 1, 2, 5, 14]} - /> -
- -
- {palettes.map((p) => ( - - ))} -
-
- ))} -
- ) -} 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 index 81ca7aefe8..2699c835b1 100644 --- a/apps/eds-color-palette-generator/src/components/themebuilder/contrast/ContrastCard.tsx +++ b/apps/eds-color-palette-generator/src/components/themebuilder/contrast/ContrastCard.tsx @@ -2,7 +2,7 @@ import { useMemo } from 'react' import { calcContrast, getApcaFontBreakdown } from '@/utils/palette' -import { Badge } from '@/components/Badge' +import { Badge } from '@/components/shared/Badge' type ContrastCardProps = { fgHex: string 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 index 95b553c610..9f7d54a185 100644 --- a/apps/eds-color-palette-generator/src/components/themebuilder/contrast/SemanticPairings.tsx +++ b/apps/eds-color-palette-generator/src/components/themebuilder/contrast/SemanticPairings.tsx @@ -2,7 +2,7 @@ import { useMemo } from 'react' import { calcContrast, getApcaFontBreakdown } from '@/utils/palette' -import { Badge } from '@/components/Badge' +import { Badge } from '@/components/shared/Badge' type Palette = { name: string; steps: string[] } 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 index b6e53cc703..861e589f41 100644 --- a/apps/eds-color-palette-generator/src/components/themebuilder/contrast/SurfacePreview.tsx +++ b/apps/eds-color-palette-generator/src/components/themebuilder/contrast/SurfacePreview.tsx @@ -2,7 +2,7 @@ import { useState, useMemo } from 'react' import { calcContrast } from '@/utils/palette' -import { Badge } from '@/components/Badge' +import { Badge } from '@/components/shared/Badge' import { StepSelect } from './StepSelect' type Palette = { name: string; steps: string[] } diff --git a/apps/eds-color-palette-generator/src/components/themebuilder/contrast/TextOnEmphasisFills.tsx b/apps/eds-color-palette-generator/src/components/themebuilder/contrast/TextOnEmphasisFills.tsx deleted file mode 100644 index 8e3e2d23bb..0000000000 --- a/apps/eds-color-palette-generator/src/components/themebuilder/contrast/TextOnEmphasisFills.tsx +++ /dev/null @@ -1,78 +0,0 @@ -'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: 14, bg: 8 }, - { fg: 13, bg: 8 }, -] - -export function TextOnEmphasisFills({ - 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)), - ) - } - - return ( -
-
-

- Text on emphasis fills -

-

- Inverted text readability on emphasis backgrounds (default, hover, - active) -

-
- - {pairings.map(({ fg, bg }, idx) => ( -
-
- update(idx, 'fg', v)} - recommended={[0, 13, 14]} - /> - update(idx, 'bg', v)} - recommended={[8, 9, 10, 11, 12]} - /> -
- -
- {palettes.map((p) => ( - - ))} -
-
- ))} -
- ) -} diff --git a/apps/eds-color-palette-generator/src/components/themebuilder/contrast/TextOnInteractiveFills.tsx b/apps/eds-color-palette-generator/src/components/themebuilder/contrast/TextOnInteractiveFills.tsx deleted file mode 100644 index d0585a13dd..0000000000 --- a/apps/eds-color-palette-generator/src/components/themebuilder/contrast/TextOnInteractiveFills.tsx +++ /dev/null @@ -1,77 +0,0 @@ -'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: 12, bg: 2 }, - { fg: 12, bg: 3 }, -] - -export function TextOnInteractiveFills({ - 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)), - ) - } - - return ( -
-
-

- Text on interactive fills -

-

- Text readability on muted fill states (default, hover, active) -

-
- - {pairings.map(({ fg, bg }, idx) => ( -
-
- update(idx, 'fg', v)} - recommended={[6, 7, 11, 12]} - /> - update(idx, 'bg', v)} - recommended={[0, 1, 2, 5, 14]} - /> -
- -
- {palettes.map((p) => ( - - ))} -
-
- ))} -
- ) -} 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/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() }) From a7c9787625c059a994b2baadd8e9350359e4188c Mon Sep 17 00:00:00 2001 From: Alex Louviers Date: Tue, 21 Jul 2026 13:24:25 +0200 Subject: [PATCH 4/4] feat: add dataviz tooling and reorganise palette generator code Reorganisation (behaviour-preserving): - Rename colliding config files (palette.ts -> palette-config.ts, dataviz.ts -> dataviz-defaults.ts) and update all importers. - Break up the oversized route files into feature components: about 866->55, example 1012->117, contrast 795->151, palette 485->199. - Extract shared logic to utils (contrastPageData, customPalettesStore, generatorImport) and add per-feature barrel exports. Also includes in-progress dataviz / colour-vision-deficiency tooling (new /dataviz route, CVD simulation, semantic colours). --- .../plan/fix-lint-failures.prompt.md | 136 +++ .../plan/light-dark-parity-comparison.md | 363 +++++++ .../plan/option2-ramp-mockup.png | Bin 0 -> 217342 bytes .../plan/organise-generator-code.prompt.md | 126 +++ .../restructure-palette-generator.prompt.md | 156 +++ .../generate-palette-contrast-report.ts | 2 +- .../src/app/about/page.tsx | 855 +--------------- .../src/app/contrast/page.tsx | 680 +------------ .../src/app/dataviz/page.tsx | 45 + .../src/app/example/page.tsx | 925 +----------------- .../src/app/globals.css | 11 + .../src/app/old/page.tsx | 2 +- .../src/app/page.tsx | 67 +- .../src/app/palette/page.tsx | 324 +----- .../contrast/CombinedPatternView.tsx | 196 ++++ .../src/components/contrast/ContrastCell.tsx | 100 ++ .../src/components/contrast/CopyButton.tsx | 36 + .../components/contrast/SinglePaletteView.tsx | 155 +++ .../src/components/contrast/index.ts | 4 + .../docs/about/AboutBestPractices.tsx | 52 + .../docs/about/AboutChromaDistribution.tsx | 17 + .../docs/about/AboutConfiguration.tsx | 72 ++ .../docs/about/AboutContrastRequirements.tsx | 79 ++ .../docs/about/AboutGaussianBellCurve.tsx | 15 + .../components/docs/about/AboutHowItWorks.tsx | 375 +++++++ .../components/docs/about/AboutLearnMore.tsx | 84 ++ .../docs/about/AboutOklchColorSpace.tsx | 44 + .../components/docs/about/AboutOverview.tsx | 22 + .../docs/about/AboutTableOfContents.tsx | 34 + .../src/components/docs/about/index.ts | 10 + .../components/example/InteractivePicker.tsx | 136 +++ .../src/components/example/PairingCard.tsx | 140 +++ .../components/example/PredefinedGroups.tsx | 48 + .../src/components/example/SurfacePreview.tsx | 370 +++++++ .../example/SurfacePreviewSection.tsx | 140 +++ .../src/components/example/exampleGroups.ts | 48 + .../src/components/example/index.ts | 3 + .../src/components/palette/PaletteCard.tsx | 241 +++++ .../src/components/palette/index.ts | 1 + .../src/components/shared/Badge.tsx | 16 +- .../src/components/shared/CVDFilter.tsx | 25 + .../components/themebuilder/ButtonPreview.tsx | 147 +++ .../components/themebuilder/CardPreview.tsx | 180 ++-- .../themebuilder/ComponentPreviewPanel.tsx | 29 +- .../components/themebuilder/ContrastTable.tsx | 160 ++- .../themebuilder/DataTablePreview.tsx | 74 +- .../themebuilder/LoginFormPreview.tsx | 66 +- .../themebuilder/ThemeBuilderHeader.tsx | 25 +- .../components/themebuilder/TokenMatrix.tsx | 104 +- .../themebuilder/contrast/DataColorChart.tsx | 337 +++++-- .../themebuilder/dataviz/DataVizPanel.tsx | 273 ++++++ .../src/config/categories.ts | 32 + .../src/config/config.ts | 12 +- .../src/config/dataviz-defaults.ts | 69 ++ .../src/config/dataviz-types.ts | 71 ++ .../config/{palette.ts => palette-config.ts} | 16 +- .../src/config/semanticColors.ts | 145 +++ .../src/context/ColorSchemeContext.tsx | 34 +- .../src/utils/color.ts | 25 + .../src/utils/contrastPageData.ts | 193 ++++ .../src/utils/customPalettesStore.ts | 42 + .../src/utils/cvd.ts | 75 ++ .../src/utils/dataviz-a11y.test.ts | 50 + .../src/utils/dataviz-a11y.ts | 139 +++ .../src/utils/dataviz-export.ts | 53 + .../src/utils/dataviz.test.ts | 136 +++ .../src/utils/dataviz.ts | 249 +++++ .../src/utils/generatorImport.ts | 28 + .../tests/e2e/dataviz.spec.ts | 61 ++ pnpm-lock.yaml | 501 ++++++---- 70 files changed, 6187 insertions(+), 3294 deletions(-) create mode 100644 apps/eds-color-palette-generator/plan/fix-lint-failures.prompt.md create mode 100644 apps/eds-color-palette-generator/plan/light-dark-parity-comparison.md create mode 100644 apps/eds-color-palette-generator/plan/option2-ramp-mockup.png create mode 100644 apps/eds-color-palette-generator/plan/organise-generator-code.prompt.md create mode 100644 apps/eds-color-palette-generator/plan/restructure-palette-generator.prompt.md create mode 100644 apps/eds-color-palette-generator/src/app/dataviz/page.tsx create mode 100644 apps/eds-color-palette-generator/src/components/contrast/CombinedPatternView.tsx create mode 100644 apps/eds-color-palette-generator/src/components/contrast/ContrastCell.tsx create mode 100644 apps/eds-color-palette-generator/src/components/contrast/CopyButton.tsx create mode 100644 apps/eds-color-palette-generator/src/components/contrast/SinglePaletteView.tsx create mode 100644 apps/eds-color-palette-generator/src/components/contrast/index.ts create mode 100644 apps/eds-color-palette-generator/src/components/docs/about/AboutBestPractices.tsx create mode 100644 apps/eds-color-palette-generator/src/components/docs/about/AboutChromaDistribution.tsx create mode 100644 apps/eds-color-palette-generator/src/components/docs/about/AboutConfiguration.tsx create mode 100644 apps/eds-color-palette-generator/src/components/docs/about/AboutContrastRequirements.tsx create mode 100644 apps/eds-color-palette-generator/src/components/docs/about/AboutGaussianBellCurve.tsx create mode 100644 apps/eds-color-palette-generator/src/components/docs/about/AboutHowItWorks.tsx create mode 100644 apps/eds-color-palette-generator/src/components/docs/about/AboutLearnMore.tsx create mode 100644 apps/eds-color-palette-generator/src/components/docs/about/AboutOklchColorSpace.tsx create mode 100644 apps/eds-color-palette-generator/src/components/docs/about/AboutOverview.tsx create mode 100644 apps/eds-color-palette-generator/src/components/docs/about/AboutTableOfContents.tsx create mode 100644 apps/eds-color-palette-generator/src/components/docs/about/index.ts create mode 100644 apps/eds-color-palette-generator/src/components/example/InteractivePicker.tsx create mode 100644 apps/eds-color-palette-generator/src/components/example/PairingCard.tsx create mode 100644 apps/eds-color-palette-generator/src/components/example/PredefinedGroups.tsx create mode 100644 apps/eds-color-palette-generator/src/components/example/SurfacePreview.tsx create mode 100644 apps/eds-color-palette-generator/src/components/example/SurfacePreviewSection.tsx create mode 100644 apps/eds-color-palette-generator/src/components/example/exampleGroups.ts create mode 100644 apps/eds-color-palette-generator/src/components/example/index.ts create mode 100644 apps/eds-color-palette-generator/src/components/palette/PaletteCard.tsx create mode 100644 apps/eds-color-palette-generator/src/components/palette/index.ts create mode 100644 apps/eds-color-palette-generator/src/components/shared/CVDFilter.tsx create mode 100644 apps/eds-color-palette-generator/src/components/themebuilder/ButtonPreview.tsx create mode 100644 apps/eds-color-palette-generator/src/components/themebuilder/dataviz/DataVizPanel.tsx create mode 100644 apps/eds-color-palette-generator/src/config/dataviz-defaults.ts create mode 100644 apps/eds-color-palette-generator/src/config/dataviz-types.ts rename apps/eds-color-palette-generator/src/config/{palette.ts => palette-config.ts} (53%) create mode 100644 apps/eds-color-palette-generator/src/config/semanticColors.ts create mode 100644 apps/eds-color-palette-generator/src/utils/contrastPageData.ts create mode 100644 apps/eds-color-palette-generator/src/utils/customPalettesStore.ts create mode 100644 apps/eds-color-palette-generator/src/utils/cvd.ts create mode 100644 apps/eds-color-palette-generator/src/utils/dataviz-a11y.test.ts create mode 100644 apps/eds-color-palette-generator/src/utils/dataviz-a11y.ts create mode 100644 apps/eds-color-palette-generator/src/utils/dataviz-export.ts create mode 100644 apps/eds-color-palette-generator/src/utils/dataviz.test.ts create mode 100644 apps/eds-color-palette-generator/src/utils/dataviz.ts create mode 100644 apps/eds-color-palette-generator/src/utils/generatorImport.ts create mode 100644 apps/eds-color-palette-generator/tests/e2e/dataviz.spec.ts 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 0000000000000000000000000000000000000000..15e64440ca7bc97cccbe0282f1baab2f617baf06 GIT binary patch literal 217342 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 RT>y?iNs7t6DHk#D|9_9<;)4JH literal 0 HcmV?d00001 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 e546757dcb..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/docs/BellCurveVisualization' -import { ChromaDistributionDemo } from '@/components/docs/ChromaDistributionDemo' -import { ContrastRequirementsTable } from '@/components/docs/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' @@ -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 index e3287a76fd..efb0f8cb5e 100644 --- a/apps/eds-color-palette-generator/src/app/contrast/page.tsx +++ b/apps/eds-color-palette-generator/src/app/contrast/page.tsx @@ -1,339 +1,15 @@ 'use client' -import { useState, useMemo, useCallback } from 'react' -import Color from 'colorjs.io' +import { useState, useMemo } from 'react' import Link from 'next/link' -import { contrast } from '@/utils/color' +import { PALETTES } from '@/utils/palette' import { - STEP_ROLES, - PALETTES, - getApcaFontBreakdown, - calcContrast, -} from '@/utils/palette' -import type { ContrastResult } from '@/utils/palette' -import { Badge } from '@/components/shared/Badge' -import { CATEGORY_GROUPS } from '@/config/categories' - -function getLightness(hex: string): number { - try { - return new Color(hex).to('oklch').l ?? 0 - } catch { - return 0 - } -} - -/* ------------------------------------------------------------------ */ -/* Types */ -/* ------------------------------------------------------------------ */ - -type TextChoice = ContrastResult & { color: string; label: string } - -type StepData = { - step: number - role: string - hex: string - recommended: TextChoice - paletteText: TextChoice -} - -type SortOrder = 'semantic' | 'gradient' - -type ViewMode = 'semantic' | 'gradient' | 'combined' - -/* ------------------------------------------------------------------ */ -/* Combined view — real cross-palette interaction patterns */ -/* ------------------------------------------------------------------ */ - -type Pairing = { - state: string - type: 'text' | 'border' | 'fill' - fg: { label: string; hex: string } - bg: { label: string; hex: string } - contrast: ContrastResult -} - -type PatternGroup = { - title: string - description: string - pairings: Pairing[] -} - -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 */ -/* ------------------------------------------------------------------ */ - -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})`, - } -} - -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, - } - }) -} - -/* ------------------------------------------------------------------ */ -/* Sub-components */ -/* ------------------------------------------------------------------ */ - -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}` : ''} - - ))} -
-
-
- ) -} - -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 ( - - ) -} - -/* ------------------------------------------------------------------ */ -/* Page */ -/* ------------------------------------------------------------------ */ + buildPatternGroups, + buildStepData, + getLightness, + type ViewMode, +} from '@/utils/contrastPageData' +import { CombinedPatternView, SinglePaletteView } from '@/components/contrast' export default function ContrastPage() { const [activePalette, setActivePalette] = useState(0) @@ -362,10 +38,7 @@ export default function ContrastPage() { }, [viewMode]) return ( -
+
{/* ---- Header ---- */}
- {mode === 'semantic' ? 'Curve' : mode === 'gradient' ? 'Gradient' : 'Combined'} + {mode === 'semantic' + ? 'Curve' + : mode === 'gradient' + ? 'Gradient' + : 'Combined'} ))}
@@ -460,334 +138,12 @@ export default function ContrastPage() {
- {/* ============================================================ */} - {/* COMBINED VIEW */} - {/* ============================================================ */} {viewMode === 'combined' && ( -
- {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}` : ''} - - ))} -
-
-
- ) - })} -
-
- ))} -
+ )} - {/* ============================================================ */} - {/* SINGLE PALETTE VIEW (Curve / Gradient) */} - {/* ============================================================ */} {viewMode !== 'combined' && ( - <> - {/* ---- Category legend + palette strip (shared 15-col grid) ---- */} -
- {viewMode === '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/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 index 011bb950a1..17204a5bde 100644 --- a/apps/eds-color-palette-generator/src/app/example/page.tsx +++ b/apps/eds-color-palette-generator/src/app/example/page.tsx @@ -1,598 +1,21 @@ 'use client' -import { useState, useMemo, useCallback, useSyncExternalStore } from 'react' +import { useMemo, useState } from 'react' import Link from 'next/link' +import { PALETTES } from '@/utils/palette' import { - STEP_ROLES, - PALETTES, - getApcaFontBreakdown, - calcContrast, - roleIndex, - getSimulationPalettes, -} from '@/utils/palette' -import type { StepRole, TokenPalette } from '@/utils/palette' -import { Badge } from '@/components/shared/Badge' - -/* ------------------------------------------------------------------ */ -/* Pre-defined example groups */ -/* ------------------------------------------------------------------ */ - -type ExamplePairing = { - fg: StepRole - bg: StepRole - type?: 'text' | 'border' -} - -type ExampleGroup = { - title: string - description: string - pairings: ExamplePairing[] -} - -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' }, - ], - }, -] - -/* ------------------------------------------------------------------ */ -/* Sub-components */ -/* ------------------------------------------------------------------ */ - -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}` : ''} - - ))} -
-
-
- ) -} - -/* ------------------------------------------------------------------ */ -/* Page */ -/* ------------------------------------------------------------------ */ - -/* ------------------------------------------------------------------ */ -/* Surface layer roles for the layout preview */ -/* ------------------------------------------------------------------ */ - -const SURFACE_ROLES = STEP_ROLES.filter((r) => r.startsWith('bg-')) -const BORDER_ROLES = STEP_ROLES.filter((r) => r.startsWith('border-')) - -type SurfaceConfig = { - page: StepRole - panel: StepRole - cardRow: StepRole - card: StepRole - border: StepRole - text: StepRole -} - -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. */ -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} - - -
- ) -} - -function SurfaceSelect({ - label, - value, - onChange, - options, - recommended, -}: { - label: string - value: StepRole - onChange: (v: StepRole) => void - options: readonly StepRole[] - recommended?: ReadonlySet -}) { - return ( - - ) -} - -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 - - - -
-
- ))} -
-
- ) -} - -/* ------------------------------------------------------------------ */ -/* 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; the Refresh button 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 getCustomPalettesSnapshot(): 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 getCustomPalettesServerSnapshot(): TokenPalette[] { - return EMPTY_PALETTES -} - -function subscribeCustomPalettes(callback: () => void): () => void { - window.addEventListener(SIMULATION_REFRESH_EVENT, callback) - return () => window.removeEventListener(SIMULATION_REFRESH_EVENT, callback) -} + refreshCustomPalettes, + useCustomPalettes, +} from '@/utils/customPalettesStore' +import { + InteractivePicker, + PredefinedGroups, + SurfacePreviewSection, +} from '@/components/example' export default function ExamplePage() { const [activePalette, setActivePalette] = useState(0) - const [fgRole, setFgRole] = useState('12 · fg/strong') - const [bgRole, setBgRole] = useState('1 · bg/canvas') - const [surfaceConfig, setSurfaceConfig] = - useState(DEFAULT_SURFACE) - const customPalettes = useSyncExternalStore( - subscribeCustomPalettes, - getCustomPalettesSnapshot, - getCustomPalettesServerSnapshot, - ) - - const refreshCustomPalettes = useCallback(() => { - window.dispatchEvent(new Event(SIMULATION_REFRESH_EVENT)) - }, []) + const customPalettes = useCustomPalettes() const allPalettes = useMemo( () => [...PALETTES, ...customPalettes], @@ -601,18 +24,8 @@ export default function ExamplePage() { const palette = allPalettes[activePalette] ?? allPalettes[0] - const fgRoles = STEP_ROLES.filter((r) => - r.startsWith('text-') || r.startsWith('border-'), - ) - const bgRoles = STEP_ROLES.filter((r) => - r.startsWith('bg-'), - ) - return ( -
+
{/* ---- Header ---- */}
- {/* ============================================================ */} - {/* PRE-DEFINED COMBINATION GROUPS */} - {/* ============================================================ */} -
- {EXAMPLE_GROUPS.map((group) => ( -
-

- {group.title} -

-

- {group.description} -

- -
- {group.pairings.map((p, i) => ( - - ))} -
-
- ))} -
- - {/* ============================================================ */} - {/* SURFACE PREVIEW */} - {/* ============================================================ */} -
-

- 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} -
- -
- ))} -
-
-
- - {/* ============================================================ */} - {/* INTERACTIVE PICKER */} - {/* ============================================================ */} -
-

- 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/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/old/page.tsx b/apps/eds-color-palette-generator/src/app/old/page.tsx index 2546d5db50..284b268c8c 100644 --- a/apps/eds-color-palette-generator/src/app/old/page.tsx +++ b/apps/eds-color-palette-generator/src/app/old/page.tsx @@ -19,7 +19,7 @@ import { } from '@/types' import { arraysEqual, colorsEqual } from '@/utils/compare' import { localStorageUtils } from '@/utils/localStorage' -import { paletteConfig as config } from '@/config/palette' +import { paletteConfig as config } from '@/config/palette-config' import { lightnessValuesInLightMode, darknessValuesInDarkMode, diff --git a/apps/eds-color-palette-generator/src/app/page.tsx b/apps/eds-color-palette-generator/src/app/page.tsx index 5cca9d5d71..27208a1f7c 100644 --- a/apps/eds-color-palette-generator/src/app/page.tsx +++ b/apps/eds-color-palette-generator/src/app/page.tsx @@ -8,9 +8,12 @@ import { lightnessValuesInLightMode, darknessValuesInDarkMode, } from '@/config/config' -import { paletteConfig } from '@/config/palette' +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' @@ -98,23 +101,75 @@ function ThemeBuilderContent() { 'HEX', ) } else { - // Simple hex color + // 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 = generateColorScale(hex, lightnessValues, mean, stdDev, 'HEX') + 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]) + }, [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}`, + }, + ) + downloadConfiguration( + lightnessValuesInLightMode, + darknessValuesInDarkMode, + paletteConfig.meanLight, + paletteConfig.stdDevLight, + paletteConfig.meanDark, + paletteConfig.stdDevDark, + colors, + ) + }, [palettes]) + + // 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 ( -
- +
+
{activeTab === 'fargesystem' ? ( diff --git a/apps/eds-color-palette-generator/src/app/palette/page.tsx b/apps/eds-color-palette-generator/src/app/palette/page.tsx index bb27839804..27e7776242 100644 --- a/apps/eds-color-palette-generator/src/app/palette/page.tsx +++ b/apps/eds-color-palette-generator/src/app/palette/page.tsx @@ -1,75 +1,14 @@ 'use client' import { useState, useEffect, useCallback } from 'react' -import Color from 'colorjs.io' import Link from 'next/link' -import { contrast, generateColorScale } from '@/utils/color' -import { - STEP_ROLES, - setSimulationPalettes, -} from '@/utils/palette' -import type { TokenPalette } from '@/utils/palette' -import { localStorageUtils } from '@/utils/localStorage' -import { paletteConfig } from '@/config/palette' -import { getLightnessValues } from '@/config/helpers' -import { PALETTE_STEPS } from '@/config/config' -import type { ColorDefinition } from '@/types' - -/* ------------------------------------------------------------------ */ -/* Constants */ -/* ------------------------------------------------------------------ */ - -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 - -type ViewMode = 'curve' | 'gradient' - -/* ------------------------------------------------------------------ */ -/* Helpers */ -/* ------------------------------------------------------------------ */ - -function getLightness(hex: string): number { - try { - return new Color(hex).to('oklch').l ?? 0 - } catch { - return 0 - } -} - -function labelColor(hex: string): string { - try { - const wcag = parseFloat( - String( - contrast({ - foreground: '#ffffff', - background: hex, - algorithm: 'WCAG21', - silent: true, - }), - ), - ) - return wcag >= 3 ? '#fff' : '#000' - } catch { - return '#000' - } -} - -function isValidHex(v: string): boolean { - return /^#[0-9a-fA-F]{6}$/.test(v) -} - -/* ------------------------------------------------------------------ */ -/* Page */ -/* ------------------------------------------------------------------ */ +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 [viewMode, setViewMode] = useState('curve') const [hasAutoImported, setHasAutoImported] = useState(false) /* ---- Auto-import primitives from generator on first load ---- */ @@ -77,27 +16,7 @@ export default function PalettePage() { 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) - - 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), - ) - - const generated: TokenPalette[] = 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 } - }) - - setPalettes(generated) + setPalettes(generatePalettesFromGenerator()) }, [hasAutoImported]) /* ---- Auto-save to localStorage ---- */ @@ -143,44 +62,11 @@ export default function PalettePage() { /* ---- Re-import from generator (replaces all) ---- */ const reimportFromGenerator = useCallback(() => { - 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), - ) - - const newPalettes: TokenPalette[] = 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 } - }) - - setPalettes(newPalettes) + setPalettes(generatePalettesFromGenerator()) }, []) - /* ---- Sort for gradient view ---- */ - const getSortedSteps = useCallback( - (steps: string[]) => { - if (viewMode === 'curve') return steps.map((hex, i) => ({ hex, i })) - return steps - .map((hex, i) => ({ hex, i })) - .sort((a, b) => getLightness(b.hex) - getLightness(a.hex)) - }, - [viewMode], - ) - return ( -
+
{/* ---- Header ---- */}
- {palettes.map((pal, palIdx) => { - const sorted = getSortedSteps(pal.steps) - - return ( -
- {/* Palette header */} -
- updatePaletteName(palIdx, 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 - updateStep(palIdx, 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, - }} - /> -
-
- ))} -
-
- ) - })} + {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/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/palette/PaletteCard.tsx b/apps/eds-color-palette-generator/src/components/palette/PaletteCard.tsx new file mode 100644 index 0000000000..63ca5a52c6 --- /dev/null +++ b/apps/eds-color-palette-generator/src/components/palette/PaletteCard.tsx @@ -0,0 +1,241 @@ +'use client' + +import Color from 'colorjs.io' +import { contrast } from '@/utils/color' +import { STEP_ROLES, type TokenPalette } from '@/utils/palette' + +export type PaletteViewMode = 'curve' | 'gradient' + +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 + +function getLightness(hex: string): number { + try { + return new Color(hex).to('oklch').l ?? 0 + } catch { + return 0 + } +} + +function labelColor(hex: string): string { + try { + const wcag = parseFloat( + String( + contrast({ + foreground: '#ffffff', + background: hex, + algorithm: 'WCAG21', + silent: true, + }), + ), + ) + return wcag >= 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 index 30fb93e2bb..3d26a9688a 100644 --- a/apps/eds-color-palette-generator/src/components/shared/Badge.tsx +++ b/apps/eds-color-palette-generator/src/components/shared/Badge.tsx @@ -7,13 +7,19 @@ type BadgeProps = { } export function Badge({ pass, label, variant = 'pass-fail' }: BadgeProps) { - const bgColor = pass ? '#dcfce7' : '#fee2e2' - const textColor = pass ? '#166534' : '#991b1b' + // 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 blue for informational badges (DECO, AA18) + // Level variant uses info (blue) for informational badges (DECO, AA18) const isInfo = variant === 'level' && pass - const finalBg = isInfo ? '#dbeafe' : bgColor - const finalColor = isInfo ? '#1e40af' : textColor + const finalBg = isInfo ? 'var(--eds-color-bg-info-fill-muted-default)' : bgColor + const finalColor = isInfo ? 'var(--eds-color-text-info-subtle)' : textColor return ( 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/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 index 6c918446b4..c491a6640d 100644 --- a/apps/eds-color-palette-generator/src/components/themebuilder/CardPreview.tsx +++ b/apps/eds-color-palette-generator/src/components/themebuilder/CardPreview.tsx @@ -1,45 +1,35 @@ -type GeneratedPalette = { - name: string - steps: string[] -} +'use client' + +import type { SemanticColors } from '@/config/semanticColors' type CardPreviewProps = { - neutral: string[] - accent: string[] - dataColors: GeneratedPalette[] + colors: SemanticColors } /** - * Figma token mapping: - * neutral[14] card fill → background/container/card/default (Gray/15 = white) - * neutral[3] border-default → card stroke (Gray/4) - * neutral[8] text-subtle → body text (Gray/9) - * neutral[11] text-primary → title (Gray/12) - * - * accent[10] bg-fill-emphasis → accent bar (MG/11) - * accent[12] text-strong → link text (MG/13) - * - * dataColor[2] bg-fill-muted → tag background - * dataColor[3] border → tag border - * dataColor[11] text → tag text + * 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({ - neutral, - accent, - dataColors, -}: CardPreviewProps) { +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.', @@ -54,89 +44,81 @@ export function CardPreview({ gap: '16px', }} > - {cards.map((card, i) => { - const tagPalette = dataColors.length > 0 - ? dataColors[i % dataColors.length] - : null + {cards.map((card) => ( +
+ {/* Accent bar */} +
- return ( -
+ {/* Tag — concept muted fill */} + - {/* Accent bar */} -
+ {card.tag} + -
- {/* Tag — data color muted fill */} - - {card.tag} - - - {/* Title */} -
- {card.title} -
+ {/* Title */} +
+ {card.title} +
- {/* Body */} -
- {card.body} -
+ {/* Body */} +
+ {card.body} +
- {/* Link — accent text */} -
- Read more -
-
+ {/* 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 index 3eabaaac81..5fa83c9430 100644 --- a/apps/eds-color-palette-generator/src/components/themebuilder/ComponentPreviewPanel.tsx +++ b/apps/eds-color-palette-generator/src/components/themebuilder/ComponentPreviewPanel.tsx @@ -4,6 +4,9 @@ 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 @@ -36,6 +39,10 @@ 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 @@ -47,8 +54,6 @@ export function ComponentPreviewPanel({ .map((p, i) => ({ ...p, idx: i })) .filter((_, i) => i !== neutralIdx) - const accent = palettes[accentIdx]?.steps ?? neutral - // All palettes except Gray and the selected accent are data colors const dataColors = palettes.filter( (_, i) => i !== neutralIdx && i !== accentIdx, @@ -119,30 +124,28 @@ export function ComponentPreviewPanel({
+ {/* 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 index a0ba0b34b4..57b41a76f8 100644 --- a/apps/eds-color-palette-generator/src/components/themebuilder/ContrastTable.tsx +++ b/apps/eds-color-palette-generator/src/components/themebuilder/ContrastTable.tsx @@ -16,10 +16,58 @@ type ContrastTableProps = { onActivePaletteChange: (index: number) => void } -// Background column indices (steps 1-5, 9-11 → 0-indexed) -const BG_INDICES = [0, 1, 2, 3, 4, 8, 9, 10] -// Foreground row indices (border 6-8, text 12-15 → 0-indexed) -const FG_INDICES = [5, 6, 7, 11, 12, 13, 14] +/** + * 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, @@ -38,25 +86,33 @@ export function ContrastTable({ }: 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_INDICES.map((fgIdx) => ({ - fgIdx, - fgRole: STEP_ROLES[fgIdx], - fgHex: palette.steps[fgIdx], - cells: BG_INDICES.map((bgIdx) => { - const result = calcContrast(palette.steps[fgIdx], palette.steps[bgIdx]) - const wcagNum = parseFloat(result.wcag) - const level = getContrastLevel(wcagNum) - return { - bgIdx, - bgRole: STEP_ROLES[bgIdx], - wcag: result.wcag, - level, - } - }), - })) - }, [palette]) + 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 @@ -95,23 +151,49 @@ export function ContrastTable({ }} > + {/* Group headers — background/surface token families */} + + + {BG_GROUPS.map((group) => ( + + {group.group} + + ))} + + {/* State + swatch + step per background column */} - fg \ bg + text \ surface - {BG_INDICES.map((bgIdx) => ( + {BG_COLUMNS.map((col, i) => (
- {bgIdx + 1} + {col.label} + {col.step}
))} @@ -132,7 +215,7 @@ export function ContrastTable({ {grid.map((row) => ( - + - - {row.fgIdx + 1}. {row.fgRole} + + {row.label} + + + {row.crossPalette + ? `${row.fgPaletteName}/${row.step}` + : row.step}
- {row.cells.map((cell) => ( + {row.cells.map((cell, i) => (
(null) - const greenPal = dataColors.find((p) => - p.name.toLowerCase().includes('green'), - ) - const orangePal = dataColors.find((p) => - p.name.toLowerCase().includes('orange'), - ) - const redPal = dataColors.find((p) => p.name.toLowerCase().includes('red')) - const rows = [ - { name: 'Hywind Scotland', status: 'Online', statusColor: greenPal, value: '124 MW', time: '2 min ago' }, - { name: 'Hywind Scotland', status: 'Online', statusColor: greenPal, value: '124 MW', time: '2 min ago' }, - { name: 'Hywind Scotland', status: 'Pending', statusColor: orangePal, value: '124 MW', time: '2 min ago' }, - { name: 'Hywind Scotland', status: 'Offline', statusColor: redPal, value: '124 MW', time: '2 min ago' }, - { name: 'Hywind Scotland', status: 'Online', statusColor: greenPal, 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: '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 ( @@ -58,10 +33,11 @@ export function DataTablePreview({ style={ { overflow: 'hidden', - '--_hover': neutral[1], - '--_pressed': neutral[2], - '--_selected': accent[1], - '--_border': neutral[3], + '--_hover': c['bg-neutral-fill-muted-default'], + '--_pressed': c['bg-neutral-fill-muted-hover'], + '--_selected': c['bg-accent-fill-muted-default'], + '--_selected-bar': c['bg-accent-fill-emphasis-default'], + '--_border': c['border-neutral-subtle'], } as React.CSSProperties } > @@ -69,6 +45,7 @@ export function DataTablePreview({ [data-dt="${uid}"] [data-row] { cursor: pointer; transition: background-color 100ms; + box-shadow: inset 3px 0 0 transparent; } [data-dt="${uid}"] [data-row]:hover { background-color: var(--_hover); @@ -78,6 +55,7 @@ export function DataTablePreview({ } [data-dt="${uid}"] [data-row][data-active] { background-color: var(--_selected); + box-shadow: inset 3px 0 0 var(--_selected-bar); } `} {rows.map((row, i) => ( @@ -90,7 +68,7 @@ export function DataTablePreview({ display: 'flex', alignItems: 'center', padding: '10px 0', - borderTop: `0.5px solid ${neutral[3]}`, + borderTop: `0.5px solid ${c['border-neutral-subtle']}`, }} >
@@ -110,7 +88,7 @@ export function DataTablePreview({ padding: '0 16px', fontSize: '14px', lineHeight: '20px', - color: row.statusColor?.steps[11] ?? neutral[11], + color: row.statusColor, flexShrink: 0, }} > @@ -121,7 +99,7 @@ export function DataTablePreview({ padding: '0 16px', fontSize: '14px', lineHeight: '20px', - color: neutral[11], + color: c['text-neutral-strong'], flexShrink: 0, }} > @@ -133,7 +111,7 @@ export function DataTablePreview({ padding: '0 16px', fontSize: '12px', lineHeight: '16px', - color: neutral[8], + color: c['text-neutral-subtle'], flexShrink: 0, }} > diff --git a/apps/eds-color-palette-generator/src/components/themebuilder/LoginFormPreview.tsx b/apps/eds-color-palette-generator/src/components/themebuilder/LoginFormPreview.tsx index 2c7ecc9a22..9db4c8255e 100644 --- a/apps/eds-color-palette-generator/src/components/themebuilder/LoginFormPreview.tsx +++ b/apps/eds-color-palette-generator/src/components/themebuilder/LoginFormPreview.tsx @@ -1,33 +1,21 @@ 'use client' import { useId } from 'react' +import type { SemanticColors } from '@/config/semanticColors' type LoginFormPreviewProps = { - neutral: string[] - accent: string[] + colors: SemanticColors } /** - * Figma semantic → primitive mapping: - * - * background/container/card/default → neutral[14] (card fill, Gray/15 = white) - * background/surface/default/default = Gray/1 → neutral[0] (input bg) - * border/default = Gray/4 → neutral[3] (input border default) - * border/hover = Gray/7 → neutral[6] (input border hover) - * border/strong = Gray/9 → neutral[8] (input border focus) - * - * border/accent/default = Accent/8 → accent[7] (focus ring) - * - * background/surface/accent/default/default = Accent/11 → accent[10] (button) - * background/surface/accent/default/hover = Accent/12 → accent[11] (button hover) - * background/surface/accent/default/pressed = Accent/13 → accent[12] (button pressed) - * - * text/primary → neutral[11] (labels, Gray/12) - * text/subtle → neutral[8] (placeholder, Gray/9) - * text/link → accent[12] (link text) - * text/on-emphasis → accent[14] (button text, L=1.00 = white) + * 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({ neutral, accent }: LoginFormPreviewProps) { +export function LoginFormPreview({ colors: c }: LoginFormPreviewProps) { const uid = useId().replace(/:/g, '') return ( @@ -35,22 +23,21 @@ export function LoginFormPreview({ neutral, accent }: LoginFormPreviewProps) { data-lf={uid} style={ { - backgroundColor: neutral[14], + backgroundColor: c['bg-neutral-surface'], borderRadius: '8px', padding: '28px', - border: `1px solid ${neutral[3]}`, + border: `1px solid ${c['border-neutral-subtle']}`, maxWidth: '420px', - '--_bg-input': neutral[0], - '--_border': neutral[3], - '--_border-hover': neutral[6], - '--_border-focus': neutral[8], - '--_focus-ring': accent[7], - '--_text': neutral[11], - '--_placeholder': neutral[8], - '--_btn-bg': accent[10], - '--_btn-hover': accent[11], - '--_btn-pressed': accent[12], - '--_btn-text': accent[14], + '--_bg-input': c['bg-neutral-canvas'], + '--_border': c['border-neutral-subtle'], + '--_border-hover': c['border-neutral-medium'], + '--_border-focus': c['border-focus'], + '--_text': c['text-neutral-strong'], + '--_placeholder': c['text-neutral-subtle'], + '--_btn-bg': c['bg-accent-fill-emphasis-default'], + '--_btn-hover': c['bg-accent-fill-emphasis-hover'], + '--_btn-pressed': c['bg-accent-fill-emphasis-active'], + '--_btn-text': c['text-accent-strong-on-emphasis'], } as React.CSSProperties } > @@ -77,7 +64,6 @@ export function LoginFormPreview({ neutral, accent }: LoginFormPreviewProps) { [data-lf="${uid}"] input:focus { border: 2px solid var(--_border-focus); padding: 7px 9px; - box-shadow: 0 0 0 1px var(--_focus-ring); } [data-lf="${uid}"] [data-btn] { cursor: pointer; @@ -111,7 +97,7 @@ export function LoginFormPreview({ neutral, accent }: LoginFormPreviewProps) { style={{ fontSize: '18px', fontWeight: 700, - color: neutral[11], + color: c['text-neutral-strong'], marginBottom: '4px', }} > @@ -120,7 +106,7 @@ export function LoginFormPreview({ neutral, accent }: LoginFormPreviewProps) {
@@ -132,7 +118,7 @@ export function LoginFormPreview({ neutral, accent }: LoginFormPreviewProps) { display: 'block', fontSize: '12px', fontWeight: 500, - color: neutral[11], + color: c['text-neutral-strong'], marginBottom: '4px', }} > @@ -149,7 +135,7 @@ export function LoginFormPreview({ neutral, accent }: LoginFormPreviewProps) { display: 'block', fontSize: '12px', fontWeight: 500, - color: neutral[11], + color: c['text-neutral-strong'], marginBottom: '4px', }} > @@ -165,7 +151,7 @@ export function LoginFormPreview({ neutral, accent }: LoginFormPreviewProps) { data-link="" style={{ fontSize: '12px', - color: accent[12], + color: c['text-link'], marginBottom: '20px', textDecoration: 'underline', }} diff --git a/apps/eds-color-palette-generator/src/components/themebuilder/ThemeBuilderHeader.tsx b/apps/eds-color-palette-generator/src/components/themebuilder/ThemeBuilderHeader.tsx index 409b663db4..cdb5a43d02 100644 --- a/apps/eds-color-palette-generator/src/components/themebuilder/ThemeBuilderHeader.tsx +++ b/apps/eds-color-palette-generator/src/components/themebuilder/ThemeBuilderHeader.tsx @@ -1,19 +1,23 @@ 'use client' import { useState, useCallback } from 'react' +import Link from 'next/link' import { ThemeToggle } from '@/components/shared/ThemeToggle' -import { Link2, Check } from 'lucide-react' +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) @@ -66,8 +70,27 @@ export function ThemeBuilderHeader({
+ + + Data viz + + + + + ) + })} ) } 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 index 32e5cd824c..0879ba3df3 100644 --- a/apps/eds-color-palette-generator/src/components/themebuilder/contrast/DataColorChart.tsx +++ b/apps/eds-color-palette-generator/src/components/themebuilder/contrast/DataColorChart.tsx @@ -3,30 +3,14 @@ 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[] } -type CVDType = 'none' | 'protanopia' | 'deuteranopia' | 'tritanopia' | 'achromatopsia' - -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)' }, -] - -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', -} +/** 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' @@ -44,6 +28,17 @@ const BAR_DATA = [ 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) => @@ -58,6 +53,37 @@ 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 */ /* ---------------------------------------------------------------------- */ @@ -66,9 +92,10 @@ type ChartProps = { colors: { name: string; hex: string }[] bgHex: string textHex: string + showPatterns?: boolean } -function BarChart({ colors, bgHex, textHex }: ChartProps) { +function BarChart({ colors, bgHex, textHex, showPatterns }: ChartProps) { return (
@@ -90,6 +117,7 @@ function BarChart({ colors, bgHex, textHex }: ChartProps) { className="w-full rounded-t" style={{ backgroundColor: pc.hex, + backgroundImage: overlayFor(i, !!showPatterns), height: `${h}%`, minHeight: 8, }} @@ -103,7 +131,7 @@ function BarChart({ colors, bgHex, textHex }: ChartProps) { ) } -function StackedBarChart({ colors, bgHex, textHex }: ChartProps) { +function StackedBarChart({ colors, bgHex, textHex, showPatterns }: ChartProps) { const groups = ['Q1', 'Q2', 'Q3', 'Q4'] return (
@@ -121,6 +149,7 @@ function StackedBarChart({ colors, bgHex, textHex }: ChartProps) { key={pc.name} style={{ backgroundColor: pc.hex, + backgroundImage: overlayFor(ci, !!showPatterns), height: `${val}%`, minHeight: 4, }} @@ -149,7 +178,7 @@ function StackedBarChart({ colors, bgHex, textHex }: ChartProps) { ) } -function DonutChart({ colors, bgHex, textHex }: ChartProps) { +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, @@ -168,14 +197,38 @@ function DonutChart({ colors, bgHex, textHex }: ChartProps) { >
- {segments.map(({ name, hex, pct, offset }) => ( + {showPatterns && ( + + {segments.map(({ name, hex }, i) => ( + + + + + ))} + + )} + {segments.map(({ name, hex, pct, offset }, i) => ( + 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 @@ -214,15 +298,30 @@ function LineChart({ colors, bgHex, textHex }: ChartProps) { .map((v, i) => `${i === 0 ? 'M' : 'L'}${px(i)},${py(v, offset)}`) .join(' ') return ( - + + + {showPatterns && + points.map((v, i) => ( + + ))} + ) })} @@ -273,13 +372,7 @@ function XAxis({ /* Legend */ /* ---------------------------------------------------------------------- */ -function Legend({ - colors, - bgHex, -}: { - colors: { name: string; hex: string; step: number }[] - bgHex: string -}) { +function Legend({ colors, bgHex }: { colors: ChartColor[]; bgHex: string }) { return (
- {pc.name}{' '} - ({STEP_ROLES[pc.step]}) + {pc.name} + {pc.step != null && ( + <> + {' '} + + ({STEP_ROLES[pc.step]}) + + + )}
))} @@ -309,44 +409,79 @@ function Legend({ /* Main component */ /* ---------------------------------------------------------------------- */ -export function DataColorChart({ palettes }: { palettes: Palette[] }) { +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 neutralIdx = findGrayIndex(palettes) - const bgHex = palettes[neutralIdx].steps[bgStep] - const textHex = palettes[neutralIdx].steps[textStep] + 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 multiColors = useMemo(() => { const center = 8 - const count = palettes.length + const count = safePalettes.length const startStep = Math.max(0, center - Math.floor((count - 1) / 2)) - return palettes.map((p, i) => { + return safePalettes.map((p, i) => { const step = Math.min(14, startStep + i) return { name: p.name, hex: p.steps[step], step } }) - }, [palettes]) + }, [safePalettes]) // Monochromatic: multiple shades from a single palette - const monoColors = useMemo(() => { - const p = palettes[monoPaletteIdx] ?? palettes[0] + 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, })) - }, [palettes, monoPaletteIdx]) + }, [safePalettes, monoPaletteIdx]) - const colors = mono ? monoColors : multiColors + const derivedColors = mono ? monoColors : multiColors + const colors: ChartColor[] = externalColors ?? derivedColors const ChartComponent = { bar: BarChart, @@ -362,8 +497,9 @@ export function DataColorChart({ palettes }: { palettes: Palette[] }) { Data color chart

- Preview palette colors in chart contexts — multi-chromatic (one color - per palette) or monochromatic (shades from a single palette) + {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.'}

@@ -387,16 +523,18 @@ export function DataColorChart({ palettes }: { palettes: Palette[] }) { ))}
- + {paletteMode && ( + + )} - {mono && ( + {paletteMode && mono && ( + +
{/* SVG filter definitions */} - {cvd !== 'none' && ( - - - - - - - - )} + {/* Filtered preview area */} -
+
{/* Chart */} - + {/* Legend */}
@@ -467,7 +625,7 @@ export function DataColorChart({ palettes }: { palettes: Palette[] }) { className="rounded-lg p-4 flex flex-wrap gap-2" style={{ backgroundColor: bgHex }} > - {colors.map((pc) => ( + {colors.map((pc, i) => ( {pc.name} @@ -522,7 +681,7 @@ export function DataColorChart({ palettes }: { palettes: Palette[] }) {
{/* Inter-color contrast matrix */} - {colors.length > 1 && ( + {pairwiseCheck && colors.length > 1 && (

Color-to-color contrast @@ -593,8 +752,12 @@ export function DataColorChart({ palettes }: { palettes: Palette[] }) { key={col.name} className="px-2 py-1 text-center font-mono" style={{ - backgroundColor: pass3 ? '#dcfce7' : '#fee2e2', - color: pass3 ? '#166534' : '#991b1b', + backgroundColor: pass3 + ? 'var(--eds-color-bg-success-fill-muted-default)' + : 'var(--eds-color-bg-danger-fill-muted-default)', + color: pass3 + ? 'var(--eds-color-text-success-subtle)' + : 'var(--eds-color-text-danger-subtle)', }} > {result.wcag} 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 index 08c14596a7..2e4421730a 100644 --- a/apps/eds-color-palette-generator/src/config/categories.ts +++ b/apps/eds-color-palette-generator/src/config/categories.ts @@ -5,3 +5,35 @@ export const CATEGORY_GROUPS = [ { 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 487e3ba176..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', + // 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.25, + darkValue: 0.226, } export const BG_FILL_MUTED_DEFAULT: StepDefinition = { 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 183cb97db6..a9b68e75ae 100644 --- a/apps/eds-color-palette-generator/src/utils/color.ts +++ b/apps/eds-color-palette-generator/src/utils/color.ts @@ -339,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/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/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