@kolkrabbi/kol-component 0.2.0 → 0.3.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.
Files changed (74) hide show
  1. package/package.json +11 -3
  2. package/src/atoms/AnimatedTitle.jsx +108 -0
  3. package/src/atoms/AssetGrid.jsx +29 -0
  4. package/src/atoms/Button.jsx +17 -6
  5. package/src/atoms/CurveOverlay.jsx +180 -0
  6. package/src/atoms/DocsToc.jsx +48 -0
  7. package/src/atoms/EmptyState.jsx +22 -0
  8. package/src/atoms/Figure.jsx +27 -0
  9. package/src/atoms/HlsVideo.jsx +56 -0
  10. package/src/atoms/OverlayGlassPanel.jsx +40 -0
  11. package/src/atoms/PriceDisplay.jsx +34 -0
  12. package/src/atoms/ProsePreview.jsx +53 -0
  13. package/src/atoms/RotaryDial.jsx +150 -0
  14. package/src/atoms/SearchInput.jsx +108 -0
  15. package/src/atoms/TextPressure.jsx +331 -0
  16. package/src/atoms/TiltCard.jsx +127 -0
  17. package/src/atoms/TypeSample.jsx +50 -0
  18. package/src/atoms/TypeSpecCard.jsx +42 -0
  19. package/src/hooks/cssVar.js +53 -0
  20. package/src/hooks/useAxisAnimation.js +91 -0
  21. package/src/hooks/usePrefersReducedMotion.js +21 -0
  22. package/src/hooks/useTilt.js +49 -0
  23. package/src/index.js +66 -1
  24. package/src/molecules/AlignmentGrid.jsx +53 -0
  25. package/src/molecules/ArticleCard.jsx +178 -0
  26. package/src/molecules/CardFeatureItem.jsx +130 -0
  27. package/src/molecules/ColorInputRow.jsx +179 -0
  28. package/src/molecules/ColorRamp.jsx +114 -0
  29. package/src/molecules/FramedMediaBand.jsx +56 -0
  30. package/src/molecules/ImageBlock.jsx +34 -0
  31. package/src/molecules/SelectionOverlay.jsx +108 -0
  32. package/src/molecules/ShapeDropdown.jsx +92 -0
  33. package/src/molecules/ShellDrawer.jsx +169 -0
  34. package/src/molecules/ShellSearchOverlay.jsx +177 -0
  35. package/src/molecules/SpecList.jsx +30 -0
  36. package/src/molecules/SpectrumControls.jsx +504 -0
  37. package/src/molecules/SwatchControls.jsx +217 -0
  38. package/src/molecules/TabsRow.jsx +87 -0
  39. package/src/molecules/VideoBlock.jsx +86 -0
  40. package/src/molecules/WorkListItem.jsx +83 -0
  41. package/src/molecules/foundry/SpecimenSectionHeader.jsx +89 -0
  42. package/src/organisms/ArticleHeader.jsx +95 -0
  43. package/src/organisms/AsciiCursor.jsx +526 -0
  44. package/src/organisms/BentoCard.jsx +187 -0
  45. package/src/organisms/Canvas.jsx +299 -0
  46. package/src/organisms/ColorLoader.jsx +155 -0
  47. package/src/organisms/CtaGlobal.jsx +67 -0
  48. package/src/organisms/DiagonalMarqueeRiver.jsx +138 -0
  49. package/src/organisms/EditorShell.jsx +111 -0
  50. package/src/organisms/ErrorBoundary.jsx +70 -0
  51. package/src/organisms/FeatureSplit.jsx +82 -0
  52. package/src/organisms/FeaturedCarousel.jsx +258 -0
  53. package/src/organisms/FeaturesCardSection.jsx +90 -0
  54. package/src/organisms/FullBleedHero.jsx +111 -0
  55. package/src/organisms/GalleryCarousel.jsx +83 -0
  56. package/src/organisms/LoaderOverlay.jsx +30 -0
  57. package/src/organisms/MediaViewer.jsx +95 -0
  58. package/src/organisms/NewsletterBand.jsx +122 -0
  59. package/src/organisms/ParallaxShelf.jsx +141 -0
  60. package/src/organisms/PortableTextRenderer.jsx +115 -0
  61. package/src/organisms/ProductDetailLayout.jsx +189 -0
  62. package/src/organisms/ScrollDriftGallery.jsx +214 -0
  63. package/src/organisms/SpectrumGrid.jsx +90 -0
  64. package/src/organisms/StackHero.jsx +83 -0
  65. package/src/organisms/WorkCard.jsx +120 -0
  66. package/src/organisms/WorkViewToggle.jsx +170 -0
  67. package/src/organisms/foundry/FontPreviewSection.jsx +187 -0
  68. package/src/organisms/foundry/FoundryCharacterSets.jsx +113 -0
  69. package/src/organisms/foundry/GlyphMetricsGrid.jsx +335 -0
  70. package/src/organisms/foundry/TypefaceHero.jsx +107 -0
  71. package/src/organisms/foundry/TypefaceStyleSection.jsx +163 -0
  72. package/src/organisms/foundry/VariableFontSection.jsx +158 -0
  73. package/src/organisms/foundry/glyphData.js +30 -0
  74. package/src/organisms/foundry/index.js +21 -0
