@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/package.json +9 -8
- package/src/ColorLab.jsx +549 -117
- package/src/ComboLab.jsx +73 -181
- package/src/DocumentLibrary.jsx +61 -11
- package/src/LockupBuilder.jsx +8 -56
- package/src/LockupLab.jsx +176 -0
- package/src/TypeLab.jsx +123 -0
- package/src/TypeScaleLab.jsx +6 -67
- package/src/colorLab.js +254 -56
- package/src/comboLab.js +37 -0
- package/src/comboLayouts.jsx +3 -1
- package/src/comboMath.js +2 -0
- package/src/index.js +17 -8
- package/src/lab/LabWindow.jsx +130 -0
- package/src/lab/index.js +30 -0
- package/src/lockupLab.js +71 -0
- package/src/typeLab.js +125 -0
- package/src/typeScale.js +0 -45
package/src/ColorLab.jsx
CHANGED
|
@@ -1,144 +1,576 @@
|
|
|
1
|
-
import { useState } from 'react'
|
|
2
|
-
import { ColorPickerPopover, Dropdown,
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
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
|
|
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 —
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* (
|
|
14
|
-
*
|
|
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
|
-
*
|
|
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
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
57
|
-
</section>
|
|
109
|
+
</div>
|
|
58
110
|
)
|
|
59
111
|
}
|
|
60
112
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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=
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
</
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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
|
}
|