@kolkrabbi/kol-styleguide 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ColorLab.jsx CHANGED
@@ -1,144 +1,576 @@
1
- import { useState } from 'react'
2
- import { ColorPickerPopover, Dropdown, HARMONIES, PaletteHarmonyWheel, ToggleSwitch, hslToHex } from '@kolkrabbi/kol-component'
3
- import { COLOR_MIX, COLOR_ROLES, COLOR_SLOTS, DEFAULT_COLOR_LAB, deriveColorLab, rgbText } from './colorLab.js'
4
- import { fgOn } from './comboMath.js'
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import { Button, ColorPickerPopover, Dropdown, Icon, IconFrame, PaletteHarmonyWheel, SettingsSections, Slider, Tooltip, hexToHsl, hslToHex } from '@kolkrabbi/kol-component'
3
+ import { COLOR_RELATIONS, CVD_TYPES, DEFAULT_COLOR_LAB, colorLab, colorLabCss, contrastGrade, contrastRatio, hexToOklch, inkOn, midColor, oklchToHex, paletteFromPixels, rollColorLab, simulateCvd, tintLadder } from './colorLab.js'
4
+ import LabWindow, { CopyAction, LabHistory, useLabValue } from './lab/LabWindow.jsx'
5
5
 
6
- /* taxonomy-ok: organism — the color builder: harmony wheel, roles, slots, palette, ramps and the 60/30/10 strip over one value */
6
+ /* taxonomy-ok: organism — the colour generator in the lab window: sources and harmonies in the rail, the colours on the stage, the two accessibility views */
7
7
 
8
8
  /**
9
- * ColorLab — an interactive palette builder from one base color.
9
+ * ColorLab — A brand's colour, generated from one base colour. kol-fxr's engine (`colorLab.js`): colour 1 is the base,
10
+ * every colour given its job by how it looks — the darkest the Background, the lightest the Ink, the most saturated the
11
+ * Accent.
10
12
  *
11
- * Carried from kol-atlas's Color page (2026-10-20). Six sections over ONE value: Harmony (base +
12
- * rule on KOL's wheel), Roles (the harmony's five), Brand slots (four, two dark two light), Palette
13
- * (ten hues 36° apart + blue-grey and grey ramps), Ramps (five blend steps per hue) and Composition
14
- * (60/30/10). Select a slot or a composition share, then click any swatch to fill it.
13
+ * Adobe Color's anatomy, the user's reference (BrandLabToolWindow reviews 1–3, plan-2026-10-29-lab-window-review-1):
14
+ * header three views — Create palette · Contrast checker · Color blindness simulator — then undo · redo
15
+ * palette rail: Base (the picker, its modes HEX · RGB · HSB · Lab, the base's lock) · Image (Adobe's drop zone, then
16
+ * `samples` — images from the bucket, each with its palette — and the `library` link) · Wheel (the wheel, the
17
+ * harmony tiles under it — the DS's `clr-*` glyphs, the mode named; review 6: on the Wheel tab only). Stage:
18
+ * the colours as columns — lock · edit tint (seven steps) · drag · delete, the hex opening Edit color, copy, a +
19
+ * on every seam — then the palette small, Generate random and Copy CSS. 2 to 8 colours, added and removed on
20
+ * the swatches; a hand edit makes the harmony Custom.
21
+ * contrast the same window (review 6 — *"why is it totally different from color lab?"*): the rail's tabs Summary ·
22
+ * Suggestions · Set a ratio (Adobe's three, short enough for the rail) over the pair (foreground and background from the palette, each
23
+ * with its lightness, swap between them, the ratio above); the stage Adobe's preview — the image beside the pair
24
+ * as a page — and the palette small under it
25
+ * vision the rail names the conflicts and plots the palette on the wheel; the stage the palette, then a row per colour
26
+ * vision, a warning where two colours become too close to tell apart; the palette small under it
27
+ * output the palette as a table and CSS. Color Lab owns the palette; Combo Lab combines it.
15
28
  *
16
- * The math is `colorLab.js` (`deriveColorLab`, `colorLabCss`), so a consumer can save the value into
17
- * a brand and emit it as CSS without rendering the lab.
18
- *
19
- * @param {Object} value `{ base, harmony, blueBase, greyBase, blueLinked, slots, picks }` — controlled; omit for internal
29
+ * @param {Object} value `{ base, relation, count, seed, colors, locked }` — controlled; omit for internal
20
30
  * @param {Function} onChange `(next) => void`
31
+ * @param {Array} [samples] `[{ src, label }]` — images to try (the Image tab's "Try one of ours"); `src` must be
32
+ * same-origin or CORS-clean, since the pixels are read (kol-media-client's `proxied(url)`)
33
+ * @param {Object} [library] `{ label, href }` — where the samples live, linked beside them ("Media: R2B2")
21
34
  * @param {string} className classes on the root
22
35
  */
23
- const LABEL = 'kol-helper-10 tracking-widest text-meta uppercase'
24
- const BARE_SWITCH = '[&>.toggle-switch-label]:hidden'
25
- const PICKER = '[&>*]:!size-3'
26
- /* ten columns capped so the 5-row ramp fits the window — a swatch is the same size in every section */
27
- const ROW_MAX = 'calc((100vh - 200px) * 2)'
28
- const isHex = (hex) => /^#[0-9a-f]{6}/i.test(hex)
29
-
30
- function Swatch({ hex, name, selected, onClick, info }) {
31
- return (
32
- <button type="button" onClick={onClick} title={`${name} ${hex}`} className={`group relative aspect-square cursor-pointer overflow-hidden rounded ${selected ? 'ring-2 ring-[var(--kol-accent-primary)]' : ''}`} style={{ background: hex }}>
33
- <span className={`absolute inset-x-0 bottom-0 flex flex-col gap-0.5 bg-surface-primary px-1.5 py-1 text-left kol-mono-10 leading-tight transition-opacity ${info ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`}>
34
- <span className="truncate text-emphasis">{name}</span>
35
- <span className="truncate text-meta">{hex}</span>
36
- <span className="truncate text-meta">{rgbText(hex)}</span>
37
- </span>
38
- </button>
39
- )
40
- }
36
+ const hexToOklchL = (hex) => hexToOklch(hex)[0]
37
+ const isHex = (hex) => /^#[0-9a-f]{6}$/i.test(hex)
38
+ const ratioText = (r) => (Math.floor(r * 100) / 100).toFixed(2)
39
+ const rgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)).join(' ')
41
40
 
