diff --git a/app/src/pages/PalettePage.tsx b/app/src/pages/PalettePage.tsx index 2f3e3e46980..17ae3dccc32 100644 --- a/app/src/pages/PalettePage.tsx +++ b/app/src/pages/PalettePage.tsx @@ -1,21 +1,20 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; -import ContentCopyIcon from '@mui/icons-material/ContentCopy'; import Box from '@mui/material/Box'; -import Collapse from '@mui/material/Collapse'; -import FormControlLabel from '@mui/material/FormControlLabel'; -import IconButton from '@mui/material/IconButton'; -import Switch from '@mui/material/Switch'; -import Tab from '@mui/material/Tab'; -import Tabs from '@mui/material/Tabs'; -import Tooltip from '@mui/material/Tooltip'; -import { SectionHeader } from 'src/components/SectionHeader'; -import matrixData from 'src/data/paletteMatrices.json'; import { useAnalytics, useTheme } from 'src/hooks'; -import { type Lang, PALETTE, snippet, type Swatch } from 'src/pages/PalettePage.helpers'; +import { type Lang, PALETTE, type Swatch } from 'src/pages/PalettePage.helpers'; import { paths } from 'src/routes/paths'; -import { colors, fontSize, textStyle, typography } from 'src/theme'; +import { ContinuousCmapsSection } from 'src/sections/palette/ContinuousCmapsSection'; +import { CopySnippetsSection } from 'src/sections/palette/CopySnippetsSection'; +import { DeltaESection } from 'src/sections/palette/DeltaESection'; +import { HeroSection } from 'src/sections/palette/HeroSection'; +import { LineageSection } from 'src/sections/palette/LineageSection'; +import { PaletteGridSection } from 'src/sections/palette/PaletteGridSection'; +import { COMPARISONS } from 'src/sections/palette/paletteHelpers'; +import { SemanticAnchorsSection } from 'src/sections/palette/SemanticAnchorsSection'; +import type { WheelDot } from 'src/sections/palette/types'; +import { WcagContrastSection } from 'src/sections/palette/WcagContrastSection'; // ───────────────────────────────────────────────────────────────────────────── // Imprint palette — 8 categorical hues + OKLCH coords for the wheel @@ -27,656 +26,6 @@ import { colors, fontSize, textStyle, typography } from 'src/theme'; * slot 3 rose) close together. Order: [brand, lavender, lime, rose, red, cyan, blue, ochre]. */ const CVD_OPTIMAL_ORDER: number[] = [0, 1, 7, 6, 4, 5, 2, 3]; -type CompPalette = { - id: string; - name: string; - href?: string; - hexes: { hex: string; L: number; C: number; H: number }[]; -}; - -const OKABE_ITO: CompPalette = { - id: 'okabe', - name: 'Okabe-Ito', - href: 'https://jfly.uni-koeln.de/color/', - hexes: [ - { hex: '#009E73', L: 0.62, C: 0.13, H: 165.5 }, - { hex: '#D55E00', L: 0.621, C: 0.17, H: 47.5 }, - { hex: '#0072B2', L: 0.532, C: 0.131, H: 244.0 }, - { hex: '#CC79A7', L: 0.679, C: 0.118, H: 346.3 }, - { hex: '#E69F00', L: 0.753, C: 0.158, H: 76.8 }, - { hex: '#56B4E9', L: 0.735, C: 0.117, H: 236.2 }, - { hex: '#F0E442', L: 0.902, C: 0.172, H: 105.0 }, - ], -}; - -const TOL_MUTED: CompPalette = { - id: 'tol', - name: 'Tol muted', - href: 'https://personal.sron.nl/~pault/', - hexes: [ - { hex: '#332288', L: 0.347, C: 0.159, H: 281.8 }, - { hex: '#88CCEE', L: 0.812, C: 0.084, H: 230.8 }, - { hex: '#44AA99', L: 0.674, C: 0.098, H: 180.4 }, - { hex: '#117733', L: 0.499, C: 0.135, H: 148.6 }, - { hex: '#999933', L: 0.663, C: 0.123, H: 109.3 }, - { hex: '#DDCC77', L: 0.841, C: 0.108, H: 98.3 }, - { hex: '#CC6677', L: 0.635, C: 0.13, H: 11.0 }, - { hex: '#882255', L: 0.432, C: 0.145, H: 354.5 }, - { hex: '#AA4499', L: 0.553, C: 0.167, H: 334.6 }, - ], -}; - -const SET2: CompPalette = { - id: 'set2', - name: 'ColorBrewer Set2', - href: 'https://colorbrewer2.org', - hexes: [ - { hex: '#66C2A5', L: 0.749, C: 0.099, H: 170.8 }, - { hex: '#FC8D62', L: 0.755, C: 0.147, H: 41.5 }, - { hex: '#8DA0CB', L: 0.707, C: 0.067, H: 266.1 }, - { hex: '#E78AC3', L: 0.748, C: 0.132, H: 343.3 }, - { hex: '#A6D854', L: 0.821, C: 0.169, H: 127.3 }, - { hex: '#FFD92F', L: 0.892, C: 0.173, H: 95.2 }, - { hex: '#E5C494', L: 0.837, C: 0.073, H: 76.8 }, - { hex: '#B3B3B3', L: 0.767, C: 0.0, H: 89.9 }, - ], -}; - -const ANYPLOT_PREV: CompPalette = { - id: 'prev', - name: 'anyplot v1 (previous live)', - hexes: [ - { hex: '#009E73', L: 0.62, C: 0.13, H: 165.5 }, - { hex: '#9418DB', L: 0.529, C: 0.259, H: 307.4 }, - { hex: '#B71D27', L: 0.503, C: 0.187, H: 24.7 }, - { hex: '#16B8F3', L: 0.735, C: 0.145, H: 231.0 }, - { hex: '#99B314', L: 0.722, C: 0.167, H: 119.8 }, - { hex: '#D359A7', L: 0.643, C: 0.176, H: 344.0 }, - { hex: '#BA843E', L: 0.654, C: 0.109, H: 70.8 }, - ], -}; - -const COMPARISONS: CompPalette[] = [OKABE_ITO, TOL_MUTED, SET2, ANYPLOT_PREV]; - -type Anchor = { - key: string; - hexLight: string; - hexDark?: string; - role: string; - hint: string; -}; - -const ANCHORS: Anchor[] = [ - { - key: 'amber', - hexLight: '#DDCC77', - role: 'warning / caution', - hint: 'Chosen for max ΔE under CVD against lime, so it stays distinct under deuteranopia.', - }, - { - key: 'neutral', - hexLight: '#1A1A17', - hexDark: '#F0EFE8', - role: 'totals / baseline / outline', - hint: "Theme-adaptive ink. Same hex as text and gridlines — the series reads as part of the chart's structural layer.", - }, - { - key: 'muted', - hexLight: '#6B6A63', - hexDark: '#A8A79F', - role: 'other / rest / disabled', - hint: 'Theme-adaptive ink-muted. For "other" / "rest" slices, disabled series, confidence-band fills.', - }, -]; - -const HYBRID_V3_CVD: number[] = [36.19, 16.34, 13.98, 13.98, 13.7, 10.7, 8.81]; -const PURE_CVD_GREEDY: number[] = [36.19, 21.45, 19.81, 15.2, 13.7, 10.7, 8.81]; - -const WCAG_TABLE: { hex: string; name: string; light: number; dark: number }[] = [ - { hex: '#009E73', name: 'brand-green', light: 3.08, dark: 5.48 }, - { hex: '#AE3030', name: 'matte-red', light: 5.79, dark: 2.92 }, - { hex: '#C475FD', name: 'lavender', light: 2.59, dark: 6.53 }, - { hex: '#99B314', name: 'lime', light: 2.15, dark: 7.87 }, - { hex: '#4467A3', name: 'blue', light: 5.09, dark: 3.32 }, - { hex: '#2ABCCD', name: 'cyan', light: 2.06, dark: 8.19 }, - { hex: '#954477', name: 'rose', light: 5.61, dark: 3.01 }, - { hex: '#BD8233', name: 'ochre', light: 2.95, dark: 5.72 }, - { hex: '#DDCC77', name: 'amber', light: 1.46, dark: 11.59 }, -]; - -type HistoryEntry = { - id: string; - title: string; - hexes: string[]; - summary: string; - href?: string; - hrefLabel?: string; -}; - -const HISTORY: HistoryEntry[] = [ - { - id: 'v3', - title: 'v3 — imprint (current)', - hexes: ['#009E73', '#C475FD', '#4467A3', '#BD8233', '#AE3030', '#2ABCCD', '#954477', '#99B314'], - summary: - "the shipping palette — v1's hues pulled into an even tighter, lower-chroma band so the set reads editorial rather than dashboard and nothing washes out on cream. all six primary hue families are present — red / yellow / green / cyan / blue / purple — which is also why the palette doubles as the site's code-syntax coloring — every token type gets its own colourblind-safe hue. the first four slots span four distinct hue families, and semantic red is deferred to slot 4 so it stays a free named anchor for bad / loss / error instead of landing in every 3-series chart. plus 3 anchors outside the pool: amber, neutral, muted.", - }, - { - id: 'v1', - title: 'v1 — variant D ("balanced")', - hexes: ['#009E73', '#9418DB', '#B71D27', '#16B8F3', '#99B314', '#D359A7', '#BA843E'], - summary: - 'anyplot\'s first custom palette — the last one live before v3, and what "anyplot v1 (previous live)" overlays on the wheel above. 7 hues + adaptive neutral. Brand green inherited from Okabe-Ito; the rest from a Petroff-style max-min ΔE search in the muted-paper chroma envelope — separating red from tan and trading the near-yellow for olive-lime so nothing washes out on cream.', - href: 'https://arxiv.org/abs/2107.02270', - hrefLabel: 'Petroff 2021 (arXiv)', - }, - { - id: 'v0', - title: 'v0 — Okabe-Ito', - hexes: ['#009E73', '#D55E00', '#0072B2', '#CC79A7', '#E69F00', '#56B4E9', '#F0E442'], - summary: - 'The original colorblind-safe categorical palette (Wong 2011, Nature Methods). 7 hues + adaptive neutral, brand green at slot 0 — inherited unchanged by every version since. Its limits on warm paper: orange and vermillion sit only ~30° apart on the hue wheel, and the yellow washes out on cream — both fixed by the later variants.', - href: 'https://jfly.uni-koeln.de/color/', - hrefLabel: 'jfly.uni-koeln.de', - }, -]; - -// ───────────────────────────────────────────────────────────────────────────── -// Code snippets per language -// ───────────────────────────────────────────────────────────────────────────── - -const LANG_LABELS: Record = { - python: 'Python', - r: 'R', - julia: 'Julia', - js: 'JavaScript', -}; - -// ───────────────────────────────────────────────────────────────────────────── -// Inline sub-components -// ───────────────────────────────────────────────────────────────────────────── - -// Theme-adaptive tooltip. MUI's default tooltip is a fixed dark grey that -// ignores the page's light/dark mode, so paint it from the design tokens — -// they cascade from :root / [data-theme="dark"] on into the portal. -const tooltipSlotProps = { - tooltip: { - sx: { - bgcolor: 'var(--bg-elevated)', - color: 'var(--ink)', - border: '1px solid var(--rule)', - boxShadow: '0 6px 24px rgba(0, 0, 0, 0.18)', - maxWidth: 320, - }, - }, -}; - -const MAX_WHEEL_CHROMA = 0.28; // clamp for radial position normalisation across palettes -const WHEEL_RING_L = 0.72; // fixed OKLCH lightness for the hue-ring disk - -type WheelDot = { hex: string; L: number; C: number; H: number; name?: string }; - -/** OKLCH (L 0..1, C, H°) → sRGB [r,g,b] 0..255, clamped to gamut. - * Björn Ottosson's OKLab→linear-sRGB matrices. Used to paint the wheel disk - * per-pixel so the hue ring is seamless (no conic-gradient facets). */ -function oklchToRgb(L: number, C: number, H: number): [number, number, number] { - const h = (H * Math.PI) / 180; - const a = C * Math.cos(h); - const b = C * Math.sin(h); - const l_ = L + 0.3963377774 * a + 0.2158037573 * b; - const m_ = L - 0.1055613458 * a - 0.0638541728 * b; - const s_ = L - 0.0894841775 * a - 1.291485548 * b; - const l = l_ * l_ * l_; - const m = m_ * m_ * m_; - const s = s_ * s_ * s_; - const lin = (c: number) => { - const v = c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055; - return Math.max(0, Math.min(1, v)); - }; - const r = lin(+4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s); - const g = lin(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s); - const bb = lin(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s); - return [Math.round(r * 255), Math.round(g * 255), Math.round(bb * 255)]; -} - -/** 5-point star polygon points string, centred at (cx,cy). */ -function starPoints(cx: number, cy: number, rOuter: number, rInner: number): string { - const pts: string[] = []; - for (let i = 0; i < 10; i++) { - const r = i % 2 === 0 ? rOuter : rInner; - // start at 12 o'clock, go clockwise - const ang = Math.PI / 2 - (i * Math.PI) / 5; - pts.push(`${(cx + Math.cos(ang) * r).toFixed(2)},${(cy - Math.sin(ang) * r).toFixed(2)}`); - } - return pts.join(' '); -} - -/** Pick a readable text color (ink-dark or ink-light) for a hex bg via WCAG luminance. */ -function textOn(hex: string): string { - const s = hex.replace('#', ''); - const r = parseInt(s.slice(0, 2), 16) / 255; - const g = parseInt(s.slice(2, 4), 16) / 255; - const b = parseInt(s.slice(4, 6), 16) / 255; - const lin = (c: number) => (c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)); - const L = 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b); - return L > 0.5 ? '#1A1A17' : '#F0EFE8'; -} - -function ChromaWheel({ - size = 320, - imprintDots, - overlay, - compact = false, -}: { - size?: number; - imprintDots: WheelDot[]; - overlay?: WheelDot[]; - /** Compact preview: hide angle ticks/labels, scale dots down, use more disk. */ - compact?: boolean; -}) { - const cx = size / 2; - const cy = size / 2; - // The coloured disk runs to outerR; only a thin margin is left (just enough - // for the angle labels in full mode). No second border ring beyond it. - const outerR = size / 2 - (compact ? 4 : 22); - const dotR = Math.min(7.5, Math.max(3, size * 0.024)); // scale with size so the small wheel stays clean - const canvasRef = useRef(null); - const lastPaintRef = useRef(''); // skip redundant repaints (StrictMode double-invoke / same-size re-render) - - // Project (hue°, chroma) → (x, y). Hue 0° at right (3 o'clock), increasing - // counter-clockwise — matching the per-pixel disk painted below. - const project = (H: number, C: number): { x: number; y: number } => { - const rad = (H * Math.PI) / 180; - const r = Math.min(C / MAX_WHEEL_CHROMA, 1) * outerR; - return { x: cx + Math.cos(rad) * r, y: cy - Math.sin(rad) * r }; - }; - - // Paint the hue ring per-pixel into a canvas: every pixel at (r, θ) is - // oklch(WHEEL_RING_L, (r/outerR)·MAX_CHROMA, θ). Continuous → no facets, and - // the chroma genuinely fades to neutral grey at the centre (matches the - // matplotlib reference wheel rather than faking the fade with a bg overlay). - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - // The per-pixel disk is the same for a given (size, outerR); skip the - // O(n²) trig repaint when nothing that affects it changed. - const dpr = Math.min(window.devicePixelRatio || 1, 2); - // Key includes dpr so a zoom / display change repaints at the right resolution. - const paintKey = `${size}|${outerR}|${dpr}`; - if (lastPaintRef.current === paintKey) return; - const px = Math.round(size * dpr); - canvas.width = px; - canvas.height = px; - const ctx = canvas.getContext('2d'); - if (!ctx) return; - const img = ctx.createImageData(px, px); - const data = img.data; - const r0 = outerR * dpr; - const cxp = cx * dpr; - const cyp = cy * dpr; - for (let y = 0; y < px; y++) { - for (let x = 0; x < px; x++) { - const dx = x - cxp; - const dy = y - cyp; - const r = Math.hypot(dx, dy); - const idx = (y * px + x) * 4; - if (r > r0) continue; // leave fully transparent outside the disk - let theta = (Math.atan2(-dy, dx) * 180) / Math.PI; - if (theta < 0) theta += 360; - const c = Math.min(r / r0, 1) * MAX_WHEEL_CHROMA; - const [rr, gg, bb] = oklchToRgb(WHEEL_RING_L, c, theta); - data[idx] = rr; - data[idx + 1] = gg; - data[idx + 2] = bb; - data[idx + 3] = r > r0 - dpr ? Math.round((255 * (r0 - r)) / dpr) : 255; // 1px AA edge - } - } - ctx.putImageData(img, 0, 0); - lastPaintRef.current = paintKey; - }, [size, outerR, cx, cy]); - - // Chroma envelope — the band all imprint hues fall inside (low-chroma corridor). - const cs = imprintDots.map(d => d.C); - const rInner = (Math.min(...cs) / MAX_WHEEL_CHROMA) * outerR; - const rOuter = (Math.max(...cs) / MAX_WHEEL_CHROMA) * outerR; - const rMid = (rInner + rOuter) / 2; - const ticks = compact ? [] : [0, 90, 180, 270]; - - return ( - - - - {/* Crisp ring exactly at the coloured-disk edge — no outer moat / second rim */} - - - {/* Low-chroma corridor: shaded annulus + dashed edges */} - - - - - {/* Angle ticks + labels (0° = 3 o'clock, CCW) */} - {ticks.map(deg => { - const rad = (deg * Math.PI) / 180; - const x1 = cx + Math.cos(rad) * outerR; - const y1 = cy - Math.sin(rad) * outerR; - const x2 = cx + Math.cos(rad) * (outerR + 5); - const y2 = cy - Math.sin(rad) * (outerR + 5); - const lx = cx + Math.cos(rad) * (outerR + 15); - const ly = cy - Math.sin(rad) * (outerR + 15); - return ( - - - - {deg}° - - - ); - })} - - {/* Overlay palette (hollow rings) — below imprint dots so imprint stays dominant */} - {overlay && - overlay.map((d, i) => { - const { x, y } = project(d.H, d.C); - return ( - - {d.hex} - - - - ); - })} - - {/* Imprint dots — white halo + filled dot; slot 0 (brand) drawn as a star */} - {imprintDots.map((d, i) => { - const { x, y } = project(d.H, d.C); - const label = d.name ? `${d.name} · ${d.hex}` : d.hex; - if (i === 0) { - return ( - - {`★ ${label}`} - - - ); - } - return ( - - {label} - - - - ); - })} - - - ); -} - -function CodeBlock({ code }: { code: string }) { - const [copied, setCopied] = useState(false); - const handleCopy = () => { - void navigator.clipboard - .writeText(code) - .then(() => { - setCopied(true); - setTimeout(() => setCopied(false), 1500); - }) - .catch(() => { - // Silently ignore — clipboard API can fail in insecure contexts. - }); - }; - return ( - - - {code} - - - - - - - - ); -} - -// ───────────────────────────────────────────────────────────────────────────── -// ΔE distance matrix — generated by scripts/palette-matrix-export.py -// ───────────────────────────────────────────────────────────────────────────── - -const MATRIX_LABELS: string[] = matrixData.labels; -const MATRIX_HEXES: string[] = matrixData.hexes; -const [DE_T_BAD, DE_T_WARN, DE_T_OK] = matrixData.thresholds; // 5 / 10 / 15 (Petroff 2021) - -/** Background tint for a ΔE cell — low distance is the problem, so <5 is the - * loudest. Mirrors _palette_common.cell_class thresholds. */ -function deltaECellBg(v: number): string { - if (v <= 0) return 'transparent'; // diagonal - if (v < DE_T_BAD) return 'rgba(174, 48, 48, 0.26)'; // <5 indistinguishable - if (v < DE_T_WARN) return 'rgba(189, 130, 51, 0.22)'; // <10 weak - if (v < DE_T_OK) return 'rgba(221, 204, 119, 0.18)'; // <15 acceptable - return 'transparent'; // >=15 comfortable -} - -const DE_LEGEND: { label: string; bg: string }[] = [ - { label: '<5 indistinct', bg: 'rgba(174, 48, 48, 0.26)' }, - { label: '<10 weak', bg: 'rgba(189, 130, 51, 0.22)' }, - { label: '<15 acceptable', bg: 'rgba(221, 204, 119, 0.18)' }, - { label: '≥15 comfortable', bg: 'transparent' }, -]; - -function DeltaEMatrix({ - title, - matrix, - breakdown, -}: { - title: string; - matrix: number[][]; - breakdown?: { label: string; m: number[][] }[]; -}) { - const dot = (hx: string, label: string) => ( - - ); - return ( - - - {title} - - - - - - - {MATRIX_HEXES.map((hx, j) => ( - - {dot(hx, MATRIX_LABELS[j])} - - ))} - - - - {matrix.map((row, i) => ( - - - {dot(MATRIX_HEXES[i], MATRIX_LABELS[i])} - - {row.map((v, j) => { - const tip = - i === j - ? MATRIX_LABELS[i] - : breakdown - ? `${MATRIX_LABELS[i]} ↔ ${MATRIX_LABELS[j]} — ${breakdown.map(b => `${b.label} ${b.m[i][j].toFixed(1)}`).join(' · ')}` - : `${MATRIX_LABELS[i]} ↔ ${MATRIX_LABELS[j]} — ΔE ${v.toFixed(1)}`; - return ( - 0 && v < DE_T_BAD ? 700 : 400, - cursor: 'help', - }} - > - {i === j ? '·' : v.toFixed(0)} - - ); - })} - - ))} - - - - - ); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Layout -// ───────────────────────────────────────────────────────────────────────────── - -const sectionSx = { py: { xs: 2, md: 3 } }; -const proseColumnSx = { maxWidth: 880, mx: 'auto' }; - // ───────────────────────────────────────────────────────────────────────────── // Main page // ───────────────────────────────────────────────────────────────────────────── @@ -747,1036 +96,48 @@ export function PalettePage() { {/* 1. HERO — short lede + a compact hue–chroma wheel. Click the wheel to enlarge it and reveal the palette-comparison toggles. */} - - anyplot's imprint palette} /> - - {/* sm+: wheel on the left, content (lede + comparison) on the right. - When expanded the comparison panel fills the column beside the - wheel rather than floating below it, so the tall wheel is balanced. */} - - {/* Wheel column — the button toggles the expanded comparison view */} - - setWheelOpen(o => !o)} - aria-expanded={wheelOpen} - aria-label={ - wheelOpen - ? 'Collapse the hue–chroma wheel' - : 'Expand the hue–chroma wheel to compare palettes' - } - sx={{ - background: 'none', - border: 'none', - p: 0, - m: 0, - cursor: 'pointer', - lineHeight: 0, - borderRadius: '50%', - transition: 'transform 0.15s', - '&:hover': { transform: 'scale(1.03)' }, - '&:focus-visible': { - outline: `2px solid ${colors.primary}`, - outlineOffset: '3px', - }, - }} - > - - - - {wheelOpen ? 'click to close' : 'compare ▸'} - - - - {/* Content column — lede, then the comparison panel when expanded */} - - - imprint is anyplot's categorical palette — 8 hues plus 3 - semantic anchors. low-chroma and warm-tinted for cream paper, the same hues on - dark, validated against deuteranopia / protanopia / tritanopia. built to let the - data speak. - - - - - - where imprint sits in hue–chroma space — eight hues inside a narrow low-chroma - band, spread evenly around the wheel. overlay another published categorical - palette to compare: - - - {COMPARISONS.map(c => { - const active = compareId === c.id; - return ( - setCompareId(active ? null : c.id)} - sx={{ - background: active ? 'var(--bg-surface)' : 'none', - border: `1px solid ${active ? colors.primary : 'var(--rule)'}`, - borderRadius: 1, - padding: '6px 10px', - fontFamily: typography.mono, - fontSize: '11px', - color: active ? colors.primary : 'var(--ink-soft)', - cursor: 'pointer', - display: 'flex', - alignItems: 'center', - gap: 1, - '&:hover': { borderColor: colors.primary, color: colors.primary }, - }} - > - - {c.hexes.map(d => ( - - ))} - - {c.name} - - ); - })} - - - hue counter-clockwise from 3 o'clock · chroma = distance from centre · ★ - = brand · shaded band = imprint's low-chroma corridor - {compareId && ( - - {' '} - · rings = {COMPARISONS.find(c => c.id === compareId)?.name} - - )} - - - - - - - + {/* 2. PALETTE GRID + SORT TOGGLE */} - - the 8 categorical hues} /> - - - - sort: - {(['imprint', 'cvd'] as const).map(s => ( - setSort(s)} - sx={{ - background: 'none', - border: 'none', - padding: 0, - ml: 1, - fontFamily: typography.mono, - fontSize: fontSize.base, - fontWeight: 300, - color: sort === s ? colors.primary : 'var(--ink-soft)', - textDecoration: 'underline', - textDecorationColor: sort === s ? colors.primary : 'var(--rule)', - cursor: 'pointer', - }} - > - {s === 'imprint' - ? 'imprint (default)' - : 'CVD-optimal (max ΔE under colorblindness)'} - - ))} - - - {sort === 'cvd' && ( - - - why use CVD-optimal sort? - - - this ordering picks each next slot by maximising ΔE under the worst of - deuteranopia / protanopia / tritanopia simulation against the already-placed set. - it's the best choice when colour alone has to carry the categorical encoding - for users with red-green colour vision deficiency — e.g. small-n charts in - publications targeting CVD-first audiences, status-only legends, or signage-style - summary tiles. - - - {/* Compact per-n table — only shown in CVD mode since that's where the trade-off matters */} - - - - - n series - imprint default - CVD-optimal (active) - - - - {HYBRID_V3_CVD.map((de, i) => { - const altDe = PURE_CVD_GREEDY[i]; - const gain = altDe - de; - return ( - - {i + 2} - - {de.toFixed(2)} - - 0 ? '#007A59' : 'var(--ink-muted)', - fontWeight: gain > 0 ? 600 : 400, - }} - > - {altDe.toFixed(2)} - {gain > 0 - ? ` (+${gain.toFixed(2)})` - : gain < 0 - ? ` (${gain.toFixed(2)})` - : ' (=)'} - - - ); - })} - - - - - - - trade-off: - {' '} - the first 4 slots end up with two greens (brand at slot 0 + lime at slot 2) and - two purples (lavender at slot 1 + rose at slot 3) side by side — the eye loses the - “four distinct hue families” cue that imprint default gives you. it - gains +1.22–5.83 ΔE under CVD at n=3..5 (most at n=4); n=2 and n=6..8 are - identical, since both sortings ship the same 8 hexes. - - - - when to switch: - {' '} - CVD-first audience + small-n (n ≤ 4) + colour is the only encoding. otherwise the - imprint default is the better balance — and at n = 7..8 you should add a marker - shape or linestyle either way. - - - )} - - {sortedPalette.map((s, i) => { - const copied = copiedHex === s.hex; - return ( - copyHex(s.hex)} - sx={{ - display: 'flex', - flexDirection: 'column', - background: 'none', - border: 'none', - padding: 0, - cursor: 'pointer', - textAlign: 'left', - '&:hover .v3-sw': { transform: 'scale(1.02)' }, - }} - > - - - {s.name} · {s.hex} - - - hue · chroma - - {s.H.toFixed(0)}° · {s.C.toFixed(2)}{' '} - - OKLCH - - - - nearest ΔE - - {s.minNorm.toFixed(1)} - normal vision - {s.minCvd.toFixed(1)} - colour-blind - - - contrast - - {s.wcagL.toFixed(1)}:1 - on cream - {s.wcagD.toFixed(1)}:1 - on dark - - - - higher ΔE = easier to tell apart - - - } - > - - - {s.name} - - - {copied ? 'copied ✓' : s.hex} - - - - {/* Only the slot + role stay under the swatch; H/C, ΔE and - contrast moved into the swatch tooltip (and remain in - full in the ΔE matrix + WCAG table below). */} - - - slot {i} - {i === 0 ? ' ★' : ''} - - {' · '} - {s.role} - - - ); - })} - - - + {/* 3. SEMANTIC ANCHORS */} - - semantic anchors} /> - - - three additional anchors that live outside the categorical pool. they - are never returned by palette[:n] — reached only by name. two of them - flip per theme. - - - {ANCHORS.map(a => ( - - {/* One clickable swatch per hex (amber = 1, adaptive = light + dark); - each shows its hex and copies it on click. */} - - {(a.hexDark ? [a.hexLight, a.hexDark] : [a.hexLight]).map(hx => { - const copied = copiedHex === hx; - return ( - copyHex(hx)} - sx={{ - flex: 1, - border: 'none', - p: 0, - pb: 0.75, - bgcolor: hx, - color: textOn(hx), - display: 'flex', - alignItems: 'flex-end', - justifyContent: 'center', - fontFamily: typography.mono, - fontSize: '11px', - cursor: 'pointer', - transition: 'filter 0.15s', - '&:hover': { filter: 'brightness(1.06)' }, - }} - > - {copied ? 'copied ✓' : hx} - - ); - })} - - - - {a.role} - - - palette.{a.key} - {a.hexDark ? ' — adaptive' : ''} - - - {a.hint} - - - - ))} - - - + {/* 3b. CONTINUOUS CMAPS — sequential + diverging */} - - continuous cmaps} /> - - - for continuous data the categorical pool is replaced with two palette-derived - colormaps. - - - {/* Sequential */} - - - - imprint_seq - - - single-polarity · density / magnitude - - - - - #009E73 brand-green - #4467A3 blue - - - {/* Diverging */} - - - - imprint_div - - - diverging · correlations / signed deviations - - - - - #AE3030 red - theme-adaptive midpoint - #4467A3 blue - - - - - the diverging midpoint flips per theme — #FAF8F1 on cream bg /{' '} - #1A1A17 - on warm-near-black — so the zero point reads as part of the page rather than as a grey - blob. - - - + {/* 4. COPY SNIPPETS */} - - copy & paste} /> - - - setLang(v as Lang)} - variant="scrollable" - scrollButtons="auto" - sx={{ - minHeight: 36, - '& .MuiTab-root': { - fontFamily: typography.mono, - fontSize: '11px', - minHeight: 36, - py: 0, - textTransform: 'none', - color: 'var(--ink-muted)', - }, - '& .Mui-selected': { color: 'var(--ink)' }, - '& .MuiTabs-indicator': { backgroundColor: colors.primary }, - }} - > - {(Object.keys(LANG_LABELS) as Lang[]).map(l => ( - - ))} - - setOklch(e.target.checked)} - sx={{ - '& .MuiSwitch-switchBase.Mui-checked': { color: colors.primary }, - '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { - backgroundColor: colors.primary, - }, - }} - /> - } - label={ - - OKLCH - - } - /> - - - - + {/* 5. CONTRAST (WCAG) */} - - WCAG contrast} /> - - - muted palettes share a known limit: the lighter members carry their distinguishability - through chroma, not L-spread. On cream bg #F5F3EC, four categorical hues - + amber fall under WCAG 2.1 SC 1.4.11's 3:1 minimum. The industry-standard fix is - a 1px ink-color stroke on affected series — but it's a renderer - judgment call, not a hard rule. the swatches below carry that stroke wherever a series - fails in the current theme. - - - - - - hex - name - on cream - on dark - - - - {WCAG_TABLE.map(row => { - // Live demo of the intro's fix: in the current theme the failing (✗) series get - // the 1px ink-color stroke on their swatch. The affected set flips per theme (light - // hues fail on cream, dark hues fail on dark) and --ink adapts, so each swatch shows - // the fix exactly as it would render. - const affected = isDark ? row.dark < 3 : row.light < 3; - return ( - - - - {row.hex} - - {row.name} - - {row.light.toFixed(2)}:1 {row.light < 3 ? '✗' : '✓'} - - - {row.dark.toFixed(2)}:1 {row.dark < 3 ? '✗' : '✓'} - - - ); - })} - - - - - + {/* 6. ΔE COLOUR DISTANCE — pairwise distances generated by scripts/palette-matrix-export.py (CAM02-UCS + Machado-2009 CVD). */} - - ΔE colour distance} /> - - - the numbers behind each hue's norm / cvd readout, in - full — pairwise ΔE in CAM02-UCS for the 8 hues plus the amber and neutral anchors. - lower = closer = harder to tell apart. the worst-CVD grid takes the lowest of the - deuteranopia / protanopia / tritanopia simulations per pair; hover any cell for the - breakdown. - - - - - - - - {DE_LEGEND.map(l => ( - - - {l.label} - - ))} - - - thresholds: Petroff 2021 · amber + neutral (light-theme ink) sit outside the - categorical pool - - - - + {/* 7. HISTORY */} - - lineage} /> - - - three versions, each fixing a concrete failure of the last. the interactive hue–chroma - wheel at the top of the page overlays any of these against imprint. - - - version timeline - - - {HISTORY.map(entry => ( - - - - {entry.title} - - {entry.href && ( - - {entry.hrefLabel ?? entry.href} - - )} - - - {entry.hexes.map(hx => ( - - ))} - - - {entry.summary} - - - ))} - - - {/* Credit / inspiration + method references */} - - colour-harmony and chroma input from Anselm Hahn, whose eye for colour shows in his{' '} - - dracula-palette - {' '} - generator. perceptual distances in CAM02-UCS (Petroff 2021); CVD via Machado et al. - 2009. - - - + ); diff --git a/app/src/sections/palette/ChromaWheel.tsx b/app/src/sections/palette/ChromaWheel.tsx new file mode 100644 index 00000000000..941959dab38 --- /dev/null +++ b/app/src/sections/palette/ChromaWheel.tsx @@ -0,0 +1,238 @@ +import { useEffect, useRef } from 'react'; + +import Box from '@mui/material/Box'; + +import { oklchToRgb, starPoints } from 'src/sections/palette/paletteHelpers'; +import type { WheelDot } from 'src/sections/palette/types'; + +const MAX_WHEEL_CHROMA = 0.28; // clamp for radial position normalisation across palettes +const WHEEL_RING_L = 0.72; // fixed OKLCH lightness for the hue-ring disk + +export function ChromaWheel({ + size = 320, + imprintDots, + overlay, + compact = false, +}: { + size?: number; + imprintDots: WheelDot[]; + overlay?: WheelDot[]; + /** Compact preview: hide angle ticks/labels, scale dots down, use more disk. */ + compact?: boolean; +}) { + const cx = size / 2; + const cy = size / 2; + // The coloured disk runs to outerR; only a thin margin is left (just enough + // for the angle labels in full mode). No second border ring beyond it. + const outerR = size / 2 - (compact ? 4 : 22); + const dotR = Math.min(7.5, Math.max(3, size * 0.024)); // scale with size so the small wheel stays clean + const canvasRef = useRef(null); + const lastPaintRef = useRef(''); // skip redundant repaints (StrictMode double-invoke / same-size re-render) + + // Project (hue°, chroma) → (x, y). Hue 0° at right (3 o'clock), increasing + // counter-clockwise — matching the per-pixel disk painted below. + const project = (H: number, C: number): { x: number; y: number } => { + const rad = (H * Math.PI) / 180; + const r = Math.min(C / MAX_WHEEL_CHROMA, 1) * outerR; + return { x: cx + Math.cos(rad) * r, y: cy - Math.sin(rad) * r }; + }; + + // Paint the hue ring per-pixel into a canvas: every pixel at (r, θ) is + // oklch(WHEEL_RING_L, (r/outerR)·MAX_CHROMA, θ). Continuous → no facets, and + // the chroma genuinely fades to neutral grey at the centre (matches the + // matplotlib reference wheel rather than faking the fade with a bg overlay). + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + // The per-pixel disk is the same for a given (size, outerR); skip the + // O(n²) trig repaint when nothing that affects it changed. + const dpr = Math.min(window.devicePixelRatio || 1, 2); + // Key includes dpr so a zoom / display change repaints at the right resolution. + const paintKey = `${size}|${outerR}|${dpr}`; + if (lastPaintRef.current === paintKey) return; + const px = Math.round(size * dpr); + canvas.width = px; + canvas.height = px; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + const img = ctx.createImageData(px, px); + const data = img.data; + const r0 = outerR * dpr; + const cxp = cx * dpr; + const cyp = cy * dpr; + for (let y = 0; y < px; y++) { + for (let x = 0; x < px; x++) { + const dx = x - cxp; + const dy = y - cyp; + const r = Math.hypot(dx, dy); + const idx = (y * px + x) * 4; + if (r > r0) continue; // leave fully transparent outside the disk + let theta = (Math.atan2(-dy, dx) * 180) / Math.PI; + if (theta < 0) theta += 360; + const c = Math.min(r / r0, 1) * MAX_WHEEL_CHROMA; + const [rr, gg, bb] = oklchToRgb(WHEEL_RING_L, c, theta); + data[idx] = rr; + data[idx + 1] = gg; + data[idx + 2] = bb; + data[idx + 3] = r > r0 - dpr ? Math.round((255 * (r0 - r)) / dpr) : 255; // 1px AA edge + } + } + ctx.putImageData(img, 0, 0); + lastPaintRef.current = paintKey; + }, [size, outerR, cx, cy]); + + // Chroma envelope — the band all imprint hues fall inside (low-chroma corridor). + const cs = imprintDots.map(d => d.C); + const rInner = (Math.min(...cs) / MAX_WHEEL_CHROMA) * outerR; + const rOuter = (Math.max(...cs) / MAX_WHEEL_CHROMA) * outerR; + const rMid = (rInner + rOuter) / 2; + const ticks = compact ? [] : [0, 90, 180, 270]; + + return ( + + + + {/* Crisp ring exactly at the coloured-disk edge — no outer moat / second rim */} + + + {/* Low-chroma corridor: shaded annulus + dashed edges */} + + + + + {/* Angle ticks + labels (0° = 3 o'clock, CCW) */} + {ticks.map(deg => { + const rad = (deg * Math.PI) / 180; + const x1 = cx + Math.cos(rad) * outerR; + const y1 = cy - Math.sin(rad) * outerR; + const x2 = cx + Math.cos(rad) * (outerR + 5); + const y2 = cy - Math.sin(rad) * (outerR + 5); + const lx = cx + Math.cos(rad) * (outerR + 15); + const ly = cy - Math.sin(rad) * (outerR + 15); + return ( + + + + {deg}° + + + ); + })} + + {/* Overlay palette (hollow rings) — below imprint dots so imprint stays dominant */} + {overlay && + overlay.map((d, i) => { + const { x, y } = project(d.H, d.C); + return ( + + {d.hex} + + + + ); + })} + + {/* Imprint dots — white halo + filled dot; slot 0 (brand) drawn as a star */} + {imprintDots.map((d, i) => { + const { x, y } = project(d.H, d.C); + const label = d.name ? `${d.name} · ${d.hex}` : d.hex; + if (i === 0) { + return ( + + {`★ ${label}`} + + + ); + } + return ( + + {label} + + + + ); + })} + + + ); +} diff --git a/app/src/sections/palette/CodeBlock.tsx b/app/src/sections/palette/CodeBlock.tsx new file mode 100644 index 00000000000..d69b250a470 --- /dev/null +++ b/app/src/sections/palette/CodeBlock.tsx @@ -0,0 +1,61 @@ +import { useState } from 'react'; + +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import Box from '@mui/material/Box'; +import IconButton from '@mui/material/IconButton'; +import Tooltip from '@mui/material/Tooltip'; + +import { tooltipSlotProps } from 'src/sections/palette/paletteHelpers'; +import { colors, typography } from 'src/theme'; + +export function CodeBlock({ code }: { code: string }) { + const [copied, setCopied] = useState(false); + const handleCopy = () => { + void navigator.clipboard + .writeText(code) + .then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }) + .catch(() => { + // Silently ignore — clipboard API can fail in insecure contexts. + }); + }; + return ( + + + {code} + + + + + + + + ); +} diff --git a/app/src/sections/palette/ContinuousCmapsSection.tsx b/app/src/sections/palette/ContinuousCmapsSection.tsx new file mode 100644 index 00000000000..21f3dffdfac --- /dev/null +++ b/app/src/sections/palette/ContinuousCmapsSection.tsx @@ -0,0 +1,146 @@ +import Box from '@mui/material/Box'; + +import { SectionHeader } from 'src/components/SectionHeader'; +import { proseColumnSx, sectionSx } from 'src/sections/palette/paletteHelpers'; +import { textStyle, typography } from 'src/theme'; + +export function ContinuousCmapsSection() { + return ( + + continuous cmaps} /> + + + for continuous data the categorical pool is replaced with two palette-derived colormaps. + + + {/* Sequential */} + + + + imprint_seq + + + single-polarity · density / magnitude + + + + + #009E73 brand-green + #4467A3 blue + + + {/* Diverging */} + + + + imprint_div + + + diverging · correlations / signed deviations + + + + + #AE3030 red + theme-adaptive midpoint + #4467A3 blue + + + + + the diverging midpoint flips per theme — #FAF8F1 on cream bg /{' '} + #1A1A17 + on warm-near-black — so the zero point reads as part of the page rather than as a grey + blob. + + + + ); +} diff --git a/app/src/sections/palette/CopySnippetsSection.tsx b/app/src/sections/palette/CopySnippetsSection.tsx new file mode 100644 index 00000000000..e0da2bc9d73 --- /dev/null +++ b/app/src/sections/palette/CopySnippetsSection.tsx @@ -0,0 +1,107 @@ +import Box from '@mui/material/Box'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Switch from '@mui/material/Switch'; +import Tab from '@mui/material/Tab'; +import Tabs from '@mui/material/Tabs'; + +import { SectionHeader } from 'src/components/SectionHeader'; +import { type Lang, snippet, type Swatch } from 'src/pages/PalettePage.helpers'; +import { CodeBlock } from 'src/sections/palette/CodeBlock'; +import { proseColumnSx, sectionSx } from 'src/sections/palette/paletteHelpers'; +import { colors, typography } from 'src/theme'; + +// ───────────────────────────────────────────────────────────────────────────── +// Code snippets per language +// ───────────────────────────────────────────────────────────────────────────── + +const LANG_LABELS: Record = { + python: 'Python', + r: 'R', + julia: 'Julia', + js: 'JavaScript', +}; + +export interface CopySnippetsSectionProps { + lang: Lang; + setLang: (lang: Lang) => void; + oklch: boolean; + setOklch: (oklch: boolean) => void; + sortedPalette: Swatch[]; +} + +export function CopySnippetsSection({ + lang, + setLang, + oklch, + setOklch, + sortedPalette, +}: CopySnippetsSectionProps) { + return ( + + copy & paste} /> + + + setLang(v as Lang)} + variant="scrollable" + scrollButtons="auto" + sx={{ + minHeight: 36, + '& .MuiTab-root': { + fontFamily: typography.mono, + fontSize: '11px', + minHeight: 36, + py: 0, + textTransform: 'none', + color: 'var(--ink-muted)', + }, + '& .Mui-selected': { color: 'var(--ink)' }, + '& .MuiTabs-indicator': { backgroundColor: colors.primary }, + }} + > + {(Object.keys(LANG_LABELS) as Lang[]).map(l => ( + + ))} + + setOklch(e.target.checked)} + sx={{ + '& .MuiSwitch-switchBase.Mui-checked': { color: colors.primary }, + '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { + backgroundColor: colors.primary, + }, + }} + /> + } + label={ + + OKLCH + + } + /> + + + + + ); +} diff --git a/app/src/sections/palette/DeltaEMatrix.tsx b/app/src/sections/palette/DeltaEMatrix.tsx new file mode 100644 index 00000000000..11bdffddb04 --- /dev/null +++ b/app/src/sections/palette/DeltaEMatrix.tsx @@ -0,0 +1,114 @@ +import Box from '@mui/material/Box'; + +import matrixData from 'src/data/paletteMatrices.json'; +import { DE_T_BAD, DE_T_WARN, deltaECellBg } from 'src/sections/palette/paletteHelpers'; +import { typography } from 'src/theme'; + +// ───────────────────────────────────────────────────────────────────────────── +// ΔE distance matrix — generated by scripts/palette-matrix-export.py +// ───────────────────────────────────────────────────────────────────────────── + +const MATRIX_LABELS: string[] = matrixData.labels; +const MATRIX_HEXES: string[] = matrixData.hexes; + +export function DeltaEMatrix({ + title, + matrix, + breakdown, +}: { + title: string; + matrix: number[][]; + breakdown?: { label: string; m: number[][] }[]; +}) { + const dot = (hx: string, label: string) => ( + + ); + return ( + + + {title} + + + + + + + {MATRIX_HEXES.map((hx, j) => ( + + {dot(hx, MATRIX_LABELS[j])} + + ))} + + + + {matrix.map((row, i) => ( + + + {dot(MATRIX_HEXES[i], MATRIX_LABELS[i])} + + {row.map((v, j) => { + const tip = + i === j + ? MATRIX_LABELS[i] + : breakdown + ? `${MATRIX_LABELS[i]} ↔ ${MATRIX_LABELS[j]} — ${breakdown.map(b => `${b.label} ${b.m[i][j].toFixed(1)}`).join(' · ')}` + : `${MATRIX_LABELS[i]} ↔ ${MATRIX_LABELS[j]} — ΔE ${v.toFixed(1)}`; + return ( + 0 && v < DE_T_BAD ? 700 : 400, + cursor: 'help', + }} + > + {i === j ? '·' : v.toFixed(0)} + + ); + })} + + ))} + + + + + ); +} diff --git a/app/src/sections/palette/DeltaESection.tsx b/app/src/sections/palette/DeltaESection.tsx new file mode 100644 index 00000000000..39464bc29fd --- /dev/null +++ b/app/src/sections/palette/DeltaESection.tsx @@ -0,0 +1,72 @@ +import Box from '@mui/material/Box'; + +import { SectionHeader } from 'src/components/SectionHeader'; +import matrixData from 'src/data/paletteMatrices.json'; +import { DeltaEMatrix } from 'src/sections/palette/DeltaEMatrix'; +import { proseColumnSx, sectionSx } from 'src/sections/palette/paletteHelpers'; +import { textStyle, typography } from 'src/theme'; + +const DE_LEGEND: { label: string; bg: string }[] = [ + { label: '<5 indistinct', bg: 'rgba(174, 48, 48, 0.26)' }, + { label: '<10 weak', bg: 'rgba(189, 130, 51, 0.22)' }, + { label: '<15 acceptable', bg: 'rgba(221, 204, 119, 0.18)' }, + { label: '≥15 comfortable', bg: 'transparent' }, +]; + +export function DeltaESection() { + return ( + + ΔE colour distance} /> + + + the numbers behind each hue's norm / cvd readout, in full — + pairwise ΔE in CAM02-UCS for the 8 hues plus the amber and neutral anchors. lower = closer + = harder to tell apart. the worst-CVD grid takes the lowest of the deuteranopia / + protanopia / tritanopia simulations per pair; hover any cell for the breakdown. + + + + + + + + {DE_LEGEND.map(l => ( + + + {l.label} + + ))} + + + thresholds: Petroff 2021 · amber + neutral (light-theme ink) sit outside the categorical + pool + + + + + ); +} diff --git a/app/src/sections/palette/HeroSection.tsx b/app/src/sections/palette/HeroSection.tsx new file mode 100644 index 00000000000..8b3dfeea7a3 --- /dev/null +++ b/app/src/sections/palette/HeroSection.tsx @@ -0,0 +1,180 @@ +import type { Dispatch, SetStateAction } from 'react'; + +import Box from '@mui/material/Box'; +import Collapse from '@mui/material/Collapse'; + +import { SectionHeader } from 'src/components/SectionHeader'; +import { ChromaWheel } from 'src/sections/palette/ChromaWheel'; +import { COMPARISONS, proseColumnSx, sectionSx } from 'src/sections/palette/paletteHelpers'; +import type { WheelDot } from 'src/sections/palette/types'; +import { colors, textStyle, typography } from 'src/theme'; + +export interface HeroSectionProps { + wheelOpen: boolean; + setWheelOpen: Dispatch>; + imprintDots: WheelDot[]; + overlayDots: WheelDot[] | undefined; + compareId: string | null; + setCompareId: Dispatch>; +} + +export function HeroSection({ + wheelOpen, + setWheelOpen, + imprintDots, + overlayDots, + compareId, + setCompareId, +}: HeroSectionProps) { + return ( + + anyplot's imprint palette} /> + + {/* sm+: wheel on the left, content (lede + comparison) on the right. + When expanded the comparison panel fills the column beside the + wheel rather than floating below it, so the tall wheel is balanced. */} + + {/* Wheel column — the button toggles the expanded comparison view */} + + setWheelOpen(o => !o)} + aria-expanded={wheelOpen} + aria-label={ + wheelOpen + ? 'Collapse the hue–chroma wheel' + : 'Expand the hue–chroma wheel to compare palettes' + } + sx={{ + background: 'none', + border: 'none', + p: 0, + m: 0, + cursor: 'pointer', + lineHeight: 0, + borderRadius: '50%', + transition: 'transform 0.15s', + '&:hover': { transform: 'scale(1.03)' }, + '&:focus-visible': { + outline: `2px solid ${colors.primary}`, + outlineOffset: '3px', + }, + }} + > + + + + {wheelOpen ? 'click to close' : 'compare ▸'} + + + + {/* Content column — lede, then the comparison panel when expanded */} + + + imprint is anyplot's categorical palette — 8 hues plus 3 + semantic anchors. low-chroma and warm-tinted for cream paper, the same hues on dark, + validated against deuteranopia / protanopia / tritanopia. built to let the data speak. + + + + + + where imprint sits in hue–chroma space — eight hues inside a narrow low-chroma + band, spread evenly around the wheel. overlay another published categorical + palette to compare: + + + {COMPARISONS.map(c => { + const active = compareId === c.id; + return ( + setCompareId(active ? null : c.id)} + sx={{ + background: active ? 'var(--bg-surface)' : 'none', + border: `1px solid ${active ? colors.primary : 'var(--rule)'}`, + borderRadius: 1, + padding: '6px 10px', + fontFamily: typography.mono, + fontSize: '11px', + color: active ? colors.primary : 'var(--ink-soft)', + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + gap: 1, + '&:hover': { borderColor: colors.primary, color: colors.primary }, + }} + > + + {c.hexes.map(d => ( + + ))} + + {c.name} + + ); + })} + + + hue counter-clockwise from 3 o'clock · chroma = distance from centre · ★ = + brand · shaded band = imprint's low-chroma corridor + {compareId && ( + + {' '} + · rings = {COMPARISONS.find(c => c.id === compareId)?.name} + + )} + + + + + + + + ); +} diff --git a/app/src/sections/palette/LineageSection.tsx b/app/src/sections/palette/LineageSection.tsx new file mode 100644 index 00000000000..36bbb5f5b6c --- /dev/null +++ b/app/src/sections/palette/LineageSection.tsx @@ -0,0 +1,150 @@ +import Box from '@mui/material/Box'; + +import { SectionHeader } from 'src/components/SectionHeader'; +import { proseColumnSx, sectionSx } from 'src/sections/palette/paletteHelpers'; +import type { HistoryEntry } from 'src/sections/palette/types'; +import { colors, textStyle, typography } from 'src/theme'; + +const HISTORY: HistoryEntry[] = [ + { + id: 'v3', + title: 'v3 — imprint (current)', + hexes: ['#009E73', '#C475FD', '#4467A3', '#BD8233', '#AE3030', '#2ABCCD', '#954477', '#99B314'], + summary: + "the shipping palette — v1's hues pulled into an even tighter, lower-chroma band so the set reads editorial rather than dashboard and nothing washes out on cream. all six primary hue families are present — red / yellow / green / cyan / blue / purple — which is also why the palette doubles as the site's code-syntax coloring — every token type gets its own colourblind-safe hue. the first four slots span four distinct hue families, and semantic red is deferred to slot 4 so it stays a free named anchor for bad / loss / error instead of landing in every 3-series chart. plus 3 anchors outside the pool: amber, neutral, muted.", + }, + { + id: 'v1', + title: 'v1 — variant D ("balanced")', + hexes: ['#009E73', '#9418DB', '#B71D27', '#16B8F3', '#99B314', '#D359A7', '#BA843E'], + summary: + 'anyplot\'s first custom palette — the last one live before v3, and what "anyplot v1 (previous live)" overlays on the wheel above. 7 hues + adaptive neutral. Brand green inherited from Okabe-Ito; the rest from a Petroff-style max-min ΔE search in the muted-paper chroma envelope — separating red from tan and trading the near-yellow for olive-lime so nothing washes out on cream.', + href: 'https://arxiv.org/abs/2107.02270', + hrefLabel: 'Petroff 2021 (arXiv)', + }, + { + id: 'v0', + title: 'v0 — Okabe-Ito', + hexes: ['#009E73', '#D55E00', '#0072B2', '#CC79A7', '#E69F00', '#56B4E9', '#F0E442'], + summary: + 'The original colorblind-safe categorical palette (Wong 2011, Nature Methods). 7 hues + adaptive neutral, brand green at slot 0 — inherited unchanged by every version since. Its limits on warm paper: orange and vermillion sit only ~30° apart on the hue wheel, and the yellow washes out on cream — both fixed by the later variants.', + href: 'https://jfly.uni-koeln.de/color/', + hrefLabel: 'jfly.uni-koeln.de', + }, +]; + +export function LineageSection() { + return ( + + lineage} /> + + + three versions, each fixing a concrete failure of the last. the interactive hue–chroma + wheel at the top of the page overlays any of these against imprint. + + + version timeline + + + {HISTORY.map(entry => ( + + + + {entry.title} + + {entry.href && ( + + {entry.hrefLabel ?? entry.href} + + )} + + + {entry.hexes.map(hx => ( + + ))} + + + {entry.summary} + + + ))} + + + {/* Credit / inspiration + method references */} + + colour-harmony and chroma input from Anselm Hahn, whose eye for colour shows in his{' '} + + dracula-palette + {' '} + generator. perceptual distances in CAM02-UCS (Petroff 2021); CVD via Machado et al. 2009. + + + + ); +} diff --git a/app/src/sections/palette/PaletteGridSection.tsx b/app/src/sections/palette/PaletteGridSection.tsx new file mode 100644 index 00000000000..d21aa352351 --- /dev/null +++ b/app/src/sections/palette/PaletteGridSection.tsx @@ -0,0 +1,339 @@ +import Box from '@mui/material/Box'; +import Tooltip from '@mui/material/Tooltip'; + +import { SectionHeader } from 'src/components/SectionHeader'; +import type { Swatch } from 'src/pages/PalettePage.helpers'; +import { + proseColumnSx, + sectionSx, + textOn, + tooltipSlotProps, +} from 'src/sections/palette/paletteHelpers'; +import { colors, fontSize, typography } from 'src/theme'; + +const HYBRID_V3_CVD: number[] = [36.19, 16.34, 13.98, 13.98, 13.7, 10.7, 8.81]; +const PURE_CVD_GREEDY: number[] = [36.19, 21.45, 19.81, 15.2, 13.7, 10.7, 8.81]; + +export interface PaletteGridSectionProps { + sort: 'imprint' | 'cvd'; + setSort: (sort: 'imprint' | 'cvd') => void; + sortedPalette: Swatch[]; + copiedHex: string | null; + copyHex: (hex: string) => void; +} + +export function PaletteGridSection({ + sort, + setSort, + sortedPalette, + copiedHex, + copyHex, +}: PaletteGridSectionProps) { + return ( + + the 8 categorical hues} /> + + + + sort: + {(['imprint', 'cvd'] as const).map(s => ( + setSort(s)} + sx={{ + background: 'none', + border: 'none', + padding: 0, + ml: 1, + fontFamily: typography.mono, + fontSize: fontSize.base, + fontWeight: 300, + color: sort === s ? colors.primary : 'var(--ink-soft)', + textDecoration: 'underline', + textDecorationColor: sort === s ? colors.primary : 'var(--rule)', + cursor: 'pointer', + }} + > + {s === 'imprint' + ? 'imprint (default)' + : 'CVD-optimal (max ΔE under colorblindness)'} + + ))} + + + {sort === 'cvd' && ( + + + why use CVD-optimal sort? + + + this ordering picks each next slot by maximising ΔE under the worst of deuteranopia / + protanopia / tritanopia simulation against the already-placed set. it's the best + choice when colour alone has to carry the categorical encoding for users with + red-green colour vision deficiency — e.g. small-n charts in publications targeting + CVD-first audiences, status-only legends, or signage-style summary tiles. + + + {/* Compact per-n table — only shown in CVD mode since that's where the trade-off matters */} + + + + + n series + imprint default + CVD-optimal (active) + + + + {HYBRID_V3_CVD.map((de, i) => { + const altDe = PURE_CVD_GREEDY[i]; + const gain = altDe - de; + return ( + + {i + 2} + + {de.toFixed(2)} + + 0 ? '#007A59' : 'var(--ink-muted)', + fontWeight: gain > 0 ? 600 : 400, + }} + > + {altDe.toFixed(2)} + {gain > 0 + ? ` (+${gain.toFixed(2)})` + : gain < 0 + ? ` (${gain.toFixed(2)})` + : ' (=)'} + + + ); + })} + + + + + + + trade-off: + {' '} + the first 4 slots end up with two greens (brand at slot 0 + lime at slot 2) and two + purples (lavender at slot 1 + rose at slot 3) side by side — the eye loses the + “four distinct hue families” cue that imprint default gives you. it gains + +1.22–5.83 ΔE under CVD at n=3..5 (most at n=4); n=2 and n=6..8 are identical, since + both sortings ship the same 8 hexes. + + + + when to switch: + {' '} + CVD-first audience + small-n (n ≤ 4) + colour is the only encoding. otherwise the + imprint default is the better balance — and at n = 7..8 you should add a marker shape + or linestyle either way. + + + )} + + {sortedPalette.map((s, i) => { + const copied = copiedHex === s.hex; + return ( + copyHex(s.hex)} + sx={{ + display: 'flex', + flexDirection: 'column', + background: 'none', + border: 'none', + padding: 0, + cursor: 'pointer', + textAlign: 'left', + '&:hover .v3-sw': { transform: 'scale(1.02)' }, + }} + > + + + {s.name} · {s.hex} + + + hue · chroma + + {s.H.toFixed(0)}° · {s.C.toFixed(2)}{' '} + + OKLCH + + + + nearest ΔE + + {s.minNorm.toFixed(1)} + normal vision + {s.minCvd.toFixed(1)} + colour-blind + + + contrast + + {s.wcagL.toFixed(1)}:1 + on cream + {s.wcagD.toFixed(1)}:1 + on dark + + + higher ΔE = easier to tell apart + + } + > + + {s.name} + + {copied ? 'copied ✓' : s.hex} + + + + {/* Only the slot + role stay under the swatch; H/C, ΔE and + contrast moved into the swatch tooltip (and remain in + full in the ΔE matrix + WCAG table below). */} + + + slot {i} + {i === 0 ? ' ★' : ''} + + {' · '} + {s.role} + + + ); + })} + + + + ); +} diff --git a/app/src/sections/palette/SemanticAnchorsSection.tsx b/app/src/sections/palette/SemanticAnchorsSection.tsx new file mode 100644 index 00000000000..418a5d7a098 --- /dev/null +++ b/app/src/sections/palette/SemanticAnchorsSection.tsx @@ -0,0 +1,133 @@ +import Box from '@mui/material/Box'; + +import { SectionHeader } from 'src/components/SectionHeader'; +import { proseColumnSx, sectionSx, textOn } from 'src/sections/palette/paletteHelpers'; +import type { Anchor } from 'src/sections/palette/types'; +import { textStyle, typography } from 'src/theme'; + +const ANCHORS: Anchor[] = [ + { + key: 'amber', + hexLight: '#DDCC77', + role: 'warning / caution', + hint: 'Chosen for max ΔE under CVD against lime, so it stays distinct under deuteranopia.', + }, + { + key: 'neutral', + hexLight: '#1A1A17', + hexDark: '#F0EFE8', + role: 'totals / baseline / outline', + hint: "Theme-adaptive ink. Same hex as text and gridlines — the series reads as part of the chart's structural layer.", + }, + { + key: 'muted', + hexLight: '#6B6A63', + hexDark: '#A8A79F', + role: 'other / rest / disabled', + hint: 'Theme-adaptive ink-muted. For "other" / "rest" slices, disabled series, confidence-band fills.', + }, +]; + +export interface SemanticAnchorsSectionProps { + copiedHex: string | null; + copyHex: (hex: string) => void; +} + +export function SemanticAnchorsSection({ copiedHex, copyHex }: SemanticAnchorsSectionProps) { + return ( + + semantic anchors} /> + + + three additional anchors that live outside the categorical pool. they are + never returned by palette[:n] — reached only by name. two of them flip per + theme. + + + {ANCHORS.map(a => ( + + {/* One clickable swatch per hex (amber = 1, adaptive = light + dark); + each shows its hex and copies it on click. */} + + {(a.hexDark ? [a.hexLight, a.hexDark] : [a.hexLight]).map(hx => { + const copied = copiedHex === hx; + return ( + copyHex(hx)} + sx={{ + flex: 1, + border: 'none', + p: 0, + pb: 0.75, + bgcolor: hx, + color: textOn(hx), + display: 'flex', + alignItems: 'flex-end', + justifyContent: 'center', + fontFamily: typography.mono, + fontSize: '11px', + cursor: 'pointer', + transition: 'filter 0.15s', + '&:hover': { filter: 'brightness(1.06)' }, + }} + > + {copied ? 'copied ✓' : hx} + + ); + })} + + + + {a.role} + + + palette.{a.key} + {a.hexDark ? ' — adaptive' : ''} + + + {a.hint} + + + + ))} + + + + ); +} diff --git a/app/src/sections/palette/WcagContrastSection.tsx b/app/src/sections/palette/WcagContrastSection.tsx new file mode 100644 index 00000000000..15f9e929ff5 --- /dev/null +++ b/app/src/sections/palette/WcagContrastSection.tsx @@ -0,0 +1,118 @@ +import Box from '@mui/material/Box'; + +import { SectionHeader } from 'src/components/SectionHeader'; +import { proseColumnSx, sectionSx } from 'src/sections/palette/paletteHelpers'; +import { textStyle, typography } from 'src/theme'; + +const WCAG_TABLE: { hex: string; name: string; light: number; dark: number }[] = [ + { hex: '#009E73', name: 'brand-green', light: 3.08, dark: 5.48 }, + { hex: '#AE3030', name: 'matte-red', light: 5.79, dark: 2.92 }, + { hex: '#C475FD', name: 'lavender', light: 2.59, dark: 6.53 }, + { hex: '#99B314', name: 'lime', light: 2.15, dark: 7.87 }, + { hex: '#4467A3', name: 'blue', light: 5.09, dark: 3.32 }, + { hex: '#2ABCCD', name: 'cyan', light: 2.06, dark: 8.19 }, + { hex: '#954477', name: 'rose', light: 5.61, dark: 3.01 }, + { hex: '#BD8233', name: 'ochre', light: 2.95, dark: 5.72 }, + { hex: '#DDCC77', name: 'amber', light: 1.46, dark: 11.59 }, +]; + +export interface WcagContrastSectionProps { + isDark: boolean; +} + +export function WcagContrastSection({ isDark }: WcagContrastSectionProps) { + return ( + + WCAG contrast} /> + + + muted palettes share a known limit: the lighter members carry their distinguishability + through chroma, not L-spread. On cream bg #F5F3EC, four categorical hues + + amber fall under WCAG 2.1 SC 1.4.11's 3:1 minimum. The industry-standard fix is a{' '} + 1px ink-color stroke on affected series — but it's a renderer + judgment call, not a hard rule. the swatches below carry that stroke wherever a series + fails in the current theme. + + + + + + hex + name + on cream + on dark + + + + {WCAG_TABLE.map(row => { + // Live demo of the intro's fix: in the current theme the failing (✗) series get + // the 1px ink-color stroke on their swatch. The affected set flips per theme (light + // hues fail on cream, dark hues fail on dark) and --ink adapts, so each swatch shows + // the fix exactly as it would render. + const affected = isDark ? row.dark < 3 : row.light < 3; + return ( + + + + {row.hex} + + {row.name} + + {row.light.toFixed(2)}:1 {row.light < 3 ? '✗' : '✓'} + + + {row.dark.toFixed(2)}:1 {row.dark < 3 ? '✗' : '✓'} + + + ); + })} + + + + + + ); +} diff --git a/app/src/sections/palette/paletteHelpers.ts b/app/src/sections/palette/paletteHelpers.ts new file mode 100644 index 00000000000..f07ba407e9e --- /dev/null +++ b/app/src/sections/palette/paletteHelpers.ts @@ -0,0 +1,146 @@ +import matrixData from 'src/data/paletteMatrices.json'; +import type { CompPalette } from 'src/sections/palette/types'; + +export const [DE_T_BAD, DE_T_WARN, DE_T_OK] = matrixData.thresholds; // 5 / 10 / 15 (Petroff 2021) + +const OKABE_ITO: CompPalette = { + id: 'okabe', + name: 'Okabe-Ito', + href: 'https://jfly.uni-koeln.de/color/', + hexes: [ + { hex: '#009E73', L: 0.62, C: 0.13, H: 165.5 }, + { hex: '#D55E00', L: 0.621, C: 0.17, H: 47.5 }, + { hex: '#0072B2', L: 0.532, C: 0.131, H: 244.0 }, + { hex: '#CC79A7', L: 0.679, C: 0.118, H: 346.3 }, + { hex: '#E69F00', L: 0.753, C: 0.158, H: 76.8 }, + { hex: '#56B4E9', L: 0.735, C: 0.117, H: 236.2 }, + { hex: '#F0E442', L: 0.902, C: 0.172, H: 105.0 }, + ], +}; + +const TOL_MUTED: CompPalette = { + id: 'tol', + name: 'Tol muted', + href: 'https://personal.sron.nl/~pault/', + hexes: [ + { hex: '#332288', L: 0.347, C: 0.159, H: 281.8 }, + { hex: '#88CCEE', L: 0.812, C: 0.084, H: 230.8 }, + { hex: '#44AA99', L: 0.674, C: 0.098, H: 180.4 }, + { hex: '#117733', L: 0.499, C: 0.135, H: 148.6 }, + { hex: '#999933', L: 0.663, C: 0.123, H: 109.3 }, + { hex: '#DDCC77', L: 0.841, C: 0.108, H: 98.3 }, + { hex: '#CC6677', L: 0.635, C: 0.13, H: 11.0 }, + { hex: '#882255', L: 0.432, C: 0.145, H: 354.5 }, + { hex: '#AA4499', L: 0.553, C: 0.167, H: 334.6 }, + ], +}; + +const SET2: CompPalette = { + id: 'set2', + name: 'ColorBrewer Set2', + href: 'https://colorbrewer2.org', + hexes: [ + { hex: '#66C2A5', L: 0.749, C: 0.099, H: 170.8 }, + { hex: '#FC8D62', L: 0.755, C: 0.147, H: 41.5 }, + { hex: '#8DA0CB', L: 0.707, C: 0.067, H: 266.1 }, + { hex: '#E78AC3', L: 0.748, C: 0.132, H: 343.3 }, + { hex: '#A6D854', L: 0.821, C: 0.169, H: 127.3 }, + { hex: '#FFD92F', L: 0.892, C: 0.173, H: 95.2 }, + { hex: '#E5C494', L: 0.837, C: 0.073, H: 76.8 }, + { hex: '#B3B3B3', L: 0.767, C: 0.0, H: 89.9 }, + ], +}; + +const ANYPLOT_PREV: CompPalette = { + id: 'prev', + name: 'anyplot v1 (previous live)', + hexes: [ + { hex: '#009E73', L: 0.62, C: 0.13, H: 165.5 }, + { hex: '#9418DB', L: 0.529, C: 0.259, H: 307.4 }, + { hex: '#B71D27', L: 0.503, C: 0.187, H: 24.7 }, + { hex: '#16B8F3', L: 0.735, C: 0.145, H: 231.0 }, + { hex: '#99B314', L: 0.722, C: 0.167, H: 119.8 }, + { hex: '#D359A7', L: 0.643, C: 0.176, H: 344.0 }, + { hex: '#BA843E', L: 0.654, C: 0.109, H: 70.8 }, + ], +}; + +export const COMPARISONS: CompPalette[] = [OKABE_ITO, TOL_MUTED, SET2, ANYPLOT_PREV]; + +/** OKLCH (L 0..1, C, H°) → sRGB [r,g,b] 0..255, clamped to gamut. + * Björn Ottosson's OKLab→linear-sRGB matrices. Used to paint the wheel disk + * per-pixel so the hue ring is seamless (no conic-gradient facets). */ +export function oklchToRgb(L: number, C: number, H: number): [number, number, number] { + const h = (H * Math.PI) / 180; + const a = C * Math.cos(h); + const b = C * Math.sin(h); + const l_ = L + 0.3963377774 * a + 0.2158037573 * b; + const m_ = L - 0.1055613458 * a - 0.0638541728 * b; + const s_ = L - 0.0894841775 * a - 1.291485548 * b; + const l = l_ * l_ * l_; + const m = m_ * m_ * m_; + const s = s_ * s_ * s_; + const lin = (c: number) => { + const v = c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055; + return Math.max(0, Math.min(1, v)); + }; + const r = lin(+4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s); + const g = lin(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s); + const bb = lin(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s); + return [Math.round(r * 255), Math.round(g * 255), Math.round(bb * 255)]; +} + +/** 5-point star polygon points string, centred at (cx,cy). */ +export function starPoints(cx: number, cy: number, rOuter: number, rInner: number): string { + const pts: string[] = []; + for (let i = 0; i < 10; i++) { + const r = i % 2 === 0 ? rOuter : rInner; + // start at 12 o'clock, go clockwise + const ang = Math.PI / 2 - (i * Math.PI) / 5; + pts.push(`${(cx + Math.cos(ang) * r).toFixed(2)},${(cy - Math.sin(ang) * r).toFixed(2)}`); + } + return pts.join(' '); +} + +/** Pick a readable text color (ink-dark or ink-light) for a hex bg via WCAG luminance. */ +export function textOn(hex: string): string { + const s = hex.replace('#', ''); + const r = parseInt(s.slice(0, 2), 16) / 255; + const g = parseInt(s.slice(2, 4), 16) / 255; + const b = parseInt(s.slice(4, 6), 16) / 255; + const lin = (c: number) => (c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)); + const L = 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b); + return L > 0.5 ? '#1A1A17' : '#F0EFE8'; +} + +/** Background tint for a ΔE cell — low distance is the problem, so <5 is the + * loudest. Mirrors _palette_common.cell_class thresholds. */ +export function deltaECellBg(v: number): string { + if (v <= 0) return 'transparent'; // diagonal + if (v < DE_T_BAD) return 'rgba(174, 48, 48, 0.26)'; // <5 indistinguishable + if (v < DE_T_WARN) return 'rgba(189, 130, 51, 0.22)'; // <10 weak + if (v < DE_T_OK) return 'rgba(221, 204, 119, 0.18)'; // <15 acceptable + return 'transparent'; // >=15 comfortable +} + +// Theme-adaptive tooltip. MUI's default tooltip is a fixed dark grey that +// ignores the page's light/dark mode, so paint it from the design tokens — +// they cascade from :root / [data-theme="dark"] on into the portal. +export const tooltipSlotProps = { + tooltip: { + sx: { + bgcolor: 'var(--bg-elevated)', + color: 'var(--ink)', + border: '1px solid var(--rule)', + boxShadow: '0 6px 24px rgba(0, 0, 0, 0.18)', + maxWidth: 320, + }, + }, +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Layout +// ───────────────────────────────────────────────────────────────────────────── + +export const sectionSx = { py: { xs: 2, md: 3 } }; +export const proseColumnSx = { maxWidth: 880, mx: 'auto' }; diff --git a/app/src/sections/palette/types.ts b/app/src/sections/palette/types.ts new file mode 100644 index 00000000000..4f4503f7594 --- /dev/null +++ b/app/src/sections/palette/types.ts @@ -0,0 +1,25 @@ +export type CompPalette = { + id: string; + name: string; + href?: string; + hexes: { hex: string; L: number; C: number; H: number }[]; +}; + +export type Anchor = { + key: string; + hexLight: string; + hexDark?: string; + role: string; + hint: string; +}; + +export type HistoryEntry = { + id: string; + title: string; + hexes: string[]; + summary: string; + href?: string; + hrefLabel?: string; +}; + +export type WheelDot = { hex: string; L: number; C: number; H: number; name?: string }; diff --git a/changelog.d/palette-page-split.md b/changelog.d/palette-page-split.md new file mode 100644 index 00000000000..6b78076edd7 --- /dev/null +++ b/changelog.d/palette-page-split.md @@ -0,0 +1,7 @@ +### Changed + +- **PalettePage split into sections.** `PalettePage.tsx` shrinks from 1,783 to + 144 lines: its colour helpers, sub-components (`ChromaWheel`, `CodeBlock`, + `DeltaEMatrix`), and eight numbered sections move into + `app/src/sections/palette/`. The page keeps only state, effects, memos, and + layout, and renders the same markup as before. (#12463)