@kolkrabbi/kol-component 0.8.0 → 0.10.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-component",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "KOL design-system components — atoms through organisms, emitting canonical kol-* classes. Pairs with @kolkrabbi/kol-theme for styling.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -23,7 +23,7 @@
23
23
  "dependencies": {
24
24
  "@floating-ui/react": "^0.27.19",
25
25
  "embla-carousel-react": "^8.6.0",
26
- "@kolkrabbi/kol-icons": "0.5.0"
26
+ "@kolkrabbi/kol-icons": "0.6.0"
27
27
  },
28
28
  "peerDependencies": {
29
29
  "react": "^18.3.0 || ^19.0.0",
@@ -10,7 +10,7 @@ import { Icon } from '@kolkrabbi/kol-icons'
10
10
  *
11
11
  * @param {Object} props
12
12
  * @param {ReactNode} props.children - Button content
13
- * @param {'primary'|'secondary'|'accent'|'outline'|'ghost'|'control'} props.variant - Visual variant. `control` is an alias for `ghost` (legacy call-sites).
13
+ * @param {'primary'|'secondary'|'accent'|'outline'|'ghost'|'danger'|'grey'|'control'} props.variant - Visual variant. `danger` is the destructive treatment (--ui-error fill); `control` is an alias for `ghost` (legacy call-sites).
14
14
  * @param {'sm'|'md'|'lg'} props.size - Button size (default: 'md')
15
15
  * @param {string} props.iconLeft - Icon name to display on the left
16
16
  * @param {string} props.iconRight - Icon name to display on the right
@@ -71,6 +71,10 @@ const Button = ({
71
71
  ? 'kol-btn-outline'
72
72
  : resolvedVariant === 'ghost'
73
73
  ? 'kol-btn-ghost'
74
+ : resolvedVariant === 'danger'
75
+ ? 'kol-btn-danger'
76
+ : resolvedVariant === 'grey'
77
+ ? 'kol-btn-grey'
74
78
  : 'kol-btn-secondary'
75
79
 
76
80
  // Add size class — pairs the padding rule with its mono type class.
@@ -1,6 +1,6 @@
1
1
  import { useEffect, useRef } from 'react'
2
2
 
3
- export default function FullscreenOverlay({ open, onClose, children }) {
3
+ export default function FullscreenOverlay({ open, onClose, closeButton = true, children }) {
4
4
  const sheetRef = useRef(null)
5
5
 
6
6
  useEffect(() => {
@@ -24,14 +24,16 @@ export default function FullscreenOverlay({ open, onClose, children }) {
24
24
  return (
25
25
  <div className="kol-overlay" role="dialog" aria-modal="true" onMouseDown={onBackdropClick}>
26
26
  <div ref={sheetRef} className="kol-overlay-sheet">
27
- <button
28
- type="button"
29
- className="kol-overlay-close"
30
- onClick={onClose}
31
- aria-label="Close"
32
- >
33
- ×
34
- </button>
27
+ {closeButton && (
28
+ <button
29
+ type="button"
30
+ className="kol-overlay-close"
31
+ onClick={onClose}
32
+ aria-label="Close"
33
+ >
34
+ ×
35
+ </button>
36
+ )}
35
37
  {children}
36
38
  </div>
37
39
  </div>
@@ -1,3 +1,4 @@
1
+ import { useEffect, useRef, useState } from 'react'
1
2
  import { Icon } from '@kolkrabbi/kol-icons'
2
3
 
3
4
  /**
@@ -7,6 +8,14 @@ import { Icon } from '@kolkrabbi/kol-icons'
7
8
  * icon, then two mutually exclusive trailing affordances: a caller-authored
8
9
  * shortcut kbd chip while the value is empty, a clear × while it isn't.
9
10
  *
11
+ * `expanding` is the third body plan (after the shell and `bare`): a round
12
+ * icon-button pill that animates open into an inline field — the /work
13
+ * navbar pattern, extracted from kol-content's WorkViewToggle (2026-07-15).
14
+ * `open` is controllable so a parent can choreograph siblings around it
15
+ * (collapse a toggle, reveal a close button); uncontrolled works too.
16
+ * Escape reports `onOpenChange(false)` — clearing on close stays the
17
+ * parent's call.
18
+ *
10
19
  * Always controlled (`value` defaults to ''). Controlled + no onChange =
11
20
  * deliberate display-only → readOnly, same convention as Input. Extra props
12
21
  * (onKeyDown, autoFocus, role, aria-*) spread onto the inner <input> —
@@ -23,13 +32,19 @@ import { Icon } from '@kolkrabbi/kol-icons'
23
32
  * @param {string} shortcutHint caller-authored kbd chip (e.g. "⌘K"); shown while the value is empty
24
33
  * @param {Function} onFocus (event) => void — input focus
25
34
  * @param {string} size 'sm' | 'md' — kol-control size + matched mono type class
26
- * @param {string} variant 'filled' | 'ghost' | 'outline' — kol-control variant, same chrome as Input (ignored when bare)
35
+ * @param {string} variant 'filled' | 'ghost' | 'outline' — kol-control variant, same chrome as Input (ignored when bare/expanding)
27
36
  * @param {boolean} bare borderless inline field for overlay panels (full width, no shell chrome; keeps icon/clear/chip slots)
37
+ * @param {boolean} expanding round icon-button pill that animates open into an inline field
38
+ * @param {boolean} open (expanding) controlled open state; omit for internal state
39
+ * @param {Function} onOpenChange (expanding) (bool) => void — trigger click / Escape
40
+ * @param {number} expandedWidth (expanding) open pill width in px (default 280)
41
+ * @param {string} triggerLabel (expanding) aria-label on the icon trigger (default 'Open search')
28
42
  * @param {string} className extra classes on the shell
29
43
  */
30
44
 
31
45
  const SIZE_TYPE = { sm: 'kol-mono-12', md: 'kol-mono-14' }
32
46
  const ICON_SIZE = { sm: 14, md: 14 }
47
+ const CUBIC_EASE = 'cubic-bezier(0.16, 1, 0.3, 1)'
33
48
 
34
49
  export default function SearchInput({
35
50
  value = '',
@@ -41,9 +56,59 @@ export default function SearchInput({
41
56
  size = 'md',
42
57
  variant = 'filled',
43
58
  bare = false,
59
+ expanding = false,
60
+ open,
61
+ onOpenChange,
62
+ expandedWidth = 280,
63
+ triggerLabel = 'Open search',
44
64
  className = '',
45
65
  ...inputProps
46
66
  }) {
67
+ /* expanding body plan — hooks stay unconditional (expanding never flips at runtime) */
68
+ const [internalOpen, setInternalOpen] = useState(false)
69
+ const inputRef = useRef(null)
70
+ const isOpen = open !== undefined ? open : internalOpen
71
+ const setOpen = (next) => {
72
+ if (open === undefined) setInternalOpen(next)
73
+ onOpenChange?.(next)
74
+ }
75
+
76
+ useEffect(() => {
77
+ if (expanding && isOpen) inputRef.current?.focus()
78
+ }, [expanding, isOpen])
79
+
80
+ if (expanding) {
81
+ return (
82
+ <div
83
+ className={`flex items-center bg-fg-04 rounded-full h-9 ${className}`.trim()}
84
+ style={{ width: isOpen ? expandedWidth : 36, transition: `width 600ms ${CUBIC_EASE}` }}
85
+ >
86
+ <button
87
+ type="button"
88
+ className={`flex items-center justify-center w-9 h-9 rounded-full text-auto flex-shrink-0 border border-transparent ${isOpen ? '' : 'transition-colors hover:border-fg-12'}`}
89
+ onClick={() => !isOpen && setOpen(true)}
90
+ aria-label={triggerLabel}
91
+ aria-expanded={isOpen}
92
+ >
93
+ <Icon name="search" size={16} className="text-fg-80" />
94
+ </button>
95
+ {isOpen && (
96
+ <input
97
+ ref={inputRef}
98
+ type="search"
99
+ value={value ?? ''}
100
+ onChange={onChange}
101
+ readOnly={!onChange || undefined}
102
+ placeholder={placeholder}
103
+ spellCheck={false}
104
+ className="bg-transparent outline-none kol-helper-14 flex-1 text-fg-80 caret-current pr-4 min-w-0 appearance-none [&::-webkit-search-cancel-button]:hidden"
105
+ onKeyDown={(e) => { if (e.key === 'Escape') setOpen(false) }}
106
+ {...inputProps}
107
+ />
108
+ )}
109
+ </div>
110
+ )
111
+ }
47
112
  const shellCls = [
48
113
  bare
49
114
  ? 'flex w-full gap-2.5 px-4 py-3'
@@ -0,0 +1,214 @@
1
+ /**
2
+ * colorMath — HSL/hex conversion + harmony generation for the KOL color
3
+ * pickers (SpectrumControls, PaletteHarmonyWheel).
4
+ *
5
+ * Plain functions, not a hook — it lives in src/hooks/ because that is the
6
+ * taxonomy's only non-component folder (same rationale as cssVar.js).
7
+ *
8
+ * Two harmony paths:
9
+ * - Deterministic — `harmonyColors(hue, harmony, {saturation, lightness})`
10
+ * / `generateHarmony(baseHex, harmony)`: role-offset hues off a base,
11
+ * no randomness, so dragging the harmony wheel is smooth + repeatable.
12
+ * - Seeded/jittered — `seedHarmony(baseHex, mode)`: slot 0 = the base,
13
+ * slots 1–5 derived via a mode with small random jitter, so consecutive
14
+ * "randomize" clicks produce related-but-different variants around a seed.
15
+ */
16
+
17
+ /* ── HSL / hex conversion ─────────────────────────────────────────────── */
18
+
19
+ /** '#RRGGBB' → { h: 0–360, s: 0–100, l: 0–100 }. */
20
+ export function hexToHsl(hex) {
21
+ const r = parseInt(hex.slice(1, 3), 16) / 255
22
+ const g = parseInt(hex.slice(3, 5), 16) / 255
23
+ const b = parseInt(hex.slice(5, 7), 16) / 255
24
+ const max = Math.max(r, g, b)
25
+ const min = Math.min(r, g, b)
26
+ let h = 0, s = 0
27
+ const l = (max + min) / 2
28
+ if (max !== min) {
29
+ const d = max - min
30
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
31
+ switch (max) {
32
+ case r: h = (g - b) / d + (g < b ? 6 : 0); break
33
+ case g: h = (b - r) / d + 2; break
34
+ case b: h = (r - g) / d + 4; break
35
+ }
36
+ h *= 60
37
+ }
38
+ return { h, s: s * 100, l: l * 100 }
39
+ }
40
+
41
+ /** (h: 0–360, s: 0–100, l: 0–100) → uppercase '#RRGGBB'. */
42
+ export function hslToHex(h, s, l) {
43
+ h = normHue(h)
44
+ s = Math.max(0, Math.min(100, s)) / 100
45
+ l = Math.max(0, Math.min(100, l)) / 100
46
+ const c = (1 - Math.abs(2 * l - 1)) * s
47
+ const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
48
+ const m = l - c / 2
49
+ let r = 0, g = 0, b = 0
50
+ if (h < 60) { r = c; g = x; b = 0 }
51
+ else if (h < 120) { r = x; g = c; b = 0 }
52
+ else if (h < 180) { r = 0; g = c; b = x }
53
+ else if (h < 240) { r = 0; g = x; b = c }
54
+ else if (h < 300) { r = x; g = 0; b = c }
55
+ else { r = c; g = 0; b = x }
56
+ const toHex = (v) => Math.round((v + m) * 255).toString(16).padStart(2, '0')
57
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase()
58
+ }
59
+
60
+ /** RGB components (0–255) → uppercase '#RRGGBB'. Shared with the eyedropper. */
61
+ export function rgbToHex(r, g, b) {
62
+ return '#' + [r, g, b]
63
+ .map((n) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0'))
64
+ .join('')
65
+ .toUpperCase()
66
+ }
67
+
68
+ /** Wrap any hue into [0, 360). */
69
+ export const normHue = (h) => ((h % 360) + 360) % 360
70
+
71
+ /* ── Harmony schemes ──────────────────────────────────────────────────── */
72
+
73
+ /**
74
+ * Hue offsets for the five palette roles (Primary / Secondary / Light /
75
+ * Dark / Accent) relative to the base hue. The wheel draws a satellite
76
+ * marker per unique offset; the generators emit one color per role.
77
+ */
78
+ export const HARMONIES = [
79
+ { id: 'analogous', label: 'Analogous', roleOffsets: [0, -30, -15, 15, 30] },
80
+ { id: 'complementary', label: 'Complementary', roleOffsets: [0, 0, 0, 180, 180] },
81
+ { id: 'split', label: 'Split complementary', roleOffsets: [0, 150, 210, 0, 150] },
82
+ { id: 'triadic', label: 'Triadic', roleOffsets: [0, 120, 240, 0, 120] },
83
+ { id: 'tetradic', label: 'Tetradic', roleOffsets: [0, 90, 180, 270, 0] },
84
+ ]
85
+
86
+ /** Resolve a harmony id string OR a harmony object to the object. */
87
+ export const harmonyById = (harmony) => {
88
+ if (harmony && typeof harmony === 'object') return harmony
89
+ return HARMONIES.find((h) => h.id === harmony) ?? HARMONIES[0]
90
+ }
91
+
92
+ /**
93
+ * Deterministic role colors for a harmony, driven by a hue + fixed S/L.
94
+ * Preserves saturation/lightness across roles (only hue rotates), so the
95
+ * output tracks the wheel's satellite markers exactly.
96
+ *
97
+ * @param {number} hue base hue, 0–360
98
+ * @param {string|object} harmony harmony id or object
99
+ * @param {{saturation?: number, lightness?: number}} opts base S/L (0–100)
100
+ * @returns {string[]} one '#RRGGBB' per role offset
101
+ */
102
+ export function harmonyColors(hue, harmony, { saturation = 100, lightness = 50 } = {}) {
103
+ const { roleOffsets } = harmonyById(harmony)
104
+ return roleOffsets.map((off) => hslToHex(normHue(hue + off), saturation, lightness))
105
+ }
106
+
107
+ /**
108
+ * Deterministic role colors derived from a base hex (S/L taken from the hex).
109
+ * Convenience wrapper over `harmonyColors` for callers holding a color, not
110
+ * a hue.
111
+ */
112
+ export function generateHarmony(baseHex, harmony) {
113
+ const { h, s, l } = hexToHsl(baseHex)
114
+ return harmonyColors(h, harmony, { saturation: s, lightness: l })
115
+ }
116
+
117
+ /* ── Seeded (jittered) modes ──────────────────────────────────────────── */
118
+
119
+ const jitter = (range) => (Math.random() - 0.5) * 2 * range
120
+
121
+ /**
122
+ * Seed-based generators — slot 0 = the base, slots 1–5 derived via the mode.
123
+ * Each call jitters parameters so repeated "randomize" produces visibly
124
+ * different but harmonically-related variants around the same seed.
125
+ */
126
+ const SEED_MODES = {
127
+ random: (base) => {
128
+ const { h, s, l } = hexToHsl(base)
129
+ return [
130
+ base,
131
+ hslToHex(normHue(h + Math.random() * 360), s, l),
132
+ hslToHex(normHue(h + Math.random() * 360), s, l),
133
+ hslToHex(h, s, Math.max(10, l - 25 + jitter(10))),
134
+ hslToHex(h, s, Math.min(90, l + 25 + jitter(10))),
135
+ hslToHex(h, s * (0.4 + Math.random() * 0.4), l),
136
+ ]
137
+ },
138
+
139
+ monochromatic: (base) => {
140
+ const { h, s, l } = hexToHsl(base)
141
+ const j = 8
142
+ return [
143
+ base,
144
+ hslToHex(h, s, Math.max(10, l - 30 + jitter(j))),
145
+ hslToHex(h, s, Math.max(20, l - 15 + jitter(j))),
146
+ hslToHex(h, s, Math.min(85, l + 15 + jitter(j))),
147
+ hslToHex(h, s, Math.min(95, l + 30 + jitter(j))),
148
+ hslToHex(h, s, Math.min(98, l + 45 + jitter(j))),
149
+ ]
150
+ },
151
+
152
+ analogous: (base) => {
153
+ const { h, s, l } = hexToHsl(base)
154
+ return [
155
+ base,
156
+ hslToHex(normHue(h - 30 + jitter(8)), s, l),
157
+ hslToHex(normHue(h - 15 + jitter(8)), s, l),
158
+ hslToHex(normHue(h + 15 + jitter(8)), s, l),
159
+ hslToHex(normHue(h + 30 + jitter(8)), s, l),
160
+ hslToHex(normHue(h + 45 + jitter(8)), s, l),
161
+ ]
162
+ },
163
+
164
+ complementary: (base) => {
165
+ const { h, s, l } = hexToHsl(base)
166
+ return [
167
+ base,
168
+ hslToHex(h, s, Math.max(15, l - 25 + jitter(8))),
169
+ hslToHex(h, s, Math.min(85, l + 25 + jitter(8))),
170
+ hslToHex(normHue(h + 180 + jitter(10)), s, l),
171
+ hslToHex(normHue(h + 180 + jitter(10)), s, Math.max(15, l - 15 + jitter(8))),
172
+ hslToHex(normHue(h + 180 + jitter(10)), s, Math.min(85, l + 15 + jitter(8))),
173
+ ]
174
+ },
175
+
176
+ triadic: (base) => {
177
+ const { h, s, l } = hexToHsl(base)
178
+ return [
179
+ base,
180
+ hslToHex(normHue(h + 120 + jitter(10)), s, l),
181
+ hslToHex(normHue(h + 240 + jitter(10)), s, l),
182
+ hslToHex(h, s, Math.min(85, l + 20 + jitter(8))),
183
+ hslToHex(normHue(h + 120 + jitter(10)), s, Math.max(15, l - 20 + jitter(8))),
184
+ hslToHex(normHue(h + 240 + jitter(10)), s, Math.min(85, l + 20 + jitter(8))),
185
+ ]
186
+ },
187
+
188
+ doubleComplementary: (base) => {
189
+ const { h, s, l } = hexToHsl(base)
190
+ return [
191
+ base,
192
+ hslToHex(normHue(h + 90 + jitter(10)), s, l),
193
+ hslToHex(normHue(h + 180 + jitter(10)), s, l),
194
+ hslToHex(normHue(h + 270 + jitter(10)), s, l),
195
+ hslToHex(h, s, Math.min(85, l + 20 + jitter(8))),
196
+ hslToHex(normHue(h + 180 + jitter(10)), s, Math.max(15, l - 20 + jitter(8))),
197
+ ]
198
+ },
199
+ }
200
+
201
+ /** The available seed-mode ids (for a consumer's mode selector). */
202
+ export const SEED_MODE_IDS = Object.keys(SEED_MODES)
203
+
204
+ /**
205
+ * Generate a jittered 6-color palette from a seed hex.
206
+ *
207
+ * @param {string} baseHex the seed color, occupies slot 0
208
+ * @param {string} mode one of SEED_MODE_IDS (falls back to 'random')
209
+ * @returns {string[]} six '#RRGGBB' values
210
+ */
211
+ export function seedHarmony(baseHex, mode = 'random') {
212
+ const gen = SEED_MODES[mode] ?? SEED_MODES.random
213
+ return gen(baseHex)
214
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * useEyedropper — cross-browser color sampling.
3
+ *
4
+ * Primary path is the native `window.EyeDropper` API (Chromium): one call,
5
+ * samples any pixel on screen, returns an sRGB hex. Where it is unavailable
6
+ * (Firefox, Safari) the hook falls back to an injected `fallback` sampler —
7
+ * e.g. `pickFromCanvasElement`, which samples a pixel from a <canvas> the
8
+ * consumer renders. The DS stays decoupled: it never reaches into an app's
9
+ * canvas/scene — the consumer supplies the fallback.
10
+ *
11
+ * Pairs with the EyedropPick affordance in SwatchControls: gate the button on
12
+ * `supported`, wire the button's `onPick` to `pick`.
13
+ *
14
+ * const { supported, pick } = useEyedropper({ fallback })
15
+ * // supported → render the eyedropper button
16
+ * const hex = await pick() // '#RRGGBB' or null (cancelled / unavailable)
17
+ */
18
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
19
+ import { rgbToHex } from './colorMath.js'
20
+
21
+ /* Native support probe — read at call time so it is correct even before the
22
+ * post-mount state (below) has settled. */
23
+ function hasNativeEyeDropper() {
24
+ return typeof window !== 'undefined' && 'EyeDropper' in window
25
+ }
26
+
27
+ /**
28
+ * Generic canvas-region sampler — the reusable core of a canvas eyedropper.
29
+ * Sets a crosshair cursor on `canvas`, awaits one pointer press on it, reads
30
+ * the pixel under the cursor via getImageData, and resolves the hex. Escape,
31
+ * a press outside the canvas bounds, or an aborted `signal` resolve to null.
32
+ *
33
+ * Backing-store aware: scales client coords by canvas.width/height ÷ CSS box,
34
+ * so a HiDPI or CSS-scaled canvas still samples the right pixel.
35
+ *
36
+ * @param {HTMLCanvasElement} canvas the canvas to sample from
37
+ * @param {{signal?: AbortSignal}} [opts]
38
+ * @returns {Promise<string|null>} uppercase '#RRGGBB' or null
39
+ */
40
+ export async function pickFromCanvasElement(canvas, { signal } = {}) {
41
+ if (!canvas || typeof canvas.getContext !== 'function') return null
42
+ const ctx = canvas.getContext('2d', { willReadFrequently: true })
43
+ if (!ctx) return null
44
+ if (signal?.aborted) return null
45
+
46
+ const prevCursor = canvas.style.cursor
47
+ canvas.style.cursor = 'crosshair'
48
+
49
+ return new Promise((resolve) => {
50
+ const cleanup = () => {
51
+ canvas.removeEventListener('pointerdown', onDown, true)
52
+ document.removeEventListener('keydown', onKey, true)
53
+ signal?.removeEventListener('abort', onAbort)
54
+ canvas.style.cursor = prevCursor
55
+ }
56
+
57
+ const onDown = (e) => {
58
+ e.preventDefault()
59
+ e.stopPropagation()
60
+ const rect = canvas.getBoundingClientRect()
61
+ const sx = rect.width ? canvas.width / rect.width : 1
62
+ const sy = rect.height ? canvas.height / rect.height : 1
63
+ const vx = Math.floor((e.clientX - rect.left) * sx)
64
+ const vy = Math.floor((e.clientY - rect.top) * sy)
65
+ if (vx < 0 || vy < 0 || vx >= canvas.width || vy >= canvas.height) {
66
+ cleanup(); resolve(null); return
67
+ }
68
+ let data
69
+ try { data = ctx.getImageData(vx, vy, 1, 1).data }
70
+ catch { cleanup(); resolve(null); return } /* tainted canvas */
71
+ cleanup()
72
+ resolve(rgbToHex(data[0], data[1], data[2]))
73
+ }
74
+
75
+ const onKey = (e) => {
76
+ if (e.key === 'Escape') { e.preventDefault(); cleanup(); resolve(null) }
77
+ }
78
+
79
+ const onAbort = () => { cleanup(); resolve(null) }
80
+
81
+ /* Capture phase so we beat the consumer's own canvas handlers. */
82
+ canvas.addEventListener('pointerdown', onDown, true)
83
+ document.addEventListener('keydown', onKey, true)
84
+ signal?.addEventListener('abort', onAbort)
85
+ })
86
+ }
87
+
88
+ /**
89
+ * @param {{fallback?: (opts?: {signal?: AbortSignal}) => Promise<string|null>}} [options]
90
+ * fallback — sampler used when the native EyeDropper API is absent. Return
91
+ * a hex or null. Without a fallback, unsupported browsers report
92
+ * `supported: false` and `pick` resolves null.
93
+ * @returns {{supported: boolean, nativeSupported: boolean,
94
+ * pick: (opts?: {signal?: AbortSignal}) => Promise<string|null>}}
95
+ */
96
+ export function useEyedropper({ fallback } = {}) {
97
+ /* Resolved after mount so SSR / first paint stays stable (starts false,
98
+ * flips true only where the native API exists). */
99
+ const [nativeSupported, setNativeSupported] = useState(false)
100
+ useEffect(() => { setNativeSupported(hasNativeEyeDropper()) }, [])
101
+
102
+ const fallbackRef = useRef(fallback)
103
+ fallbackRef.current = fallback
104
+
105
+ const supported = nativeSupported || !!fallback
106
+
107
+ const pick = useCallback(async ({ signal } = {}) => {
108
+ if (hasNativeEyeDropper()) {
109
+ try {
110
+ const res = await new window.EyeDropper().open(signal ? { signal } : undefined)
111
+ return res?.sRGBHex ? res.sRGBHex.toUpperCase() : null
112
+ } catch {
113
+ return null /* user cancelled (Escape) rejects the promise */
114
+ }
115
+ }
116
+ if (fallbackRef.current) return (await fallbackRef.current({ signal })) ?? null
117
+ return null
118
+ }, [])
119
+
120
+ return useMemo(
121
+ () => ({ supported, nativeSupported, pick }),
122
+ [supported, nativeSupported, pick],
123
+ )
124
+ }
package/src/index.js CHANGED
@@ -76,6 +76,7 @@ export { default as MediaRow } from './molecules/MediaRow.jsx'
76
76
  export { MenuItem, MenuDropdownItem, MenuDropdownDivider, MenuDropdownNest } from './molecules/MenuItem.jsx'
77
77
  export { MenuPopover } from './molecules/MenuPopover.jsx'
78
78
  export { ModalProvider, useModal } from './molecules/Modal.jsx'
79
+ export { default as PaletteHarmonyWheel } from './molecules/PaletteHarmonyWheel.jsx'
79
80
  export { default as PropertyInput } from './molecules/PropertyInput.jsx'
80
81
  export { default as ShapeDropdown } from './molecules/ShapeDropdown.jsx'
81
82
  export { default as ShellDrawer } from './molecules/ShellDrawer.jsx'
@@ -123,4 +124,12 @@ export { default as useReveal } from './hooks/useReveal.js'
123
124
  export { default as useScrollSpy } from './hooks/useScrollSpy.js'
124
125
  export { default as useTilt } from './hooks/useTilt.js'
125
126
  export { default as useAxisAnimation } from './hooks/useAxisAnimation.js'
127
+ export { useEyedropper, pickFromCanvasElement } from './hooks/useEyedropper.js'
126
128
  export { resolveCssVar, resolveCssColor, isLight } from './hooks/cssVar.js'
129
+
130
+ // color math (support module — HSL/hex conversion + harmony generation)
131
+ export {
132
+ hexToHsl, hslToHex, rgbToHex, normHue,
133
+ HARMONIES, harmonyById, harmonyColors, generateHarmony,
134
+ SEED_MODE_IDS, seedHarmony,
135
+ } from './hooks/colorMath.js'
@@ -38,8 +38,10 @@ export function AccordionPanel({
38
38
  onClick={toggle}
39
39
  aria-expanded={open}
40
40
  >
41
- <span className="kol-accordion-title kol-helper-12 uppercase tracking-widest">{title}</span>
42
- {meta && <span className="kol-accordion-meta kol-helper-12 uppercase tracking-widest text-fg-48 ml-auto mr-3">{meta}</span>}
41
+ {/* no text-transform (casing law 2026-07-15): titles render as authored —
42
+ * kol-helper-12 already carries the tracked-caps letterspacing */}
43
+ <span className="kol-accordion-title kol-helper-12">{title}</span>
44
+ {meta && <span className="kol-accordion-meta kol-helper-12 text-fg-48 ml-auto mr-3">{meta}</span>}
43
45
  <span className="kol-accordion-chevron kol-helper-16 text-fg-48 min-w-3 transition-colors duration-[120ms] ml-auto" aria-hidden="true">{open ? '−' : '+'}</span>
44
46
  </button>
45
47
  {open && <div className="kol-accordion-body pt-2 pb-6">{children}</div>}
@@ -13,6 +13,8 @@ import { PopoverPanel, usePopover } from '../atoms/Popover.jsx'
13
13
  *
14
14
  * variant="primary" (default) — filled trigger; open panel continues the
15
15
  * same fill (one piece: no border, no gap, hairline divider inside)
16
+ * variant="grey" — oq-12 filled trigger (opaque per the fill
17
+ * law); panel continues it
16
18
  * variant="outline" — bordered trigger; open panel carries the
17
19
  * same border, trigger's bottom edge acts as the divider
18
20
  *
@@ -110,7 +112,9 @@ const Dropdown = ({
110
112
 
111
113
  const triggerCls = [
112
114
  'kol-btn',
113
- `kol-btn-${resolvedVariant}`,
115
+ /* grey is dropdown-only chrome (2026-07-15) — no kol-btn-* class so it
116
+ * never leaks into Button's variant set */
117
+ resolvedVariant === 'grey' ? 'kol-dd-trigger--grey' : `kol-btn-${resolvedVariant}`,
114
118
  `kol-btn-${resolvedSize}`,
115
119
  SIZE_TYPE[resolvedSize],
116
120
  'kol-dd-trigger',
@@ -154,7 +158,7 @@ const Dropdown = ({
154
158
  focus={false}
155
159
  className={`kol-dd-panel kol-dd-panel--${resolvedVariant}`}
156
160
  >
157
- {resolvedVariant === 'primary' && <div className="kol-dd-div" />}
161
+ {(resolvedVariant === 'primary' || resolvedVariant === 'grey') && <div className="kol-dd-div" />}
158
162
 
159
163
  <div className="flex max-h-[300px] flex-col items-stretch overflow-y-auto" role="listbox">
160
164
  {options.map((option) => {
@@ -95,7 +95,7 @@ export function MenuDropdownItem({ onClick, disabled, prefix, iconLeft, shortcut
95
95
  onClick={onClick}
96
96
  disabled={disabled}
97
97
  role="menuitem"
98
- className="w-full kol-helper-12 px-3 h-8 inline-flex items-center gap-2 text-body hover:text-emphasis disabled:opacity-40 disabled:cursor-not-allowed text-left"
98
+ className="w-full kol-helper-12 px-3 h-8 shrink-0 inline-flex items-center gap-2 text-body hover:text-emphasis disabled:opacity-40 disabled:cursor-not-allowed text-left"
99
99
  >
100
100
  {prefix && <span className="shrink-0 inline-flex items-center">{prefix}</span>}
101
101
  {iconLeft && <span className="shrink-0 w-4 inline-flex items-center justify-center">{iconLeft}</span>}
@@ -0,0 +1,192 @@
1
+ import { useEffect, useRef } from 'react'
2
+ import { HARMONIES, harmonyById, harmonyColors, normHue } from '../hooks/colorMath.js'
3
+
4
+ /* taxonomy-ok: a pure-canvas hue + harmony picker in the SpectrumControls
5
+ * color-picker family. It nests no KOL component — its only import is the
6
+ * colorMath support module in hooks/ — but belongs beside its siblings
7
+ * SpectrumControls / SwatchControls, not down in atoms. */
8
+
9
+ /*
10
+ * PaletteHarmonyWheel — a hue ring with a draggable base-hue handle and
11
+ * satellite markers at the active harmony's scheme hues. Dragging (or the
12
+ * arrow keys) rotates the base hue; every change emits the full set of
13
+ * harmony colors so the caller never re-derives them.
14
+ *
15
+ * The scheme table is DATA-INJECTED (`harmonies`, defaulting to the shared
16
+ * HARMONIES); saturation/lightness of the generated colors are injected too,
17
+ * so this stays a pure hue picker — the caller owns S/L.
18
+ *
19
+ * Controlled on `hue`; `onChange({ hue, colors })` fires on press, drag, and
20
+ * arrow keys. `colors` is one hex per role offset of the active harmony
21
+ * (see colorMath.harmonyColors), matching the satellite markers 1:1.
22
+ *
23
+ * The ring hues, marker outlines and handle halo are literal color math
24
+ * (hsl / #FFFFFF / rgba) on purpose — a spectrum is not themeable, and the
25
+ * markers sit on fully-saturated ring hues, not on the surface (same
26
+ * rationale as SpectrumControls). No token belongs here.
27
+ *
28
+ * @param {number} size px, square (default 248)
29
+ * @param {number} hue controlled base hue, 0–360
30
+ * @param {string|object} harmony active harmony id or object (default 'analogous')
31
+ * @param {number} saturation base saturation for emitted colors, 0–100 (default 100)
32
+ * @param {number} lightness base lightness for emitted colors, 0–100 (default 50)
33
+ * @param {Array} harmonies injectable scheme table (default HARMONIES)
34
+ * @param {Function} onChange ({ hue, colors }) => void
35
+ */
36
+
37
+ /* Marker outline — white for contrast against the fully-saturated ring hues
38
+ * (theme-independent: the wheel's colours, not the surface, sit behind it). */
39
+ const MARKER_STROKE = '#FFFFFF'
40
+
41
+ export default function PaletteHarmonyWheel({
42
+ size = 248,
43
+ hue,
44
+ harmony = 'analogous',
45
+ saturation = 100,
46
+ lightness = 50,
47
+ harmonies = HARMONIES,
48
+ onChange,
49
+ }) {
50
+ const canvasRef = useRef(null)
51
+ const draggingRef = useRef(false)
52
+ const emitRef = useRef(null)
53
+
54
+ const active = harmonyById(
55
+ typeof harmony === 'string'
56
+ ? (harmonies.find((h) => h.id === harmony) ?? harmonies[0])
57
+ : harmony,
58
+ )
59
+
60
+ /* Emit next hue + its harmony colors. Held in a ref so the pointer/key
61
+ * handlers stay stable while always seeing the latest props. */
62
+ emitRef.current = (nextHue) => {
63
+ const h = normHue(nextHue)
64
+ onChange?.({ hue: h, colors: harmonyColors(h, active, { saturation, lightness }) })
65
+ }
66
+
67
+ const outerR = size / 2 - 8
68
+ const innerR = outerR - 22
69
+ const midR = (outerR + innerR) / 2
70
+
71
+ useEffect(() => {
72
+ const canvas = canvasRef.current
73
+ if (!canvas) return
74
+ const dpr = window.devicePixelRatio || 1
75
+ canvas.width = size * dpr
76
+ canvas.height = size * dpr
77
+ const ctx = canvas.getContext('2d')
78
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
79
+ ctx.clearRect(0, 0, size, size)
80
+ const cx = size / 2
81
+ const cy = size / 2
82
+
83
+ /* Angle mapping: hue 0 at 12 o'clock, clockwise. */
84
+ const angleFor = (h) => ((h - 90) * Math.PI) / 180
85
+
86
+ /* Hue ring — 360 thin annulus wedges (1.5° overlap hides seams). */
87
+ for (let a = 0; a < 360; a++) {
88
+ const a0 = angleFor(a)
89
+ const a1 = angleFor(a + 1.5)
90
+ ctx.beginPath()
91
+ ctx.arc(cx, cy, outerR, a0, a1)
92
+ ctx.arc(cx, cy, innerR, a1, a0, true)
93
+ ctx.closePath()
94
+ ctx.fillStyle = `hsl(${a}, 100%, 50%)`
95
+ ctx.fill()
96
+ }
97
+
98
+ const baseHue = normHue(hue)
99
+ const offsets = [...new Set((active?.roleOffsets ?? [0]).map(normHue))]
100
+
101
+ /* Spokes + satellite markers at the scheme hues. */
102
+ for (const off of offsets) {
103
+ const h = normHue(baseHue + off)
104
+ const a = angleFor(h)
105
+ ctx.beginPath()
106
+ ctx.moveTo(cx, cy)
107
+ ctx.lineTo(cx + Math.cos(a) * innerR, cy + Math.sin(a) * innerR)
108
+ ctx.strokeStyle = 'rgba(128, 128, 128, 0.4)'
109
+ ctx.lineWidth = 1
110
+ ctx.stroke()
111
+ if (off === 0) continue /* base hue gets the big handle below */
112
+ const x = cx + Math.cos(a) * midR
113
+ const y = cy + Math.sin(a) * midR
114
+ ctx.beginPath()
115
+ ctx.arc(x, y, 5, 0, Math.PI * 2)
116
+ ctx.fillStyle = `hsl(${h}, 100%, 50%)`
117
+ ctx.fill()
118
+ ctx.strokeStyle = MARKER_STROKE
119
+ ctx.lineWidth = 1.5
120
+ ctx.stroke()
121
+ }
122
+
123
+ /* Base-hue handle. */
124
+ const ba = angleFor(baseHue)
125
+ const bx = cx + Math.cos(ba) * midR
126
+ const by = cy + Math.sin(ba) * midR
127
+ ctx.beginPath()
128
+ ctx.arc(bx, by, 9, 0, Math.PI * 2)
129
+ ctx.fillStyle = `hsl(${baseHue}, 100%, 50%)`
130
+ ctx.fill()
131
+ ctx.strokeStyle = MARKER_STROKE
132
+ ctx.lineWidth = 2
133
+ ctx.stroke()
134
+ ctx.beginPath()
135
+ ctx.arc(bx, by, 10.5, 0, Math.PI * 2)
136
+ ctx.strokeStyle = 'rgba(0, 0, 0, 0.35)'
137
+ ctx.lineWidth = 1
138
+ ctx.stroke()
139
+ }, [size, hue, active, outerR, innerR, midR])
140
+
141
+ const pointFromEvent = (e) => {
142
+ const rect = canvasRef.current.getBoundingClientRect()
143
+ const dx = e.clientX - rect.left - rect.width / 2
144
+ const dy = e.clientY - rect.top - rect.height / 2
145
+ return { dist: Math.hypot(dx, dy), hue: normHue((Math.atan2(dy, dx) * 180) / Math.PI + 90) }
146
+ }
147
+
148
+ const onPointerDown = (e) => {
149
+ const { dist, hue: h } = pointFromEvent(e)
150
+ if (dist < innerR - 14 || dist > outerR + 10) return
151
+ draggingRef.current = true
152
+ e.currentTarget.setPointerCapture(e.pointerId)
153
+ emitRef.current?.(h)
154
+ }
155
+
156
+ const onPointerMove = (e) => {
157
+ if (!draggingRef.current) return
158
+ emitRef.current?.(pointFromEvent(e).hue)
159
+ }
160
+
161
+ const endDrag = () => { draggingRef.current = false }
162
+
163
+ const onKeyDown = (e) => {
164
+ const step = e.shiftKey ? 15 : 3
165
+ if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
166
+ e.preventDefault()
167
+ emitRef.current?.(normHue(hue + step))
168
+ } else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
169
+ e.preventDefault()
170
+ emitRef.current?.(normHue(hue - step))
171
+ }
172
+ }
173
+
174
+ return (
175
+ <canvas
176
+ ref={canvasRef}
177
+ role="slider"
178
+ aria-label="Base hue"
179
+ aria-valuemin={0}
180
+ aria-valuemax={359}
181
+ aria-valuenow={Math.round(normHue(hue))}
182
+ tabIndex={0}
183
+ className="cursor-pointer touch-none"
184
+ style={{ width: size, height: size }}
185
+ onPointerDown={onPointerDown}
186
+ onPointerMove={onPointerMove}
187
+ onPointerUp={endDrag}
188
+ onPointerCancel={endDrag}
189
+ onKeyDown={onKeyDown}
190
+ />
191
+ )
192
+ }