42
- const Row = ({ children }) => <div className="grid grid-cols-10 gap-2" style={{ maxWidth: ROW_MAX }}>{children}</div>
41
+ /* THE HARMONIES AS THE DS DRAWS THEM (review 3 — *"I know we have some icons for this in DS"*): kol-icons' signal set,
42
+ * `color` group. `clr-split`, `clr-tet` and `clr-rnd` were drawn 2026-10-29 in the same style, for his review */
43
+ const HARMONY_ICON = { analogous: 'clr-anl', monochromatic: 'clr-mono', complementary: 'clr-comp', split: 'clr-split', triadic: 'clr-tri', tetradic: 'clr-tet', compound: 'clr-cmpd', shades: 'clr-shd', custom: 'clr-rnd' }
44
+ /* Adobe's order (review 5): custom first, then by how far the hues reach */
45
+ const HARMONY_TILES = ['custom', 'analogous', 'monochromatic', 'shades', 'complementary', 'split', 'triadic', 'tetradic', 'compound']
46
+ /* the relations the wheel can draw (kol-component HARMONIES) */
47
+ const WHEEL_HARMONIES = ['analogous', 'complementary', 'split', 'triadic', 'tetradic']
48
+ const harmonyLabel = (id) => (id === 'custom' ? 'Custom' : COLOR_RELATIONS.find((r) => r.id === id)?.label ?? id)
43
49
 
