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 (
+
+ {copied ? 'Copied!' : 'Copy'}
+
+ )
+}
+
+/* ------------------------------------------------------------------ */
+/* 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) => (
+ setActivePalette(i)}
+ className="cursor-pointer"
+ style={{
+ padding: '6px 14px',
+ fontSize: '13px',
+ fontWeight: activePalette === i ? 600 : 400,
+ borderRadius: '8px',
+ border:
+ activePalette === i
+ ? '1.5px solid #111'
+ : '1.5px solid #d1d5db',
+ background: activePalette === i ? '#111' : '#fff',
+ color: activePalette === i ? '#fff' : '#111',
+ }}
+ >
+ {p.name}
+
+ ))}
+
+ )}
+
+ {/* View mode toggle */}
+
+ {(['semantic', 'gradient', 'combined'] as const).map((mode) => (
+ setViewMode(mode)}
+ className="cursor-pointer"
+ style={{
+ padding: '6px 12px',
+ fontSize: '12px',
+ fontWeight: viewMode === mode ? 600 : 400,
+ border: 'none',
+ borderLeft: mode !== 'semantic' ? '1px solid #d1d5db' : 'none',
+ background: viewMode === mode ? '#111' : '#fff',
+ color: viewMode === mode ? '#fff' : '#666',
+ }}
+ >
+ {mode === 'semantic' ? 'Curve' : mode === 'gradient' ? 'Gradient' : 'Combined'}
+
+ ))}
+
+
+ {/* 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) => (
+
+ ))}
+
+ {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 (
+
+
+ {label}
+
+ onChange(e.target.value as StepRole)}
+ style={{
+ padding: '4px 8px',
+ fontSize: '12px',
+ borderRadius: '6px',
+ border: '1.5px solid #d1d5db',
+ background: '#fff',
+ fontFamily: 'var(--font-geist-mono, monospace)',
+ }}
+ >
+ {options.map((r) => {
+ const isRecommended = recommended ? recommended.has(r) : true
+ return (
+
+ {isRecommended ? `● ${r}` : ` ${r}`}
+
+ )
+ })}
+
+
+ )
+}
+
+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) => (
+ setActivePalette(i)}
+ className="cursor-pointer"
+ style={{
+ padding: '6px 14px',
+ fontSize: '13px',
+ fontWeight: activePalette === i ? 600 : 400,
+ borderRadius: '8px',
+ border:
+ activePalette === i
+ ? '1.5px solid #111'
+ : '1.5px solid #d1d5db',
+ background: activePalette === i ? '#111' : '#fff',
+ color: activePalette === i ? '#fff' : '#111',
+ }}
+ >
+ {p.name}
+
+ ))}
+
+ Refresh
+
+
+
+ {/* 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}
+ />
+ setSurfaceConfig(DEFAULT_SURFACE)}
+ className="cursor-pointer"
+ style={{
+ padding: '4px 10px',
+ fontSize: '11px',
+ borderRadius: '6px',
+ border: '1.5px solid #d1d5db',
+ background: '#fff',
+ color: '#6b7280',
+ }}
+ >
+ Reset
+
+
+
+ {/* Preview area — one per palette */}
+
+ {allPalettes.map((pal, palIdx) => (
+
+ ))}
+
+
+
+
+ {/* ============================================================ */}
+ {/* INTERACTIVE PICKER */}
+ {/* ============================================================ */}
+
+
+ Interactive Picker
+
+
+ Select any foreground and background variable to test contrast
+
+
+
+ {/* Selector row */}
+
+
+
+ Foreground
+
+ setFgRole(e.target.value as StepRole)}
+ style={{
+ padding: '6px 10px',
+ fontSize: '13px',
+ borderRadius: '6px',
+ border: '1.5px solid #d1d5db',
+ background: '#fff',
+ fontFamily: 'var(--font-geist-mono, monospace)',
+ }}
+ >
+ {fgRoles.map((r) => (
+
+ {r}
+
+ ))}
+
+
+
+ on
+
+
+
+ Background
+
+ setBgRole(e.target.value as StepRole)}
+ style={{
+ padding: '6px 10px',
+ fontSize: '13px',
+ borderRadius: '6px',
+ border: '1.5px solid #d1d5db',
+ background: '#fff',
+ fontFamily: 'var(--font-geist-mono, monospace)',
+ }}
+ >
+ {bgRoles.map((r) => (
+
+ {r}
+
+ ))}
+
+
+
+
+ {/* Results for each palette */}
+
+ {allPalettes.map((pal, i) => {
+ const fgHex = pal.steps[roleIndex(fgRole)]
+ const bgHex = pal.steps[roleIndex(bgRole)]
+
+ return (
+
+ )
+ })}
+
+
+
+
+
+ )
+}
diff --git a/apps/eds-color-palette-generator/src/app/palette/page.tsx b/apps/eds-color-palette-generator/src/app/palette/page.tsx
new file mode 100644
index 0000000000..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) => (
+ setViewMode(mode)}
+ className="cursor-pointer"
+ style={{
+ padding: '6px 12px',
+ fontSize: '12px',
+ fontWeight: viewMode === mode ? 600 : 400,
+ border: 'none',
+ borderLeft:
+ mode !== 'curve' ? '1px solid #d1d5db' : 'none',
+ background: viewMode === mode ? '#111' : '#fff',
+ color: viewMode === mode ? '#fff' : '#666',
+ }}
+ >
+ {mode === 'curve' ? 'Curve' : 'Gradient'}
+
+ ))}
+
+
+ {/* Nav links */}
+
+ Contrast
+
+
+ Example
+
+
+
+
+
+
+ {/* ---- Actions ---- */}
+
+
+ addPalette({
+ name: 'Custom',
+ steps: Array(15).fill('#888888'),
+ })
+ }
+ className="cursor-pointer"
+ style={{
+ padding: '8px 16px',
+ fontSize: '13px',
+ fontWeight: 500,
+ borderRadius: '8px',
+ border: '1.5px solid #d1d5db',
+ background: '#fff',
+ color: '#111',
+ }}
+ >
+ + Custom HEX
+
+
+ Re-import from generator
+
+
+
+ {/* ---- 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',
+ }}
+ />
+ removePalette(palIdx)}
+ className="ml-auto cursor-pointer"
+ style={{
+ padding: '4px 10px',
+ fontSize: '11px',
+ borderRadius: '6px',
+ border: '1.5px solid #fecaca',
+ background: '#fff',
+ color: '#dc2626',
+ }}
+ >
+ Remove
+
+
+
+ {/* Color strip */}
+
+
+ {/* Category group headers (curve mode only) */}
+ {viewMode === 'curve' &&
+ CATEGORY_GROUPS.map((group) => (
+
+ ))}
+
+ {/* Swatch cells */}
+ {sorted.map(({ hex, i: origIdx }, displayIdx) => (
+
+
+ {origIdx + 1}
+
+
+ ))}
+
+
+
+ {/* Hex input grid */}
+
+ {sorted.map(({ hex, i: origIdx }) => (
+
+
+
+ {origIdx + 1}
+ {' '}
+ {STEP_ROLES[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}
+
+
+
+
+ Accent
+ setAccentIdx(Number(e.target.value))}
+ className="px-2 py-1 text-xs rounded-md border border-neutral-subtle bg-default font-mono"
+ >
+ {accentCandidates.map((p) => (
+
+ {p.name}
+
+ ))}
+
+
+
+ {dataColors.length > 0 && (
+
+ Data colors:
+ {dataColors.map((p) => (
+
+ {p.name}
+
+ ))}
+
+ )}
+
+
+
+ {/* Login Form */}
+
+
+ {/* Data Table */}
+
+
+ {/* 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) => (
+ onActivePaletteChange(i)}
+ className={[
+ 'cursor-pointer px-2.5 py-1 text-xs rounded-md border',
+ activePaletteIndex === i
+ ? 'bg-neutral-fill-emphasis-default text-strong-on-emphasis font-semibold border-transparent'
+ : 'bg-default text-strong font-normal border-neutral-subtle',
+ ].join(' ')}
+ >
+ {p.name}
+
+ ))}
+
+ )}
+
+
+
+
+
+
+
+ fg \ bg
+
+ {BG_INDICES.map((bgIdx) => (
+
+
+
+ {bgIdx + 1}
+
+
+ ))}
+
+
+
+ {grid.map((row) => (
+
+
+
+
+
+ {row.fgIdx + 1}. {row.fgRole}
+
+
+
+ {row.cells.map((cell) => (
+
+
+
+
+ {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 */}
+
+ setSubTab('eds')}
+ className={[
+ 'cursor-pointer px-4 py-1.5 text-sm border-none',
+ subTab === 'eds'
+ ? 'bg-neutral-fill-emphasis-default text-strong-on-emphasis font-semibold'
+ : 'bg-default text-subtle font-normal',
+ ].join(' ')}
+ >
+ EDS
+
+ setSubTab('custom')}
+ className={[
+ 'cursor-pointer px-4 py-1.5 text-sm border-none border-l border-neutral-subtle',
+ subTab === 'custom'
+ ? 'bg-neutral-fill-emphasis-default text-strong-on-emphasis font-semibold'
+ : 'bg-default text-subtle font-normal',
+ ].join(' ')}
+ >
+ Custom
+
+
+
+ {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
+
+
+
+ Email
+
+
+
+
+ Password
+
+
+
+
+ Forgot password?
+
+
+
+ Sign in
+
+
+ )
+}
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)}
+ />
+ )}
+
+
+ setExpandedIndex(expandedIndex === i ? null : i)
+ }
+ className="cursor-pointer p-1 rounded-md border-none bg-transparent text-subtle hover:text-strong"
+ title={expandedIndex === i ? 'Collapse' : 'Edit anchors'}
+ >
+ {expandedIndex === i ? (
+
+ ) : (
+
+ )}
+
+
+ removePalette(i)}
+ disabled={palettes.length <= 1}
+ className="cursor-pointer p-1 rounded-md border-none bg-transparent text-subtle disabled:opacity-30"
+ title="Remove palette"
+ >
+
+
+
+
+ {/* 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}
+ />
+ ))}
+
+
addAnchor(i)}
+ className="flex items-center gap-1 cursor-pointer px-2 py-1 text-xs rounded-md border border-dashed border-neutral-subtle bg-transparent text-subtle hover:text-strong transition-colors"
+ >
+
+ Add anchor
+
+
convertToSimple(i)}
+ className="cursor-pointer px-2 py-1 text-xs rounded-md border border-neutral-subtle bg-transparent text-subtle hover:text-strong transition-colors"
+ >
+ Switch to simple
+
+
+ >
+ ) : (
+
+
+ Single color mode
+
+ convertToAnchors(i)}
+ className="cursor-pointer px-2 py-1 text-xs rounded-md border border-neutral-subtle bg-transparent text-subtle hover:text-strong transition-colors"
+ >
+ Switch to anchors
+
+
+ )}
+
+ )}
+
+ ))}
+
+
+
+
+ Add palette
+
+
+ )
+}
+
+/* ------------------------------------------------------------------ */
+/* 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 (
+
+
onUpdate('step', parseInt(e.target.value))}
+ className="px-2 py-1 text-xs rounded-md border border-neutral-subtle bg-default"
+ aria-label={`Step for anchor ${index + 1}`}
+ >
+ {Array.from({ length: 15 }, (_, i) => i + 1).map((step) => {
+ const isUsed = allAnchors.some(
+ (a, idx) => a.step === step && idx !== index,
+ )
+ return (
+
+ Step {step}
+ {isUsed ? ' (used)' : ''}
+
+ )
+ })}
+
+
+
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}
+ />
+
colorInputRef.current?.click()}
+ className="cursor-pointer p-1 rounded-md border-none bg-transparent text-subtle hover:text-strong"
+ title="Pick color"
+ >
+
+
+
+ {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"
+ />
+ nativeRef.current?.click()}
+ className="cursor-pointer rounded-md border border-neutral-subtle p-0 relative overflow-hidden shrink-0"
+ style={{
+ width: '28px',
+ height: '28px',
+ backgroundColor: displayValue,
+ }}
+ title="Pick color"
+ >
+ onChange(e.target.value.slice(1))}
+ className="absolute inset-0 opacity-0 cursor-pointer w-full h-full"
+ tabIndex={-1}
+ />
+
+
+ )
+}
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) => (
+ onTabChange(tab.key)}
+ className={[
+ 'cursor-pointer px-4 py-1.5 text-sm border-none',
+ tab.key !== 'fargesystem' ? 'border-l border-neutral-subtle' : '',
+ activeTab === tab.key
+ ? 'bg-neutral-fill-emphasis-default text-strong-on-emphasis font-semibold'
+ : 'bg-default text-subtle font-normal',
+ ].join(' ')}
+ >
+ {tab.label}
+
+ ))}
+
+
+
+
+
+
+ {copied ? (
+ <>
+
+ Copied
+ >
+ ) : (
+ <>
+
+ Share
+ >
+ )}
+
+
+
+
+ )
+}
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) => (
+
+ ))}
+
+ {/* 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 (
+
+ )
+ })}
+
+
+
+ )
+}
+
+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) => (
+ setChartType(ct.key)}
+ className={[
+ 'cursor-pointer px-3 py-1 text-xs border-none',
+ chartType === ct.key
+ ? 'bg-neutral-fill-emphasis-default text-strong-on-emphasis font-semibold'
+ : 'bg-default text-subtle font-normal',
+ ].join(' ')}
+ >
+ {ct.label}
+
+ ))}
+
+
+
+ setMono(e.target.checked)}
+ />
+ Monochromatic
+
+
+ {mono && (
+
+ Palette
+ setMonoPaletteIdx(Number(e.target.value))}
+ className="rounded border border-neutral-subtle bg-default text-xs px-2 py-1"
+ >
+ {palettes.map((p, i) => (
+
+ {p.name}
+
+ ))}
+
+
+ )}
+
+
+ {/* Controls row 2: bg + text + accessibility */}
+
+
+
+
+
+ Vision
+ {
+ setCvd(e.target.value as CVDType)
+ }}
+ className="rounded border border-neutral-subtle bg-default text-xs px-2 py-1"
+ >
+ {CVD_OPTIONS.map((opt) => (
+
+ {opt.label}
+
+ ))}
+
+
+
+
+ {/* 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((pc) => (
+
+
+
+
+ {pc.name}
+
+
+
+ ))}
+
+
+
+ {colors.map((row, ri) => (
+
+
+
+
+
+ {row.name}
+
+
+
+ {colors.map((col, ci) => {
+ if (ci <= ri) {
+ return (
+
+ {ci === ri ? '—' : ''}
+
+ )
+ }
+ const result = calcContrast(row.hex, col.hex)
+ const ratio = parseFloat(result.wcag)
+ const pass3 = ratio >= 3
+ return (
+
+ {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
+
+
+
+
+
+ Foreground
+ setFgStep(Number(e.target.value))}
+ className="rounded border border-neutral-subtle bg-default text-xs px-2 py-1"
+ >
+ {STEP_ROLES.map((role, i) => (
+
+ {role}
+
+ ))}
+
+
+
+
+ Background
+ setBgStep(Number(e.target.value))}
+ className="rounded border border-neutral-subtle bg-default text-xs px-2 py-1"
+ >
+ {STEP_ROLES.map((role, i) => (
+
+ {role}
+
+ ))}
+
+
+
+
+
+ {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 && (
+ removePairing(idx)}
+ className="cursor-pointer px-2 py-1 text-xs rounded border border-neutral-subtle bg-transparent text-subtle hover:text-strong"
+ >
+ Remove
+
+ )}
+
+
+ {palettes.map((p) => (
+
+ ))}
+
+
+ ))}
+
+
+ + Add pairing
+
+
+ )
+}
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 (
+
+ {label}
+ onChange(Number(e.target.value))}
+ className="rounded border border-neutral-subtle bg-default text-xs px-2 py-1"
+ >
+ {hasGroups ? (
+ <>
+
+ {recSteps.map((i) => (
+
+ {STEP_ROLES[i]}
+
+ ))}
+
+
+ {otherSteps.map((i) => (
+
+ {STEP_ROLES[i]}
+
+ ))}
+
+ >
+ ) : (
+ allSteps.map((i) => (
+
+ {STEP_ROLES[i]}
+
+ ))
+ )}
+
+
+ )
+}
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 */}
+
+
+ addColor({ name: 'New colour', value: '#888888' })
+ }
+ className="px-4 py-2 text-sm border border-neutral-medium hover:bg-neutral-fill-muted-hover active:bg-neutral-fill-muted-active rounded-md cursor-pointer"
+ data-testid="add-color-button"
+ >
+ Add colour
+
+
+ >
+ )}
+
+
+
+
+
+ {isMounted && showContrast && contrastSummary.total > 0 && (
+ <>
+
+ {`${contrastSummary.passed}/${contrastSummary.total} checks (${contrastSummary.percentage.toFixed(1)}%)`}
+
+ >
+ )}
+ {isMounted && isConfigDirty && (
+
+
+ Reset
+
+ )}
+
+
+ )
+}
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 */}
-
-
- addColor({ name: 'New colour', value: '#888888' })
- }
- className="px-4 py-2 text-sm border border-neutral-medium hover:bg-neutral-fill-muted-hover active:bg-neutral-fill-muted-active rounded-md cursor-pointer"
- data-testid="add-color-button"
- >
- Add colour
-
-
- >
+ ) : activeTab === 'kontrast' ? (
+
+ ) : (
+
)}
-
-
-
-
- {isMounted && showContrast && contrastSummary.total > 0 && (
- <>
-
- {`${contrastSummary.passed}/${contrastSummary.total} checks (${contrastSummary.percentage.toFixed(1)}%)`}
-
- >
- )}
- {isMounted && isConfigDirty && (
-
-
- Reset
-
- )}
-
)
}
+
+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 = ({