@@ -0,0 +1,504 @@
1
+ import { useCallback, useEffect, useId, useRef } from 'react'
2
+
3
+ /* taxonomy-ok: the default composed control nests the same-file HueStrip and
4
+ * SBSquare (KOL named exports the relative-import check can't see). */
5
+
6
+ /*
7
+ * SpectrumControls — the HSV color-picker family: three hand-tuned widgets
8
+ * plus the composed classic picker. Hand-tuned visuals (specific gradients,
9
+ * inset math, handle sizes, barycentric SV math, conic-gradient ring with
10
+ * clip-path mask). DO NOT refactor pieces to atoms — the look and the math
11
+ * are intentional and tuned as a unit.
12
+ *
13
+ * <SpectrumControls value onChange /> (default export)
14
+ * — HueStrip stacked over a fill-height SBSquare (the classic square
15
+ * picker, per the source panel's Hue mode layout).
16
+ *
17
+ * <HueStrip hue onChange />
18
+ * — 1D hue slider (12px tall) with a knob constrained inside the box.
19
+ *
20
+ * <SBSquare hue sat val onChange />
21
+ * — 2D saturation/value picker. Uses available width × height of its
22
+ * container; gradient is white→hue horizontally with a black overlay
23
+ * fading to transparent upward.
24
+ *
25
+ * <WheelTriangle hue sat val onChangeHue onChangeSV />
26
+ * — Hue ring + inscribed rotating HSV triangle. Ring snaps-on-click;
27
+ * triangle uses grab-with-offset (handle stays put on click, drag
28
+ * preserves cursor↔handle delta) so fine sat/val tuning works.
29
+ *
30
+ * All colors here are literal color math (hsl/#000/#fff/#505050) on purpose —
31
+ * a spectrum is not themeable. The only token is the handle's `bg-fg`.
32
+ */
33
+
34
+ /* Handle radius (px) used to inset both the handle's positioning area AND
35
+ * the drag hit-test math so the knob always renders inside the spectrum
36
+ * frame. Without this the handle's center maps to 0/100% of the box and
37
+ * half the knob hangs off the edge. */
38
+ const HANDLE_R = 7
39
+
40
+ const HUE_GRADIENT =
41
+ 'linear-gradient(to right, hsl(0,100%,50%), hsl(60,100%,50%), hsl(120,100%,50%), hsl(180,100%,50%), hsl(240,100%,50%), hsl(300,100%,50%), hsl(360,100%,50%))'
42
+
43
+ /**
44
+ * HueStrip — 1D hue slider: a rainbow bar with a draggable knob. Pointer
45
+ * drag anywhere on the strip; focus + Left/Right (or Down/Up) arrow keys
46
+ * nudge hue by 1° (role="slider").
47
+ *
48
+ * @param {number} hue controlled hue, 0–360
49
+ * @param {Function} onChange (hue: number) => void — fires on press, drag, and arrow keys
50
+ */
51
+ export function HueStrip({ hue, onChange }) {
52
+ const ref = useRef(null)
53
+ const dragging = useRef(false)
54
+
55
+ const update = useCallback((clientX) => {
56
+ const node = ref.current
57
+ if (!node) return
58
+ const rect = node.getBoundingClientRect()
59
+ const inner = rect.width - 2 * HANDLE_R
60
+ if (inner <= 0) return
61
+ const ratio = Math.max(0, Math.min(1, (clientX - rect.left - HANDLE_R) / inner))
62
+ onChange(ratio * 360)
63
+ }, [onChange])
64
+
65
+ const onDown = (e) => {
66
+ dragging.current = true
67
+ update(e.clientX)
68
+ e.preventDefault()
69
+ }
70
+
71
+ useEffect(() => {
72
+ const onMove = (e) => { if (dragging.current) update(e.clientX) }
73
+ const onUp = () => { dragging.current = false }
74
+ window.addEventListener('pointermove', onMove)
75
+ window.addEventListener('pointerup', onUp)
76
+ window.addEventListener('pointercancel', onUp)
77
+ return () => {
78
+ window.removeEventListener('pointermove', onMove)
79
+ window.removeEventListener('pointerup', onUp)
80
+ window.removeEventListener('pointercancel', onUp)
81
+ }
82
+ }, [update])
83
+
84
+ const onKeyDown = (e) => {
85
+ let next = null
86
+ if (e.key === 'ArrowRight' || e.key === 'ArrowUp') next = Math.min(360, hue + 1)
87
+ else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') next = Math.max(0, hue - 1)
88
+ if (next === null) return
89
+ e.preventDefault()
90
+ onChange(next)
91
+ }
92
+
93
+ const pos = (hue / 360) * 100
94
+
95
+ return (
96
+ <div
97
+ ref={ref}
98
+ role="slider"
99
+ tabIndex={0}
100
+ aria-label="Hue"
101
+ aria-valuemin={0}
102
+ aria-valuemax={360}
103
+ aria-valuenow={Math.round(hue)}
104
+ onPointerDown={onDown}
105
+ onKeyDown={onKeyDown}
106
+ className="relative rounded-[2px] cursor-pointer touch-none"
107
+ style={{ height: 12, background: HUE_GRADIENT }}
108
+ >
109
+ {/* Handle positions inside an inset region so 0..100% maps to the
110
+ * area where the knob can sit without spilling out. */}
111
+ <div className="absolute inset-y-0" style={{ left: HANDLE_R, right: HANDLE_R }}>
112
+ <Handle left={`${pos}%`} top="50%" />
113
+ </div>
114
+ </div>
115
+ )
116
+ }
117
+
118
+ /**
119
+ * SBSquare — 2D saturation/value picker: white→hue horizontal gradient with
120
+ * a black overlay fading upward, crosshair handle. Fills its container
121
+ * (consumer owns the size — and the rounding: apply `rounded-[2px]
122
+ * overflow-hidden` on the wrapper). Focus + arrow keys nudge: Left/Right =
123
+ * saturation ±1, Up/Down = value ±1 (role="slider").
124
+ *
125
+ * @param {number} hue hue for the gradient endpoint, 0–360
126
+ * @param {number} sat controlled saturation, 0–100
127
+ * @param {number} val controlled value/brightness, 0–100
128
+ * @param {Function} onChange (sat: number, val: number) => void
129
+ */
130
+ export function SBSquare({ hue, sat, val, onChange }) {
131
+ const ref = useRef(null)
132
+ const dragging = useRef(false)
133
+
134
+ const update = useCallback((clientX, clientY) => {
135
+ const node = ref.current
136
+ if (!node) return
137
+ const rect = node.getBoundingClientRect()
138
+ const innerW = rect.width - 2 * HANDLE_R
139
+ const innerH = rect.height - 2 * HANDLE_R
140
+ if (innerW <= 0 || innerH <= 0) return
141
+ const sx = Math.max(0, Math.min(1, (clientX - rect.left - HANDLE_R) / innerW))
142
+ const sy = Math.max(0, Math.min(1, (clientY - rect.top - HANDLE_R) / innerH))
143
+ onChange(sx * 100, (1 - sy) * 100)
144
+ }, [onChange])
145
+
146
+ const onDown = (e) => {
147
+ dragging.current = true
148
+ update(e.clientX, e.clientY)
149
+ e.preventDefault()
150
+ }
151
+
152
+ useEffect(() => {
153
+ const onMove = (e) => { if (dragging.current) update(e.clientX, e.clientY) }
154
+ const onUp = () => { dragging.current = false }
155
+ window.addEventListener('pointermove', onMove)
156
+ window.addEventListener('pointerup', onUp)
157
+ window.addEventListener('pointercancel', onUp)
158
+ return () => {
159
+ window.removeEventListener('pointermove', onMove)
160
+ window.removeEventListener('pointerup', onUp)
161
+ window.removeEventListener('pointercancel', onUp)
162
+ }
163
+ }, [update])
164
+
165
+ const onKeyDown = (e) => {
166
+ const clamp = (v) => Math.max(0, Math.min(100, v))
167
+ if (e.key === 'ArrowRight') onChange(clamp(sat + 1), val)
168
+ else if (e.key === 'ArrowLeft') onChange(clamp(sat - 1), val)
169
+ else if (e.key === 'ArrowUp') onChange(sat, clamp(val + 1))
170
+ else if (e.key === 'ArrowDown') onChange(sat, clamp(val - 1))
171
+ else return
172
+ e.preventDefault()
173
+ }
174
+
175
+ const sb = `linear-gradient(to bottom, transparent 0%, #000 100%), linear-gradient(to right, #fff 0%, hsl(${hue},100%,50%) 100%)`
176
+ /* Gradient field is rectangular — rounding is the consumer's job (apply
177
+ * `rounded-[2px] overflow-hidden` on the wrapper). Putting border-radius
178
+ * on the gradient itself produces tiny antialiasing artifacts at the
179
+ * corners; clipping via overflow-hidden on the parent is cleaner. */
180
+ return (
181
+ <div
182
+ ref={ref}
183
+ role="slider"
184
+ tabIndex={0}
185
+ aria-label="Saturation and brightness"
186
+ aria-valuemin={0}
187
+ aria-valuemax={100}
188
+ aria-valuenow={Math.round(sat)}
189
+ aria-valuetext={`Saturation ${Math.round(sat)}%, brightness ${Math.round(val)}%`}
190
+ onPointerDown={onDown}
191
+ onKeyDown={onKeyDown}
192
+ className="relative cursor-crosshair touch-none w-full h-full"
193
+ style={{ background: sb }}
194
+ >
195
+ <div className="absolute" style={{ inset: HANDLE_R }}>
196
+ <Handle left={`${sat}%`} top={`${100 - val}%`} />
197
+ </div>
198
+ </div>
199
+ )
200
+ }
201
+
202
+ /* Wheel + rotating HSV triangle.
203
+ *
204
+ * Layout — viewBox 100×100, all coords in viewBox space.
205
+ * Center (50, 50). Outer ring radius = 48. Inner hole radius = 40.
206
+ * Triangle inscribed in a circle of radius TRI_R = 40 (vertices touch
207
+ * the inner ring edge).
208
+ *
209
+ * Triangle vertices rotate so the hue vertex always points at the active
210
+ * hue position on the ring. With angle = hue degrees from east (CCW negated
211
+ * for screen y-down):
212
+ * V_hue at angle θ
213
+ * V_white at θ - 120° (upper-left when θ = 0)
214
+ * V_black at θ + 120° (lower-left when θ = 0)
215
+ *
216
+ * SV mapping uses barycentric coords inside the triangle:
217
+ * P = a·V_hue + b·V_white + c·V_black, a+b+c = 1
218
+ * value = a + b (1 − c, height-from-black-edge in HSV space)
219
+ * saturation = a / (a + b) (0 = white side, 1 = hue side)
220
+ *
221
+ * Render: SVG with three stacked polygons —
222
+ * 1. solid hue
223
+ * 2. white-fade (linear gradient: opaque at V_white, 0 toward opposite edge)
224
+ * 3. black-fade (linear gradient: opaque at V_black, 0 toward opposite edge)
225
+ */
226
+
227
+ const RING_OUTER = 48
228
+ const RING_INNER = 40 /* thin ring band — triangle vertices land on this edge */
229
+ const TRI_R = RING_INNER /* vertices touch the inner ring edge directly */
230
+
231
+ function vertexAt(angleRad) {
232
+ return [50 + TRI_R * Math.cos(angleRad), 50 + TRI_R * Math.sin(angleRad)]
233
+ }
234
+
235
+ /**
236
+ * WheelTriangle — HSV color wheel: conic hue ring with an inscribed,
237
+ * rotating HSV triangle for saturation/value. Ring press snaps hue to the
238
+ * cursor angle and drags; triangle press grabs the SV handle with a
239
+ * cursor↔handle offset so fine tuning never teleports it. Focus + arrow
240
+ * keys nudge: Left/Right = hue ±1° (wrapping), Up/Down = value ±1
241
+ * (role="slider"; saturation is pointer-only).
242
+ *
243
+ * @param {number} hue controlled hue, 0–360 — ring handle angle + triangle rotation
244
+ * @param {number} sat controlled saturation, 0–100
245
+ * @param {number} val controlled value/brightness, 0–100
246
+ * @param {Function} onChangeHue (hue: number) => void — ring drag / hue arrow keys
247
+ * @param {Function} onChangeSV (sat: number, val: number) => void — triangle drag / value arrow keys
248
+ */
249
+ export function WheelTriangle({ hue, sat, val, onChangeHue, onChangeSV }) {
250
+ const svgRef = useRef(null)
251
+ const dragMode = useRef(null) /* { type: 'ring' } | { type: 'tri', offsetX, offsetY } | null */
252
+
253
+ /* [source-bug] fixed — the source hardcoded these three <defs> ids, so
254
+ * mounting two WheelTriangles collided (clip + gradients resolve to the
255
+ * first instance in the document). useId-derived per instance; sanitized
256
+ * because React wraps ids in ":"/"«»", which are unreliable inside
257
+ * url(#…) references. */
258
+ const uid = useId().replace(/[^a-zA-Z0-9_-]/g, '')
259
+ const ringClipId = `kol-wheel-ring-clip-${uid}`
260
+ const whiteFadeId = `kol-wheel-white-fade-${uid}`
261
+ const blackFadeId = `kol-wheel-black-fade-${uid}`
262
+
263
+ /* Convert client coords to viewBox coords (0..100). */
264
+ const toView = useCallback((clientX, clientY) => {
265
+ const node = svgRef.current
266
+ if (!node) return [50, 50]
267
+ const rect = node.getBoundingClientRect()
268
+ return [
269
+ ((clientX - rect.left) / rect.width) * 100,
270
+ ((clientY - rect.top) / rect.height) * 100,
271
+ ]
272
+ }, [])
273
+
274
+ /* Compute hue vertex angle (radians). */
275
+ const hueAngle = hue * Math.PI / 180
276
+ const angleHue = hueAngle
277
+ const angleWhite = hueAngle - (2 * Math.PI / 3)
278
+ const angleBlack = hueAngle + (2 * Math.PI / 3)
279
+ const Vh = vertexAt(angleHue)
280
+ const Vw = vertexAt(angleWhite)
281
+ const Vb = vertexAt(angleBlack)
282
+
283
+ /* Barycentric weights for point P given the triangle vertices. */
284
+ const baryFor = (px, py) => {
285
+ const [x1, y1] = Vh
286
+ const [x2, y2] = Vw
287
+ const [x3, y3] = Vb
288
+ const denom = (y2 - y3) * (x1 - x3) + (x3 - x2) * (y1 - y3)
289
+ if (Math.abs(denom) < 1e-9) return [1, 0, 0]
290
+ const a = ((y2 - y3) * (px - x3) + (x3 - x2) * (py - y3)) / denom
291
+ const b = ((y3 - y1) * (px - x3) + (x1 - x3) * (py - y3)) / denom
292
+ const c = 1 - a - b
293
+ return [a, b, c]
294
+ }
295
+
296
+ /* Clamp a point to the triangle interior using its barycentric coords. */
297
+ const clampToTriangle = (px, py) => {
298
+ let [a, b, c] = baryFor(px, py)
299
+ a = Math.max(0, a); b = Math.max(0, b); c = Math.max(0, c)
300
+ const sum = a + b + c
301
+ if (sum === 0) return [Vh[0], Vh[1], 1, 0, 0]
302
+ a /= sum; b /= sum; c /= sum
303
+ const x = a * Vh[0] + b * Vw[0] + c * Vb[0]
304
+ const y = a * Vh[1] + b * Vw[1] + c * Vb[1]
305
+ return [x, y, a, b, c]
306
+ }
307
+
308
+ /* SV handle position from current (sat, val). */
309
+ const aH = (sat / 100) * (val / 100)
310
+ const bH = (1 - sat / 100) * (val / 100)
311
+ const cH = 1 - val / 100
312
+ const handleX = aH * Vh[0] + bH * Vw[0] + cH * Vb[0]
313
+ const handleY = aH * Vh[1] + bH * Vw[1] + cH * Vb[1]
314
+
315
+ /* Hue handle on ring (midline between outer and inner radius). */
316
+ const ringR = (RING_OUTER + RING_INNER) / 2
317
+ const ringHx = 50 + ringR * Math.cos(hueAngle)
318
+ const ringHy = 50 + ringR * Math.sin(hueAngle)
319
+
320
+ /* Pointer behavior:
321
+ * - Ring → snap-on-click; continued drag tracks cursor angle.
322
+ * - Tri → grab-with-offset so fine-tuning doesn't teleport the handle. */
323
+ const onSvgDown = (e) => {
324
+ const [vx, vy] = toView(e.clientX, e.clientY)
325
+ const dist = Math.hypot(vx - 50, vy - 50)
326
+ if (dist > RING_INNER) {
327
+ const cursorAngle = (Math.atan2(vy - 50, vx - 50) * 180 / Math.PI + 360) % 360
328
+ onChangeHue(cursorAngle)
329
+ dragMode.current = { type: 'ring' }
330
+ } else {
331
+ dragMode.current = {
332
+ type: 'tri',
333
+ offsetX: handleX - vx,
334
+ offsetY: handleY - vy,
335
+ }
336
+ }
337
+ e.preventDefault()
338
+ }
339
+
340
+ useEffect(() => {
341
+ const onMove = (e) => {
342
+ const d = dragMode.current
343
+ if (!d) return
344
+ const [vx, vy] = toView(e.clientX, e.clientY)
345
+ if (d.type === 'ring') {
346
+ const cursorAngle = (Math.atan2(vy - 50, vx - 50) * 180 / Math.PI + 360) % 360
347
+ onChangeHue(cursorAngle)
348
+ } else {
349
+ const tx = vx + d.offsetX
350
+ const ty = vy + d.offsetY
351
+ const [, , a, b] = clampToTriangle(tx, ty)
352
+ const value = a + b
353
+ const s = (a + b) > 0 ? (a / (a + b)) : 0
354
+ onChangeSV(s * 100, value * 100)
355
+ }
356
+ }
357
+ const onUp = () => { dragMode.current = null }
358
+ window.addEventListener('pointermove', onMove)
359
+ window.addEventListener('pointerup', onUp)
360
+ window.addEventListener('pointercancel', onUp)
361
+ return () => {
362
+ window.removeEventListener('pointermove', onMove)
363
+ window.removeEventListener('pointerup', onUp)
364
+ window.removeEventListener('pointercancel', onUp)
365
+ }
366
+ }, [toView, onChangeHue, onChangeSV, Vh, Vw, Vb])
367
+
368
+ const onKeyDown = (e) => {
369
+ if (e.key === 'ArrowRight') onChangeHue((hue + 1) % 360)
370
+ else if (e.key === 'ArrowLeft') onChangeHue((hue - 1 + 360) % 360)
371
+ else if (e.key === 'ArrowUp') onChangeSV(sat, Math.min(100, val + 1))
372
+ else if (e.key === 'ArrowDown') onChangeSV(sat, Math.max(0, val - 1))
373
+ else return
374
+ e.preventDefault()
375
+ }
376
+
377
+ /* Gradient endpoints: white-fade goes from V_white (opaque) toward the
378
+ * midpoint of the V_hue—V_black edge (transparent). Same idea for black. */
379
+ const midHB = [(Vh[0] + Vb[0]) / 2, (Vh[1] + Vb[1]) / 2]
380
+ const midHW = [(Vh[0] + Vw[0]) / 2, (Vh[1] + Vw[1]) / 2]
381
+
382
+ const trianglePoints = `${Vh[0]},${Vh[1]} ${Vw[0]},${Vw[1]} ${Vb[0]},${Vb[1]}`
383
+
384
+ return (
385
+ <div
386
+ role="slider"
387
+ tabIndex={0}
388
+ aria-label="Hue wheel"
389
+ aria-valuemin={0}
390
+ aria-valuemax={360}
391
+ aria-valuenow={Math.round(hue)}
392
+ aria-valuetext={`Hue ${Math.round(hue)} degrees, saturation ${Math.round(sat)}%, brightness ${Math.round(val)}%`}
393
+ onKeyDown={onKeyDown}
394
+ className="aspect-square max-h-full max-w-full"
395
+ >
396
+ <svg
397
+ ref={svgRef}
398
+ viewBox="0 0 100 100"
399
+ onPointerDown={onSvgDown}
400
+ preserveAspectRatio="xMidYMid meet"
401
+ className="block cursor-crosshair select-none touch-none w-full h-full"
402
+ >
403
+ <defs>
404
+ <clipPath id={ringClipId} clipPathUnits="userSpaceOnUse">
405
+ <path
406
+ d={`M 50 ${50 - RING_OUTER} a ${RING_OUTER} ${RING_OUTER} 0 1 0 0.001 0 Z
407
+ M 50 ${50 - RING_INNER} a ${RING_INNER} ${RING_INNER} 0 1 1 -0.001 0 Z`}
408
+ fillRule="evenodd"
409
+ />
410
+ </clipPath>
411
+ <linearGradient id={whiteFadeId} gradientUnits="userSpaceOnUse"
412
+ x1={midHB[0]} y1={midHB[1]} x2={Vw[0]} y2={Vw[1]}>
413
+ <stop offset="0" stopColor="#FFFFFF" stopOpacity="0" />
414
+ <stop offset="1" stopColor="#FFFFFF" stopOpacity="1" />
415
+ </linearGradient>
416
+ <linearGradient id={blackFadeId} gradientUnits="userSpaceOnUse"
417
+ x1={midHW[0]} y1={midHW[1]} x2={Vb[0]} y2={Vb[1]}>
418
+ <stop offset="0" stopColor="#000000" stopOpacity="0" />
419
+ <stop offset="1" stopColor="#000000" stopOpacity="1" />
420
+ </linearGradient>
421
+ </defs>
422
+
423
+ {/* Outer ring: foreignObject + CSS conic gradient, clipped to a torus
424
+ path so only the band is visible. */}
425
+ <foreignObject x="0" y="0" width="100" height="100" clipPath={`url(#${ringClipId})`}>
426
+ <div
427
+ xmlns="http://www.w3.org/1999/xhtml"
428
+ style={{
429
+ width: '100%', height: '100%',
430
+ background: `conic-gradient(from 0deg,
431
+ hsl(0,100%,50%), hsl(60,100%,50%), hsl(120,100%,50%),
432
+ hsl(180,100%,50%), hsl(240,100%,50%), hsl(300,100%,50%),
433
+ hsl(360,100%,50%))`,
434
+ }}
435
+ />
436
+ </foreignObject>
437
+
438
+ {/* Triangle layers — three stacked polygons mix to a true HSV triangle */}
439
+ <polygon points={trianglePoints} fill={`hsl(${hue}, 100%, 50%)`} />
440
+ <polygon points={trianglePoints} fill={`url(#${whiteFadeId})`} />
441
+ <polygon points={trianglePoints} fill={`url(#${blackFadeId})`} />
442
+
443
+ {/* Handles. Ring handle is larger than the SV handle to match the Ref. */}
444
+ <SvgHandle cx={ringHx} cy={ringHy} r={3.2} />
445
+ <SvgHandle cx={handleX} cy={handleY} r={2.0} />
446
+ </svg>
447
+ </div>
448
+ )
449
+ }
450
+
451
+ /* ────────── Internal helpers (not exported) ────────── */
452
+
453
+ /* DOM handle for HueStrip / SBSquare. Solid white-stroked circle with thin
454
+ * black halo (matching the macOS-port look). */
455
+ function Handle({ left, top }) {
456
+ return (
457
+ <span
458
+ className="absolute pointer-events-none rounded-full bg-fg"
459
+ style={{
460
+ left, top,
461
+ width: 14, height: 14,
462
+ transform: 'translate(-50%, -50%)',
463
+ boxShadow: '0 0 0 1px #000, 0 0 0 2px #505050',
464
+ }}
465
+ />
466
+ )
467
+ }
468
+
469
+ /* SVG handle for WheelTriangle. Two concentric circles to stay sharp at any
470
+ * zoom — outer black halo + white outline. */
471
+ function SvgHandle({ cx, cy, r = 2.4 }) {
472
+ return (
473
+ <g pointerEvents="none">
474
+ <circle cx={cx} cy={cy} r={r + 0.5} fill="none" stroke="#000" strokeOpacity="0.7" strokeWidth="0.5" />
475
+ <circle cx={cx} cy={cy} r={r} fill="none" stroke="#FFF" strokeWidth="1.4" />
476
+ </g>
477
+ )
478
+ }
479
+
480
+ /**
481
+ * SpectrumControls — the composed classic square picker: HueStrip stacked
482
+ * over a fill-height SBSquare, per the source color panel's Hue mode layout
483
+ * (SBSquare wrapped in `rounded-[2px] overflow-hidden`, see its comment).
484
+ * Fills its container — the consumer owns the outer size. For the ring
485
+ * picker, use WheelTriangle directly; the primitives are also exported for
486
+ * custom layouts.
487
+ *
488
+ * Controlled on a single HSV object; hex/RGB conversion stays at the call
489
+ * site (pairs with ColorSwatch + a hex Input there).
490
+ *
491
+ * @param {Object} value { hue: 0–360, sat: 0–100, val: 0–100 }
492
+ * @param {Function} onChange (next: { hue, sat, val }) => void
493
+ */
494
+ export default function SpectrumControls({ value, onChange }) {
495
+ const { hue, sat, val } = value
496
+ return (
497
+ <div className="flex flex-col gap-3 w-full h-full min-h-0">
498
+ <HueStrip hue={hue} onChange={(h) => onChange({ hue: h, sat, val })} />
499
+ <div className="flex-1 min-h-0 rounded-[2px] overflow-hidden">
500
+ <SBSquare hue={hue} sat={sat} val={val} onChange={(s, v) => onChange({ hue, sat: s, val: v })} />
501
+ </div>
502
+ </div>
503
+ )
504
+ }