44
- const Switch = ({ label, checked, onChange }) => (
45
- <span className="flex items-center gap-2"><span className={LABEL}>{label}</span><ToggleSwitch size="sm" className={BARE_SWITCH} checked={checked} onChange={onChange} /></span>
50
+ /* a glyph on a column, in the column's ink: kol-component's IconFrame — a click, no button chrome (ref 1) */
51
+ const Glyph = ({ icon, label, ink, ...props }) => (
52
+ <Tooltip label={label}><IconFrame name={icon} variant="ghost" size="sm" aria-label={label} style={{ color: ink, background: 'transparent' }} {...props} /></Tooltip>
46
53
  )
47
54
 
48
- function Section({ title, info, onInfo, extra, children }) {
55
+ /* the + on a seam: a hover zone on the column's edge, the round control centred on the seam */
56
+ const AddSeam = ({ side, label, onClick }) => (
57
+ <span className="group/seam absolute inset-y-0 z-[1] flex w-6 items-center justify-center" style={{ [side]: -13 }}>
58
+ <span className="opacity-0 transition-opacity group-hover/seam:opacity-100 focus-within:opacity-100">
59
+ <Tooltip label={label}><IconFrame name="plus" variant="secondary" radius="full" size="sm" aria-label={label} onClick={onClick} /></Tooltip>
60
+ </span>
61
+ </span>
62
+ )
63
+
64
+ /* the palette's outer corners take the system's 4px (review 3 — *"4px rounding is missing"*) */
65
+ const corners = (i, n) => ({ ...(i === 0 ? { borderTopLeftRadius: 4, borderBottomLeftRadius: 4 } : null), ...(i === n - 1 ? { borderTopRightRadius: 4, borderBottomRightRadius: 4 } : null) })
66
+
67
+ /* ONE COLUMN, ADOBE'S (review 1): the colour edge to edge; top right lock · edit tint · drag · delete; bottom left the hex,
68
+ * which is the "Edit color" button, and copy at the bottom right */
69
+ function Column({ s, n, inks, tint, onTint, onLock, onEdit, onCopy, onDelete, canDelete, drag, add }) {
70
+ const ink = inkOn(s.hex, inks).ink
71
+ if (tint) {
72
+ /* EDIT TINT (review 2 — *"tints are 7, and overlay shows info"*): seven steps, the colour 4th and outlined */
73
+ return (
74
+ <div className="flex min-w-0 flex-1 flex-col overflow-hidden" style={corners(s.index, n)}>
75
+ {tintLadder(s.hex).map((hex, i) => (
76
+ <Tooltip key={`${hex}${i}`} label={`${s.roleLabel}, ${i + 1} of 7, ${hex}`}>
77
+ <button type="button" aria-label={`${s.roleLabel}, ${i + 1} of 7, ${hex}`} onClick={() => onTint(hex)} className="flex-1 cursor-pointer border-0"
78
+ style={{ background: hex, boxShadow: i === 3 ? `inset 0 0 0 2px ${inkOn(hex, inks).ink}, inset 0 0 0 4px ${hex}` : undefined }} />
79
+ </Tooltip>
80
+ ))}
81
+ </div>
82
+ )
83
+ }
49
84
  return (
50
- <section className="flex flex-col gap-3">
51
- <div className="flex items-center gap-6">
52
- <h3 className="m-0 kol-eyebrow text-fg-80">{title}</h3>
53
- {onInfo && <Switch label="Info" checked={info} onChange={onInfo} />}
54
- {extra}
85
+ /* DRAG SHOWS ITSELF (review 2): the dragged column dims; the one under the pointer shows a bar on the side the colour
86
+ * lands; the dropped column is ringed for a moment */
87
+ <div {...drag.target} className={`group relative flex min-w-0 flex-1 flex-col justify-end p-3 transition-opacity ${drag.source ? 'opacity-40' : ''}`}
88
+ style={{ background: s.hex, color: ink, boxShadow: drag.landed ? `inset 0 0 0 2px ${ink}` : undefined, ...corners(s.index, n) }}>
89
+ {drag.side && <span aria-hidden="true" className="absolute inset-y-0 w-0.5" style={{ [drag.side]: -2, background: 'var(--kol-fg-96)' }} />}
90
+ {add.left && <AddSeam side="left" label="Add color left" onClick={add.left} />}
91
+ {add.right && <AddSeam side="right" label="Add color right" onClick={add.right} />}
92
+ <div className={`absolute right-2 top-2 flex flex-col gap-1 transition-opacity ${s.locked ? '' : 'opacity-0 group-hover:opacity-100 focus-within:opacity-100'}`}>
93
+ <Glyph ink={ink} icon={s.locked ? 'lock' : 'unlock'} label={s.locked ? 'Unlock color' : 'Lock color'} onClick={onLock} />
94
+ <span className={s.locked ? 'opacity-0 group-hover:opacity-100 focus-within:opacity-100 flex flex-col gap-1' : 'flex flex-col gap-1'}>
95
+ <Glyph ink={ink} icon="drop" label="Edit tint" onClick={() => onTint(null)} />
96
+ <Glyph ink={ink} icon="drag-handle" label="Drag to reorder" {...drag.handle} />
97
+ {canDelete && <Glyph ink={ink} icon="trash" label="Delete" onClick={onDelete} />}
98
+ </span>
99
+ </div>
100
+ <div className="flex items-center justify-between gap-2">
101
+ <Tooltip label="Edit color">
102
+ <ColorPickerPopover color={s.hex} label="Edit color" title="Edit color" modes placement="top-start" alpha={false}
103
+ className="kol-lab-hex"
104
+ trigger={<span className="kol-mono-12 px-2 py-1">{s.hex}</span>}
105
+ onChange={(hex) => isHex(hex.slice(0, 7)) && onEdit(hex.slice(0, 7).toUpperCase())} />
106
+ </Tooltip>
107
+ <Glyph ink={ink} icon="copy" label="Copy hex" onClick={onCopy} />
55
108
  </div>
56
- {children}
57
- </section>
109
+ </div>
58
110
  )
59
111
  }
60
112
 
61
- export default function ColorLab({ value: valueProp, onChange, className = '' }) {
62
- const [own, setOwn] = useState(DEFAULT_COLOR_LAB)
63
- const value = valueProp ?? own
64
- const set = (patch) => { const next = { ...value, ...patch }; if (valueProp === undefined) setOwn(next); onChange?.(next) }
65
- const [slot, setSlot] = useState(null)
66
- const [info, setInfo] = useState({ roles: false, brand: false, palette: false, ramps: false, mix: false })
67
- const infoFor = (k) => ({ info: info[k], onInfo: (v) => setInfo((x) => ({ ...x, [k]: v })) })
68
- const c = deriveColorLab(value)
69
-
70
- /* a swatch click fills the selected slot (0–3) or composition share (4–6) */
71
- const assign = (hex) => {
72
- if (slot === null) return
73
- if (slot >= 4) set({ picks: [0, 1, 2].map((i) => (i === slot - 4 ? hex : value.picks?.[i] ?? null)) })
74
- else set({ slots: c.slots.map((v, i) => (i === slot ? hex : v)) })
75
- setSlot(null)
113
+ /* the wheel takes the rail's width and follows it as the rail is dragged (review 2) */
114
+ function WheelFill({ children }) {
115
+ const ref = useRef(null)
116
+ const [w, setW] = useState(248)
117
+ useEffect(() => {
118
+ const el = ref.current
119
+ if (!el) return undefined
120
+ const ro = new ResizeObserver(([e]) => setW(Math.max(160, Math.floor(e.contentRect.width))))
121
+ ro.observe(el)
122
+ return () => ro.disconnect()
123
+ }, [])
124
+ return <div ref={ref} className="w-full">{children(w)}</div>
125
+ }
126
+
127
+ /* an image's palette, read off a canvas at 200px (k-means in OKLab, `paletteFromPixels`) — a sample tile's strip, or the
128
+ * palette itself when an image is chosen */
129
+ const extractPalette = (src, k) => new Promise((resolve) => {
130
+ const img = new Image()
131
+ img.onload = () => {
132
+ const f = Math.min(1, 200 / Math.max(img.width, img.height))
133
+ const cv = document.createElement('canvas')
134
+ cv.width = Math.max(1, Math.round(img.width * f)); cv.height = Math.max(1, Math.round(img.height * f))
135
+ const ctx = cv.getContext('2d')
136
+ ctx.drawImage(img, 0, 0, cv.width, cv.height)
137
+ try { resolve(paletteFromPixels(ctx.getImageData(0, 0, cv.width, cv.height).data, k)) } catch { resolve([]) }
76
138
  }
77
- const toggleSlot = (i) => setSlot(slot === i ? null : i)
78
- const picked = (hex) => c.slots.includes(hex)
139
+ img.onerror = () => resolve([])
140
+ img.src = src
141
+ })
142
+
143
+ /* ADOBE'S SAMPLE TILE (review 6, ref 54): the image, its palette as a strip under it; a click makes it the image */
144
+ function SampleTile({ src, label, onPick }) {
145
+ const [strip, setStrip] = useState([])
146
+ useEffect(() => { let on = true; extractPalette(src, 4).then((cs) => { if (on) setStrip(cs) }); return () => { on = false } }, [src])
147
+ return (
148
+ <Tooltip label={label}>
149
+ <button type="button" onClick={onPick} aria-label={`Try ${label}`} className="flex w-full cursor-pointer flex-col overflow-hidden rounded">
150
+ <img src={src} alt="" className="aspect-square w-full object-cover" />
151
+ <span className="flex h-3 w-full">{strip.map((hex, i) => <span key={i} className="flex-1" style={{ background: hex }} />)}</span>
152
+ </button>
153
+ </Tooltip>
154
+ )
155
+ }
79
156
 
157
+ /* THE PALETTE ON THE WHEEL (review 6, Adobe's ref 59): each colour a dot at its hue (clockwise from the top) and
158
+ * saturation (the radius) on the hue disc — a plot, not a picker */
159
+ function PaletteDots({ colors }) {
80
160
  return (
81
- <div className={`flex flex-col gap-10 ${className}`.trim()}>
82
- <Section title="Harmony">
83
- <div className="flex flex-wrap items-start gap-x-8 gap-y-4">
84
- <div className="flex flex-col gap-2"><span className={LABEL}>Base</span><span className="flex h-8 items-center gap-2"><ColorPickerPopover size="sm" className={PICKER} color={c.base} onChange={(hex) => isHex(hex) && set({ base: hex.slice(0, 7).toUpperCase() })} label="Base color" /><span className="kol-mono-10 text-meta">{c.base}</span></span></div>
85
- <div className="flex flex-col gap-2"><span className={LABEL}>Harmony</span><Dropdown size="sm" value={c.harmony} onChange={(v) => set({ harmony: v })} options={HARMONIES.map((x) => ({ value: x.id, label: x.label }))} /></div>
86
- <div className="flex flex-col gap-2"><span className={LABEL}>Info</span><span className="flex h-8 items-center"><ToggleSwitch size="sm" className={BARE_SWITCH} checked={Object.values(info).every(Boolean)} onChange={(v) => setInfo({ roles: v, brand: v, palette: v, ramps: v, mix: v })} /></span></div>
161
+ <div aria-hidden="true" className="relative mx-auto aspect-square w-full max-w-[280px] rounded-full"
162
+ style={{ background: 'radial-gradient(circle, #fff 0%, rgba(255,255,255,0) 70%), conic-gradient(#f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00)' }}>
163
+ {colors.map((hex, i) => {
164
+ const { h: hue, s: sat } = hexToHsl(hex)
165
+ const r = (sat / 100) * 46, a = (hue * Math.PI) / 180
166
+ return <span key={i} className="absolute size-5 -translate-x-1/2 -translate-y-1/2 rounded-full" style={{ left: `${50 + r * Math.sin(a)}%`, top: `${50 - r * Math.cos(a)}%`, background: hex, boxShadow: '0 0 0 2px #fff, 0 1px 3px rgba(0,0,0,.35)' }} />
167
+ })}
168
+ </div>
169
+ )
170
+ }
171
+
172
+ /* a pass or a fail, as a grade the Summary reads */
173
+ const Verdict = ({ ok, na }) => (na
174
+ ? <span className="kol-mono-12 text-meta">N/A</span>
175
+ : <span className={`inline-flex items-center gap-1 kol-mono-12 ${ok ? 'text-emphasis' : 'text-meta'}`}><Icon name={ok ? 'check' : 'x'} size={12} className={ok ? 'text-oq-96' : 'text-oq-48'} />{ok ? 'Pass' : 'Fail'}</span>)
176
+
177
+ /* two simulated colours too close to tell apart: OKLab distance under the threshold (≈ a just-noticeable step, doubled) */
178
+ const CONFLICT = 0.06
179
+ const oklab = (hex) => { const [L, C, h] = hexToOklch(hex); return [L, C * Math.cos((h * Math.PI) / 180), C * Math.sin((h * Math.PI) / 180)] }
180
+ const close = (a, b) => { const p = oklab(a), q = oklab(b); return Math.hypot(p[0] - q[0], p[1] - q[1], p[2] - q[2]) < CONFLICT }
181
+
182
+ /* the foreground over the background at an opacity, so the ladder measures what the eye sees */
183
+ const over = (fg, bg, a) => {
184
+ const p = [1, 3, 5].map((i) => parseInt(fg.slice(i, i + 2), 16)), q = [1, 3, 5].map((i) => parseInt(bg.slice(i, i + 2), 16))
185
+ return '#' + p.map((v, i) => Math.round(v * a + q[i] * (1 - a)).toString(16).padStart(2, '0')).join('')
186
+ }
187
+
188
+ export default function ColorLab({ value: valueProp, onChange, samples = [], library, className = '' }) {
189
+ const h = useLabValue(valueProp, onChange, DEFAULT_COLOR_LAB)
190
+ const { value, set } = h
191
+ const c = colorLab(value)
192
+ const [tinting, setTinting] = useState(null)
193
+ const inks = { dark: c.roles.bg, light: c.roles.fg }
194
+ const css = colorLabCss(value)
195
+ const [view, setView] = useState('palette')
196
+ const [tab, setTab] = useState('base')
197
+
198
+ /* a change to the inputs regenerates the unlocked colours from the same seed; locked ones keep their place */
199
+ const regenerate = (patch) => set((v) => rollColorLab({ ...v, ...patch, colors: c.colors }, v.seed))
200
+ /* A HAND EDIT MAKES THE HARMONY CUSTOM (review 3, Adobe's "Color harmonies: Custom") */
201
+ const setColors = (colors, locked = value.locked) => set({ colors, count: colors.length, locked, relation: 'custom' })
202
+ const editAt = (i, hex) => { const colors = c.colors.map((x, j) => (j === i ? hex : x)); set({ colors, count: colors.length, relation: 'custom', ...(i === 0 ? { base: hex } : {}) }) }
203
+ /* insert at `at` the colour between its neighbours (at an end, between the end colour and white or black) */
204
+ const insert = (at) => {
205
+ if (c.colors.length >= 8) return
206
+ const prev = c.colors[at - 1], next = c.colors[at]
207
+ const hex = midColor(prev ?? (next && hexToOklchL(next) < 0.5 ? '#FFFFFF' : '#000000'), next ?? (hexToOklchL(prev) < 0.5 ? '#FFFFFF' : '#000000'))
208
+ setColors([...c.colors.slice(0, at), hex, ...c.colors.slice(at)], value.locked.map((j) => (j >= at ? j + 1 : j)))
209
+ }
210
+ const remove = (i) => setColors(c.colors.filter((_, j) => j !== i), value.locked.filter((j) => j !== i).map((j) => (j > i ? j - 1 : j)))
211
+ const toggleLock = (i) => set({ colors: c.colors, locked: value.locked.includes(i) ? value.locked.filter((j) => j !== i) : [...value.locked, i] })
212
+ /* Generate random rolls the chosen harmony; a Custom palette rolls freely and stays Custom */
213
+ const roll = () => set((v) => ({ ...rollColorLab({ ...v, colors: c.colors, relation: v.relation === 'custom' ? 'random' : v.relation }), relation: v.relation }))
214
+ const base = hexToHsl(c.colors[0])
215
+
216
+ /* REORDER (the ⋮⋮): the first column is colour 1, the base, wherever the colours land; the locks travel with them */
217
+ const [dragFrom, setDragFrom] = useState(null)
218
+ const [dragOver, setDragOver] = useState(null)
219
+ const [landed, setLanded] = useState(null)
220
+ useEffect(() => { if (landed === null) return undefined; const t = setTimeout(() => setLanded(null), 900); return () => clearTimeout(t) }, [landed])
221
+ const move = (from, to) => {
222
+ if (to < 0 || to >= c.colors.length || to === from) return
223
+ const order = c.colors.map((_, i) => i)
224
+ order.splice(to, 0, order.splice(from, 1)[0])
225
+ const colors = order.map((i) => c.colors[i])
226
+ set({ colors, base: colors[0], relation: 'custom', locked: order.map((i, j) => (value.locked.includes(i) ? j : -1)).filter((j) => j >= 0) })
227
+ setLanded(to)
228
+ }
229
+ const dragFor = (i) => ({
230
+ source: dragFrom === i,
231
+ side: dragOver === i && dragFrom !== null && dragFrom !== i ? (dragFrom > i ? 'left' : 'right') : null,
232
+ landed: landed === i,
233
+ target: {
234
+ onDragOver: (e) => { if (dragFrom === null) return; e.preventDefault(); if (dragOver !== i) setDragOver(i) },
235
+ onDrop: (e) => { e.preventDefault(); if (dragFrom !== null) move(dragFrom, i); setDragFrom(null); setDragOver(null) },
236
+ },
237
+ handle: {
238
+ role: 'button', tabIndex: 0, draggable: true, className: 'cursor-grab active:cursor-grabbing',
239
+ onDragStart: (e) => { e.dataTransfer.effectAllowed = 'move'; setDragFrom(i) },
240
+ onDragEnd: () => { setDragFrom(null); setDragOver(null) },
241
+ onKeyDown: (e) => { if (e.key === 'ArrowLeft') { e.preventDefault(); move(i, i - 1) } if (e.key === 'ArrowRight') { e.preventDefault(); move(i, i + 1) } },
242
+ },
243
+ })
244
+
245
+ /* THE IMAGE SOURCE: an upload, a drop or a sample; its palette read off a canvas to the current number of colours */
246
+ const [image, setImage] = useState(null)
247
+ const fileInput = useRef(null)
248
+ const [dropping, setDropping] = useState(false)
249
+ const takeImage = async (src) => {
250
+ const colors = await extractPalette(src, c.colors.length)
251
+ if (colors.length >= 2) set({ colors, count: colors.length, base: colors[0], locked: [], relation: 'custom' })
252
+ setImage(src)
253
+ }
254
+ const fromFile = (file) => {
255
+ if (!file || !/^image\/(jpeg|png|webp)$/.test(file.type) || file.size > 40 * 1024 * 1024) return
256
+ takeImage(URL.createObjectURL(file))
257
+ }
258
+
259
+ /* ── the palette view ── */
260
+ const harmony = (
261
+ /* HARMONIES (review 3): the DS's glyphs, each named on hover, the mode named in the label — on the Wheel tab only
262
+ * (review 6 — *"you know there is a tab for wheel. why do you put it on every page?"*) */
263
+ <SettingsSections divided sections={[{ label: `Harmony: ${harmonyLabel(c.relation)}`, rows: [{ id: 'tiles', align: 'fill', render: () => (
264
+ /* review 5 — *"use bigger icons, and dont use background color"*, the row across the rail's width: no chip, the
265
+ * glyph in OPAQUE ink (oq, never fg — a translucent stroke shows where the ring crosses a dot), full when chosen */
266
+ <div className="grid w-full grid-cols-9">
267
+ {HARMONY_TILES.map((id) => (
268
+ <Tooltip key={id} label={harmonyLabel(id)}>
269
+ <button type="button" aria-label={harmonyLabel(id)} aria-pressed={c.relation === id}
270
+ className={`flex aspect-square w-full cursor-pointer items-center justify-center ${c.relation === id ? 'text-oq-96' : 'text-oq-40 hover:text-oq-72'}`}
271
+ onClick={() => { if (id !== 'custom') regenerate({ relation: id }) }}>
272
+ <Icon name={HARMONY_ICON[id]} size={28} />
273
+ </button>
274
+ </Tooltip>
275
+ ))}
276
+ </div>
277
+ ) }] }]} />
278
+ )
279
+ const sources = [
280
+ { id: 'base', label: 'Base', icon: 'eyedrop', content: (
281
+ <ColorPickerPopover inline modes title="Base" color={c.colors[0]} label="Base" alpha={false}
282
+ hexEnd={<Glyph ink="currentColor" icon={value.locked.includes(0) ? 'lock' : 'unlock'} label={value.locked.includes(0) ? 'Unlock base' : 'Lock base'} onClick={() => toggleLock(0)} />}
283
+ onChange={(hex) => isHex(hex.slice(0, 7)) && regenerate({ base: hex.slice(0, 7).toUpperCase() })} />
284
+ ) },
285
+ { id: 'image', label: 'Image', icon: 'image', content: (
286
+ <div className="flex min-h-0 flex-1 flex-col gap-6">
287
+ {/* THE DROP ZONE, ADOBE'S (review 6, refs 54 · 56 · 57): the dashed outline of the reference, the upload button
288
+ * centred with its two lines, the whole zone a target; under the pointer or a drag it shows the checker */}
289
+ <div
290
+ role="button" tabIndex={0} aria-label={image ? 'Upload another image' : 'Upload your image'}
291
+ className={`kol-lab-drop relative flex min-h-[280px] flex-1 cursor-pointer flex-col items-center justify-center gap-2 overflow-hidden rounded border border-dashed border-oq-16 p-6 text-center ${dropping ? 'is-dropping' : ''}`}
292
+ onClick={() => fileInput.current?.click()}
293
+ onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fileInput.current?.click() } }}
294
+ onDragOver={(e) => { e.preventDefault(); setDropping(true) }}
295
+ onDragLeave={() => setDropping(false)}
296
+ onDrop={(e) => { e.preventDefault(); setDropping(false); fromFile(e.dataTransfer.files?.[0]) }}
297
+ >
298
+ {image
299
+ ? <img src={image} alt="" className="absolute inset-0 h-full w-full object-cover" />
300
+ : (
301
+ <>
302
+ <Button size="md" iconLeft="upload" onClick={(e) => { e.stopPropagation(); fileInput.current?.click() }}>Upload your image</Button>
303
+ <span className="kol-sans-body-02 text-emphasis">Or drag and drop here</span>
304
+ <span className="kol-sans-body-03 text-meta">File must be JPEG, JPG, PNG or WebP and up to 40MB</span>
305
+ </>
306
+ )}
307
+ <input ref={fileInput} type="file" accept="image/jpeg,image/png,image/webp" className="hidden" onChange={(e) => { fromFile(e.target.files?.[0]); e.target.value = '' }} />
87
308
  </div>
88
- <PaletteHarmonyWheel size={220} hue={c.h} harmony={c.harmony} saturation={c.s} lightness={c.l} onHueChange={(hh) => set({ base: hslToHex(hh, c.s, c.l).toUpperCase() })} />
89
- </Section>
90
-
91
- <Section title="Roles" {...infoFor('roles')}>
92
- <Row>{c.roles.map((hex, i) => <Swatch key={i} info={info.roles} hex={hex} name={COLOR_ROLES[i]} onClick={() => assign(hex)} />)}</Row>
93
- </Section>
94
-
95
- <Section title="Brand slots" {...infoFor('brand')}>
96
- <Row>{c.slots.map((hex, i) => <Swatch key={i} info={info.brand} hex={hex} name={COLOR_SLOTS[i]} selected={slot === i} onClick={() => toggleSlot(i)} />)}</Row>
97
- </Section>
98
-
99
- <Section title="Palette" {...infoFor('palette')} extra={<Switch label="Link blue" checked={c.blueLinked} onChange={(v) => set({ blueLinked: v })} />}>
100
- <Row>{c.hues.map((x) => <Swatch key={x.name + x.hex} info={info.palette} hex={x.hex} name={x.name} selected={picked(x.hex)} onClick={() => assign(x.hex)} />)}</Row>
101
- {[['Blue-grey', c.blueGrey, c.blueBase, 'blueBase', true], ['Grey', c.grey, c.greyBase, 'greyBase', false]].map(([row, ramp, rb, key, linkable]) => (
102
- <div key={row} className="mt-4 flex flex-col gap-2">
103
- <span className="flex h-4 items-center gap-3"><span className={`${LABEL} w-[120px]`}>{row} base</span>
104
- {!(linkable && c.blueLinked) && <><ColorPickerPopover size="sm" className={PICKER} color={rb} onChange={(hex) => isHex(hex) && set({ [key]: hex.slice(0, 7).toUpperCase() })} label={`${row} base`} /><span className="kol-mono-10 text-meta">{rb}</span></>}</span>
105
- <Row>{ramp.map((hex, i) => <Swatch key={row + i} info={info.palette} hex={hex} name={`${row} ${(i + 1) * 100}`} selected={picked(hex)} onClick={() => assign(hex)} />)}</Row>
309
+ {samples.length > 0 && (
310
+ /* "TRY ONE OF OURS" (ref 54): the samples with their palettes; the user's idea — they come from the bucket, named
311
+ * and linked beside them */
312
+ <div className="flex flex-col gap-3">
313
+ <div className="flex items-baseline justify-between gap-3">
314
+ <span className="kol-sans-body-02 text-emphasis">Don’t have an image? Try one of ours:</span>
315
+ {library && <a href={library.href} target="_blank" rel="noreferrer" className="group inline-flex shrink-0 items-center gap-1 kol-mono-12 text-meta hover:text-emphasis">{library.label}<Icon name="external-link" size={12} className="text-oq-48 group-hover:text-oq-96" /></a>}
316
+ </div>
317
+ <div className="grid grid-cols-4 gap-2">
318
+ {samples.slice(0, 4).map((x) => <SampleTile key={x.src} src={x.src} label={x.label ?? x.src} onPick={() => takeImage(x.src)} />)}
319
+ </div>
106
320
  </div>
321
+ )}
322
+ </div>
323
+ ) },
324
+ { id: 'wheel', label: 'Wheel', icon: 'clr-mono', content: (
325
+ <>
326
+ <WheelFill>{(size) => <PaletteHarmonyWheel size={size} hue={base.h} saturation={base.s} lightness={base.l} harmony={WHEEL_HARMONIES.includes(c.relation) ? c.relation : 'analogous'}
327
+ onHueChange={(hue) => regenerate({ base: hslToHex(hue, base.s, base.l).toUpperCase() })} />}</WheelFill>
328
+ {harmony}
329
+ </>
330
+ ) },
331
+ ]
332
+ /* THE PALETTE SMALL (Adobe's) under every view's stage, Randomise at its right on the palette view (review 5 — *"under
333
+ * the color, not siderail"*; review 6 — the views share the window) */
334
+ const paletteFoot = (
335
+ <div className="flex shrink-0 items-center gap-3">
336
+ <div className="flex h-8 w-60 overflow-hidden rounded" aria-label="Palette">
337
+ {c.swatches.map((s) => <span key={s.index} className="flex-1" style={{ background: s.hex }} title={s.hex} />)}
338
+ </div>
339
+ {/* RANDOMISE (review 6, Adobe's shuffle): the nav rung, like the header's controls; Copy CSS is in the header, where
340
+ * every lab keeps it */}
341
+ {view === 'palette' && <Button variant="nav" size="sm" iconLeft="shuffle" className="ml-auto" onClick={roll}>Randomise</Button>}
342
+ </div>
343
+ )
344
+ const paletteStage = (
345
+ <div className="flex h-full min-h-[240px] flex-col gap-3">
346
+ <div className="flex min-h-0 flex-1 gap-0.5">
347
+ {c.swatches.map((s) => (
348
+ <Column
349
+ key={s.index}
350
+ s={s}
351
+ n={c.colors.length}
352
+ inks={inks}
353
+ tint={tinting === s.index}
354
+ onTint={(hex) => { if (hex) editAt(s.index, hex); setTinting(tinting === s.index || hex ? null : s.index) }}
355
+ onLock={() => toggleLock(s.index)}
356
+ onEdit={(hex) => editAt(s.index, hex)}
357
+ onCopy={() => navigator.clipboard?.writeText(s.hex)}
358
+ onDelete={() => remove(s.index)}
359
+ canDelete={c.colors.length > 2}
360
+ drag={dragFor(s.index)}
361
+ add={{ left: c.colors.length < 8 ? () => insert(s.index) : null, right: c.colors.length < 8 && s.index === c.colors.length - 1 ? () => insert(s.index + 1) : null }}
362
+ />
107
363
  ))}
108
- </Section>
109
-
110
- <Section title="Ramps" {...infoFor('ramps')}>
111
- <Row>
112
- {[0, 1, 2, 3, 4].flatMap((step) => c.ramps.map((hue) => {
113
- const hex = hue.steps[step]
114
- return <Swatch key={hue.hex + step} info={info.ramps} hex={hex} name={`${hue.name} ${(step + 1) * 100}`} selected={picked(hex)} onClick={() => assign(hex)} />
115
- }))}
116
- </Row>
117
- </Section>
118
-
119
- <Section title="Composition" {...infoFor('mix')}>
120
- <div style={{ maxWidth: ROW_MAX }}>
121
- <div className="flex h-40 overflow-hidden rounded">
122
- {COLOR_MIX.map(([share, label], i) => (
123
- <button key={label} type="button" onClick={() => toggleSlot(4 + i)} title={`${label} ${c.mix[i]}`} className={`flex min-w-0 cursor-pointer flex-col justify-between p-3 text-left transition-colors ${slot === 4 + i ? 'ring-2 ring-inset ring-[var(--kol-accent-primary)]' : ''}`} style={{ flex: share, background: c.mix[i], color: fgOn(c.mix[i]) }}>
124
- <span className="kol-sans-heading-03 leading-none">{share * 10}</span>
125
- <span className={`truncate kol-mono-10 leading-tight transition-opacity ${info.mix ? 'opacity-100' : 'opacity-0'}`}>{c.mix[i]}</span>
126
- </button>
127
- ))}
128
- </div>
129
- <div className="mt-6 grid grid-cols-1 gap-6 sm:grid-cols-3">
130
- {COLOR_MIX.map(([, label, note], i) => (
131
- <div key={label}>
132
- <div className="mb-1.5 flex items-center gap-2.5">
133
- <span className="size-4 rounded-[3px] border border-fg-16" style={{ background: c.mix[i] }} />
134
- <span className="kol-sans-body-02 text-emphasis">{label}</span>
135
- </div>
136
- <p className="m-0 kol-sans-body-03 text-meta">{note}</p>
137
- </div>
138
- ))}
139
- </div>
364
+ </div>
365
+ {paletteFoot}
366
+ </div>
367
+ )
368
+
369
+ /* ── the contrast checker (Adobe's ref 58, review 6) ── */
370
+ const [pair, setPair] = useState(null)
371
+ const [ctab, setCtab] = useState('summary')
372
+ const fgI = pair?.fg ?? c.roleIdx.fg
373
+ const bgI = pair?.bg ?? c.roleIdx.bg
374
+ const fg = c.colors[fgI] ?? c.roles.fg, bg = c.colors[bgI] ?? c.roles.bg
375
+ const ratio = contrastRatio(fg, bg)
376
+ const colourOptions = c.swatches.map((s) => ({ value: String(s.index), label: `${s.index + 1} ${s.hex}` }))
377
+ /* a colour's lightness, OKLCH L as 0–100 — moving it edits that palette colour */
378
+ const lightness = (i) => Math.round(hexToOklch(c.colors[i])[0] * 100)
379
+ const setLightness = (i, L) => { const [, C, hh] = hexToOklch(c.colors[i]); editAt(i, oklchToHex(L / 100, C, hh)) }
380
+ const SUMMARY = [
381
+ { id: 'large', label: 'Large text', aa: ratio >= 3, aaa: ratio >= 4.5 },
382
+ { id: 'small', label: 'Small text', aa: ratio >= 4.5, aaa: ratio >= 7 },
383
+ { id: 'ui', label: 'Graphics and UI', aa: ratio >= 3, aaa: null },
384
+ ]
385
+ /* CONTRAST SUGGESTIONS: the foreground's hue and chroma at other lightnesses that pass AA for small text, nearest first */
386
+ const fgL = hexToOklch(fg)[0]
387
+ const suggestions = (() => {
388
+ const [, C, hh] = hexToOklch(fg)
389
+ const out = []
390
+ for (let L = 0.05; L <= 0.98; L += 0.05) { const hex = oklchToHex(L, C, hh); const r = contrastRatio(hex, bg); if (r >= 4.5 && hex !== fg) out.push({ hex, r, L }) }
391
+ return out.sort((a, b) => Math.abs(a.L - fgL) - Math.abs(b.L - fgL)).slice(0, 6)
392
+ })()
393
+ /* SET A CONTRAST RATIO: the foreground's lightness moved away from the background until the pair reaches the target */
394
+ const [target, setTarget] = useState(null)
395
+ const solveFor = (t) => {
396
+ const [, C, hh] = hexToOklch(fg)
397
+ const Lb = hexToOklch(bg)[0]
398
+ const far = Lb < 0.5
399
+ let lo = 0, hi = 1, best = fg
400
+ for (let k = 0; k < 24; k++) {
401
+ const mid = (lo + hi) / 2
402
+ const hex = oklchToHex(far ? Lb + mid * (1 - Lb) : Lb * (1 - mid), C, hh)
403
+ best = hex
404
+ if (contrastRatio(hex, bg) < t) lo = mid; else hi = mid
405
+ }
406
+ return best
407
+ }
408
+ /* the pair, Adobe's: Foreground and Background side by side, swap between them, each colour's lightness under it */
409
+ const pairCol = (label, i, key) => (
410
+ <div className="flex min-w-0 flex-col gap-2">
411
+ <span className="kol-helper-10 tracking-widest text-meta">{label.toUpperCase()}</span>
412
+ <div className="flex items-center gap-2">
413
+ <span className="size-4 shrink-0 rounded-full" style={{ background: c.colors[i] }} />
414
+ <Dropdown size="sm" value={String(i)} onChange={(v) => setPair({ fg: fgI, bg: bgI, [key]: Number(v) })} options={colourOptions} className="min-w-0 flex-1" ariaLabel={`${label} colour`} />
415
+ </div>
416
+ <Slider className="w-full" min={0} max={100} step={1} value={lightness(i)} onChange={(L) => setLightness(i, L)} readout="input" displayWidth={3} readoutPreset="rail" aria-label={`${label} lightness`} />
417
+ </div>
418
+ )
419
+ const pairHead = (
420
+ <div className="flex flex-col gap-4">
421
+ <div className="flex items-center justify-between gap-3">
422
+ <span className="kol-sans-body-02 text-emphasis">Contrast ratio</span>
423
+ <span className={`inline-flex items-center gap-1 rounded px-2 py-0.5 kol-mono-12 ${ratio >= 4.5 ? 'bg-oq-08 text-emphasis' : 'bg-oq-04 text-meta'}`}><Icon name={ratio >= 4.5 ? 'check' : 'alert-triangle'} size={12} className={ratio >= 4.5 ? 'text-oq-96' : 'text-oq-48'} />{ratioText(ratio)} : 1</span>
424
+ </div>
425
+ <div className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-start gap-2">
426
+ {pairCol('Foreground color', fgI, 'fg')}
427
+ <Button variant="nav" size="sm" iconOnly="swap" aria-label="Swap foreground and background" className="mt-6" onClick={() => setPair({ fg: bgI, bg: fgI })} />
428
+ {pairCol('Background color', bgI, 'bg')}
429
+ </div>
430
+ </div>
431
+ )
432
+ const summaryTable = (
433
+ <div className="flex flex-col">
434
+ <div className="grid grid-cols-[minmax(0,1fr)_64px_64px] border-b border-oq-16 pb-2 kol-helper-12 text-meta"><span>Category</span><span>AA</span><span>AAA</span></div>
435
+ {SUMMARY.map((r) => (
436
+ <div key={r.id} className="grid grid-cols-[minmax(0,1fr)_64px_64px] items-center border-b border-oq-08 py-3 kol-sans-body-03 text-emphasis"><span>{r.label}</span><Verdict ok={r.aa} /><Verdict ok={r.aaa} na={r.aaa === null} /></div>
437
+ ))}
438
+ </div>
439
+ )
440
+ /* KOL's text levels: the foreground at every ink stop over the background, each measured */
441
+ const ladder = (
442
+ <div className="flex flex-col gap-2">
443
+ <span className="kol-helper-12 text-meta">Text levels</span>
444
+ <div className="flex w-full flex-col rounded p-3" style={{ background: bg }}>
445
+ {c.text.map((x) => {
446
+ const r = contrastRatio(over(fg, bg, x.stop / 100), bg)
447
+ return (
448
+ <span key={x.stop} className="grid grid-cols-[32px_minmax(0,1fr)_auto] items-baseline gap-3 py-0.5" style={{ color: over(fg, bg, x.stop / 100) }}>
449
+ <span className="kol-mono-12">{x.stop}</span>
450
+ <span className="truncate kol-sans-body-03">{x.name ? x.name[0].toUpperCase() + x.name.slice(1) : 'Text'}</span>
451
+ <span className="kol-mono-12">{ratioText(r)} {contrastGrade(r)}</span>
452
+ </span>
453
+ )
454
+ })}
455
+ </div>
456
+ </div>
457
+ )
458
+ const contrastRail = [
459
+ { id: 'summary', label: 'Summary', content: <>{pairHead}{summaryTable}{ladder}</> },
460
+ { id: 'suggest', label: 'Suggestions', content: (
461
+ <>
462
+ {pairHead}
463
+ <div className="flex flex-col">
464
+ <span className="border-b border-oq-16 pb-2 kol-helper-12 text-meta">Foreground colors that pass AA</span>
465
+ {suggestions.length === 0 && <span className="py-3 kol-sans-body-03 text-meta">None at this hue. Move the background’s lightness.</span>}
466
+ {suggestions.map((x) => (
467
+ <button key={x.hex} type="button" onClick={() => editAt(fgI, x.hex)} className="grid cursor-pointer grid-cols-[16px_minmax(0,1fr)_auto] items-center gap-3 border-b border-oq-08 py-2 text-left kol-mono-12 text-emphasis hover:bg-oq-04">
468
+ <span className="size-4 rounded-full" style={{ background: x.hex }} /><span>{x.hex}</span><span className="text-meta">{ratioText(x.r)} : 1 {contrastGrade(x.r)}</span>
469
+ </button>
470
+ ))}
471
+ </div>
472
+ </>
473
+ ) },
474
+ { id: 'ratio', label: 'Set a ratio', content: (
475
+ <>
476
+ {pairHead}
477
+ <div className="flex flex-col gap-2">
478
+ <span className="kol-helper-12 text-meta">Target ratio</span>
479
+ <Slider className="w-full" min={1} max={21} step={0.5} value={target ?? Math.round(ratio * 2) / 2} onChange={(t) => { setTarget(t); editAt(fgI, solveFor(t)) }} readout="input" displayWidth={4} readoutPreset="rail" aria-label="Target contrast ratio" />
480
+ <span className="kol-sans-body-03 text-meta">The foreground’s lightness moves until the pair reaches it.</span>
140
481
  </div>
141
- </Section>
482
+ </>
483
+ ) },
484
+ ]
485
+ const previewImage = image ?? samples[0]?.src
486
+ const contrastStage = (
487
+ /* ADOBE'S PREVIEW: the image beside the pair as a page would use it — a headline, body text and a button */
488
+ <div className="flex h-full min-h-[240px] flex-col gap-3">
489
+ <div className={`grid min-h-0 flex-1 grid-rows-[minmax(0,1fr)] overflow-hidden rounded ${previewImage ? 'grid-cols-2' : ''}`} style={{ background: bg, color: fg }}>
490
+ {previewImage && <img src={previewImage} alt="" className="h-full min-h-0 w-full object-cover" />}
491
+ <div className="flex min-h-0 flex-col justify-center gap-6 p-10">
492
+ <span className="kol-sans-heading-01 leading-none">The quick brown fox</span>
493
+ <span className="max-w-[48ch] kol-sans-body-02">Body text in the foreground on the background, the size most of a page is read at. If this line is hard to read, so is the page.</span>
494
+ <span className="inline-flex self-start rounded px-6 py-3 kol-mono-12" style={{ background: fg, color: bg }}>Button</span>
495
+ </div>
496
+ </div>
497
+ {paletteFoot}
142
498
  </div>
143
499
  )
500
+
501
+ /* ── the colour blindness simulator (Adobe's ref 59, review 6) ── */
502
+ const VISIONS = CVD_TYPES.filter((t) => t.id !== 'normal')
503
+ const rows = VISIONS.map((t) => {
504
+ const seen = c.colors.map((hex) => simulateCvd(hex, t.id))
505
+ return { ...t, seen, conflict: seen.map((x, i) => seen.some((y, j) => j !== i && close(x, y))) }
506
+ })
507
+ const conflicts = rows.some((r) => r.conflict.some(Boolean))
508
+ const visionRail = [{ id: 'conflicts', label: 'Conflicts', content: (
509
+ <>
510
+ <div className="flex items-center justify-between gap-3 rounded bg-oq-04 p-3">
511
+ <span className="kol-sans-body-02 text-emphasis">Potential color blind conflicts</span>
512
+ <span className={`inline-flex shrink-0 items-center gap-1 rounded px-2 py-0.5 kol-mono-12 ${conflicts ? 'bg-oq-08 text-emphasis' : 'text-meta'}`}>{conflicts && <Icon name="alert-triangle" size={12} className="text-oq-96" />}{conflicts ? 'Conflicts found' : 'None'}</span>
513
+ </div>
514
+ <PaletteDots colors={c.colors} />
515
+ </>
516
+ ) }]
517
+ const n = c.colors.length
518
+ const visionStage = (
519
+ <div className="flex h-full min-h-[240px] flex-col gap-3">
520
+ <div className="grid min-h-0 flex-1 gap-0.5" style={{ gridTemplateColumns: `112px repeat(${n}, minmax(0, 1fr))`, gridTemplateRows: `2fr repeat(${rows.length}, minmax(0, 1fr))` }}>
521
+ <span />
522
+ {c.swatches.map((s) => (
523
+ <span key={s.index} className="flex items-end p-3 kol-mono-12" style={{ background: s.hex, color: inkOn(s.hex, inks).ink, ...(s.index === 0 ? { borderTopLeftRadius: 4 } : null), ...(s.index === n - 1 ? { borderTopRightRadius: 4 } : null) }}>{s.hex}</span>
524
+ ))}
525
+ {rows.flatMap((r, ri) => [
526
+ <span key={`${r.id}-label`} className="flex items-center kol-helper-12 text-meta">{r.label}</span>,
527
+ ...r.seen.map((hex, i) => (
528
+ <span key={`${r.id}-${i}`} className="flex items-center justify-center" title={r.conflict[i] ? `${r.label}: too close to another colour` : hex}
529
+ style={{ background: hex, color: inkOn(hex, inks).ink, ...(ri === rows.length - 1 && i === 0 ? { borderBottomLeftRadius: 4 } : null), ...(ri === rows.length - 1 && i === n - 1 ? { borderBottomRightRadius: 4 } : null) }}>
530
+ {r.conflict[i] && <Icon name="alert-triangle" size={16} />}
531
+ </span>
532
+ )),
533
+ ])}
534
+ </div>
535
+ {paletteFoot}
536
+ </div>
537
+ )
538
+
539
+ const VIEWS = [
540
+ { id: 'palette', icon: 'gen-color', label: 'Create palette' },
541
+ { id: 'contrast', icon: 'brightness', label: 'Contrast checker' },
542
+ { id: 'vision', icon: 'eye-on', label: 'Color blindness simulator' },
543
+ ]
544
+ return (
545
+ <LabWindow
546
+ className={className}
547
+ title={view === 'palette' ? 'Color Lab' : VIEWS.find((v) => v.id === view).label}
548
+ tabs={view === 'contrast' ? contrastRail : view === 'vision' ? visionRail : sources}
549
+ tab={view === 'palette' ? tab : view === 'contrast' ? ctab : 'conflicts'}
550
+ onTabChange={view === 'palette' ? setTab : view === 'contrast' ? setCtab : undefined}
551
+ actions={(
552
+ <>
553
+ {/* THE THREE VIEWS (review 3 — *"the accessibility buttons should be its own thing window"*) */}
554
+ {VIEWS.map((v) => (
555
+ <Tooltip key={v.id} label={v.label}><Button variant="tab" size="sm" iconOnly={v.icon} aria-label={v.label} pressed={view === v.id} onClick={() => setView(v.id)} /></Tooltip>
556
+ ))}
557
+ <LabHistory h={h} />
558
+ <CopyAction text={css} />
559
+ </>
560
+ )}
561
+ output={{
562
+ columns: [
563
+ { header: 'Colour', accessor: 'name', render: (r) => <span className="flex items-center gap-3"><span className="size-4 shrink-0 rounded-[3px] border border-oq-12" style={{ background: r.swatch }} />{r.name}</span> },
564
+ { header: 'Hex', accessor: 'hex' },
565
+ { header: 'RGB', accessor: 'rgb' },
566
+ { header: 'OKLCH', accessor: 'oklch' },
567
+ ],
568
+ rows: c.swatches.map((s) => ({ id: `c${s.index}`, swatch: s.hex, name: `${s.index + 1} ${s.roleLabel}`, hex: s.hex, rgb: rgb(s.hex), oklch: `${s.oklch[0].toFixed(2)} ${s.oklch[1].toFixed(3)} ${Math.round(s.oklch[2])}` })),
569
+ css,
570
+ filename: 'brand-color.css',
571
+ }}
572
+ >
573
+ {view === 'contrast' ? contrastStage : view === 'vision' ? visionStage : paletteStage}
574
+ </LabWindow>
575
+ )
144
576
  }