@kolkrabbi/kol-component 0.7.0 → 0.9.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 (131) hide show
  1. package/README.md +8 -0
  2. package/package.json +2 -5
  3. package/src/atoms/RotaryDial.jsx +35 -21
  4. package/src/hooks/colorMath.js +214 -0
  5. package/src/hooks/useEyedropper.js +124 -0
  6. package/src/index.js +15 -18
  7. package/src/molecules/ButtonGroup.jsx +45 -0
  8. package/src/molecules/ColorInputRow.jsx +146 -126
  9. package/src/molecules/PaletteHarmonyWheel.jsx +192 -0
  10. package/src/molecules/Slider.jsx +29 -14
  11. package/src/molecules/SplitToolButton.jsx +133 -0
  12. package/src/organisms/ContentFilters.jsx +2 -2
  13. package/src/organisms/GalleryCarousel.jsx +2 -1
  14. package/src/organisms/LoaderOverlay.jsx +11 -14
  15. package/src/organisms/MediaTileGallery.jsx +57 -0
  16. package/src/organisms/MediaViewer.jsx +89 -64
  17. package/src/atoms/PriceDisplay.jsx +0 -34
  18. package/src/atoms/TextPressure.jsx +0 -331
  19. package/src/atoms/TypeSample.jsx +0 -50
  20. package/src/atoms/TypeSpecCard.jsx +0 -42
  21. package/src/chess/ChessPiece.jsx +0 -191
  22. package/src/chess/apparatus/AlternativeControlsMock.jsx +0 -460
  23. package/src/chess/apparatus/ChessAnalysisLayout.jsx +0 -24
  24. package/src/chess/apparatus/ChessBoard.jsx +0 -210
  25. package/src/chess/apparatus/ChessBoardFullscreen.jsx +0 -49
  26. package/src/chess/apparatus/ChessBoardWithControls.jsx +0 -41
  27. package/src/chess/apparatus/ChessBoardWithSidebar.jsx +0 -122
  28. package/src/chess/apparatus/ChessSidebar.jsx +0 -131
  29. package/src/chess/apparatus/GameArchiveTable.jsx +0 -493
  30. package/src/chess/apparatus/GameSelector.jsx +0 -47
  31. package/src/chess/apparatus/NotationPanel.jsx +0 -70
  32. package/src/chess/apparatus/PlaybackControls.jsx +0 -52
  33. package/src/chess/apparatus/VariationTree.jsx +0 -137
  34. package/src/chess/assets/chess-extra-set/set-1/bishop-1.svg +0 -6
  35. package/src/chess/assets/chess-extra-set/set-1/bishop.svg +0 -6
  36. package/src/chess/assets/chess-extra-set/set-1/king-1.svg +0 -9
  37. package/src/chess/assets/chess-extra-set/set-1/king.svg +0 -7
  38. package/src/chess/assets/chess-extra-set/set-1/knight-1.svg +0 -8
  39. package/src/chess/assets/chess-extra-set/set-1/knight.svg +0 -7
  40. package/src/chess/assets/chess-extra-set/set-1/pawn-1.svg +0 -3
  41. package/src/chess/assets/chess-extra-set/set-1/pawn.svg +0 -3
  42. package/src/chess/assets/chess-extra-set/set-1/queen-1.svg +0 -11
  43. package/src/chess/assets/chess-extra-set/set-1/queen.svg +0 -6
  44. package/src/chess/assets/chess-extra-set/set-1/rook-1.svg +0 -7
  45. package/src/chess/assets/chess-extra-set/set-1/rook.svg +0 -11
  46. package/src/chess/assets/chess-extra-set/set-2/bishop-1.svg +0 -4
  47. package/src/chess/assets/chess-extra-set/set-2/bishop.svg +0 -4
  48. package/src/chess/assets/chess-extra-set/set-2/king-1.svg +0 -4
  49. package/src/chess/assets/chess-extra-set/set-2/king.svg +0 -4
  50. package/src/chess/assets/chess-extra-set/set-2/knight-1.svg +0 -5
  51. package/src/chess/assets/chess-extra-set/set-2/knight.svg +0 -5
  52. package/src/chess/assets/chess-extra-set/set-2/pawn-1.svg +0 -4
  53. package/src/chess/assets/chess-extra-set/set-2/pawn.svg +0 -4
  54. package/src/chess/assets/chess-extra-set/set-2/queen-1.svg +0 -4
  55. package/src/chess/assets/chess-extra-set/set-2/queen.svg +0 -4
  56. package/src/chess/assets/chess-extra-set/set-2/rook-1.svg +0 -5
  57. package/src/chess/assets/chess-extra-set/set-2/rook.svg +0 -5
  58. package/src/chess/assets/chess-extra-set/set-3/bishop-1.svg +0 -10
  59. package/src/chess/assets/chess-extra-set/set-3/bishop.svg +0 -3
  60. package/src/chess/assets/chess-extra-set/set-3/king-1.svg +0 -12
  61. package/src/chess/assets/chess-extra-set/set-3/king.svg +0 -3
  62. package/src/chess/assets/chess-extra-set/set-3/knight-1.svg +0 -3
  63. package/src/chess/assets/chess-extra-set/set-3/knight.svg +0 -10
  64. package/src/chess/assets/chess-extra-set/set-3/pawn-1.svg +0 -10
  65. package/src/chess/assets/chess-extra-set/set-3/pawn.svg +0 -10
  66. package/src/chess/assets/chess-extra-set/set-3/queen-1.svg +0 -10
  67. package/src/chess/assets/chess-extra-set/set-3/queen.svg +0 -3
  68. package/src/chess/assets/chess-extra-set/set-3/rook-1.svg +0 -3
  69. package/src/chess/assets/chess-extra-set/set-3/rook.svg +0 -11
  70. package/src/chess/assets/chess-vector-set/ABoard.svg +0 -164
  71. package/src/chess/assets/chess-vector-set/BBiskup.svg +0 -12
  72. package/src/chess/assets/chess-vector-set/BKing.svg +0 -15
  73. package/src/chess/assets/chess-vector-set/BKnight.svg +0 -13
  74. package/src/chess/assets/chess-vector-set/BPawn.svg +0 -7
  75. package/src/chess/assets/chess-vector-set/BQueen.svg +0 -6
  76. package/src/chess/assets/chess-vector-set/BRook.svg +0 -12
  77. package/src/chess/assets/chess-vector-set/WBiskup.svg +0 -14
  78. package/src/chess/assets/chess-vector-set/WKing.svg +0 -13
  79. package/src/chess/assets/chess-vector-set/WKnight.svg +0 -10
  80. package/src/chess/assets/chess-vector-set/WPawn.svg +0 -8
  81. package/src/chess/assets/chess-vector-set/WQueen.svg +0 -17
  82. package/src/chess/assets/chess-vector-set/WRook.svg +0 -13
  83. package/src/chess/context/ChessControlsContext.jsx +0 -379
  84. package/src/chess/dashboards/ChessHero.jsx +0 -303
  85. package/src/chess/index.js +0 -32
  86. package/src/chess/utils/parsePgnTree.js +0 -97
  87. package/src/dashboards/cards/DashAlertCard.jsx +0 -44
  88. package/src/dashboards/cards/DashChartCard.jsx +0 -44
  89. package/src/dashboards/cards/DashFeaturedCard.jsx +0 -42
  90. package/src/dashboards/cards/DashListCard.jsx +0 -87
  91. package/src/dashboards/cards/DashMetricCard.jsx +0 -59
  92. package/src/dashboards/cards/DashSlotCard.jsx +0 -45
  93. package/src/dashboards/cards/DashStackedBarCard.jsx +0 -70
  94. package/src/dashboards/cards/DashTableCard.jsx +0 -31
  95. package/src/dashboards/cards/_shared/CardHeader.jsx +0 -15
  96. package/src/dashboards/cards/_shared/LegendRow.jsx +0 -25
  97. package/src/dashboards/charts/Candlestick.jsx +0 -79
  98. package/src/dashboards/charts/DonutChart.jsx +0 -87
  99. package/src/dashboards/charts/Heatmap.jsx +0 -79
  100. package/src/dashboards/charts/Histogram.jsx +0 -56
  101. package/src/dashboards/charts/LineChart.jsx +0 -157
  102. package/src/dashboards/charts/ScatterPlot.jsx +0 -93
  103. package/src/dashboards/charts/Sparkline.jsx +0 -63
  104. package/src/dashboards/index.js +0 -28
  105. package/src/dashboards/layout/DashboardGrid.jsx +0 -30
  106. package/src/dashboards/layout/GridCard.jsx +0 -44
  107. package/src/dashboards/shared/DashTooltip.jsx +0 -6
  108. package/src/dashboards/shared/formatMetric.js +0 -26
  109. package/src/dashboards/shared/useChartTooltip.js +0 -25
  110. package/src/dashboards/shared/useCountUp.js +0 -107
  111. package/src/molecules/ArticleCard.jsx +0 -178
  112. package/src/molecules/WorkListItem.jsx +0 -83
  113. package/src/molecules/foundry/SpecimenSectionHeader.jsx +0 -89
  114. package/src/organisms/ArticleHeader.jsx +0 -95
  115. package/src/organisms/ColorLoader.jsx +0 -155
  116. package/src/organisms/DiagonalMarqueeRiver.jsx +0 -138
  117. package/src/organisms/ParallaxShelf.jsx +0 -141
  118. package/src/organisms/PortableTextRenderer.jsx +0 -115
  119. package/src/organisms/ProductDetailLayout.jsx +0 -189
  120. package/src/organisms/ScrollDriftGallery.jsx +0 -214
  121. package/src/organisms/StackHero.jsx +0 -83
  122. package/src/organisms/WorkCard.jsx +0 -120
  123. package/src/organisms/WorkViewToggle.jsx +0 -170
  124. package/src/organisms/foundry/FontPreviewSection.jsx +0 -187
  125. package/src/organisms/foundry/FoundryCharacterSets.jsx +0 -113
  126. package/src/organisms/foundry/GlyphMetricsGrid.jsx +0 -335
  127. package/src/organisms/foundry/TypefaceHero.jsx +0 -107
  128. package/src/organisms/foundry/TypefaceStyleSection.jsx +0 -163
  129. package/src/organisms/foundry/VariableFontSection.jsx +0 -158
  130. package/src/organisms/foundry/glyphData.js +0 -30
  131. package/src/organisms/foundry/index.js +0 -21
@@ -2,153 +2,172 @@ import { useState } from 'react'
2
2
  import { Icon } from '@kolkrabbi/kol-icons'
3
3
  import ColorSwatch from './ColorSwatch'
4
4
  import Input from '../atoms/Input'
5
- import LabeledControl from '../atoms/LabeledControl'
6
5
  import { usePopover, PopoverPanel } from '../atoms/Popover'
7
6
 
8
- const HEX6 = /^[0-9A-F]{6}$/
9
-
10
- /** '#f0a' / 'F0A' / '#ff00aa' → 'FF00AA'; anything unparseable → null. */
11
- function normalizeDigits(raw) {
12
- if (typeof raw !== 'string') return null
13
- let d = raw.replace(/^#/, '').trim().toUpperCase()
14
- if (/^[0-9A-F]{3}$/.test(d)) d = d.replace(/./g, (c) => c + c)
15
- return HEX6.test(d) ? d : null
16
- }
17
-
18
7
  /**
19
8
  * ColorInputRow — swatch chip + `#` hex input row. The single merged form of
20
9
  * the brand editor's ColorField (layer color assignment: palette-ref popover
21
- * behind the swatch) and SwatchRow (palette-slot editing: trailing extras)
22
- * one core, optional trailing affordances instead of two overlapping
23
- * molecules.
10
+ * behind the swatch) and SwatchRow (palette-slot editing: lock toggle +
11
+ * token-name column) — one core with additive slots instead of two
12
+ * overlapping molecules. Supersedes both lobby briefs (lobby/ColorField.md,
13
+ * lobby/SwatchRow.md).
24
14
  *
25
- * The input keeps a local draft while typing and commits on blur/Enter:
26
- * digits are filtered to hex as typed, shorthand `F0A` expands to `FF00AA`,
27
- * and only a valid 6-digit hex fires `onChange('#RRGGBB')` — invalid drafts
28
- * revert to the current value. Picking a palette-ref swatch fires the ref's
29
- * (pre-resolved) hex and closes the popover.
15
+ * Modes (additive on the core swatch + hex input):
16
+ * refs — the swatch becomes a popover trigger over a 6-column grid
17
+ * of palette entries. Entries arrive PRE-RESOLVED
18
+ * ({ value, label, hex }) no `palette:` string convention,
19
+ * no resolver seam; the app keeps its own resolution logic.
20
+ * Picking a cell fires onChange(entry.value) and closes.
21
+ * lock — `onToggleLock` makes the swatch the lock toggle: a sibling
22
+ * absolute overlay span (NOT inside the swatch — its
23
+ * overflow-hidden would clip it) shows the lock/unlock glyph,
24
+ * pinned visible when locked, on hover otherwise. Ignored
25
+ * when `refs` is set — the swatch has one click affordance.
26
+ * tokenName — adds the resolved-token readout column and switches the
27
+ * row from a flex row to the fixed 4-column grid
28
+ * (24px / label / token / input).
30
29
  *
31
- * Composition only no color math, no store coupling. Palette entries come
32
- * in pre-resolved via `paletteRefs`; the app-side `resolveColor`/PALETTE_REFS
33
- * and `tokenNameFor` seams stay in the consumer. SwatchRow's `edited` prop
34
- * was dead in the source render and is dropped ([dead-prop] handled);
35
- * `transparentTone` is an explicit prop, not inferred from the label string.
30
+ * The hex input is normalized per SwatchRow: value renders as bare UPPER
31
+ * digits behind the `#` prefix, and every keystroke emits
32
+ * onChange('#' + digits.toUpperCase()) the `.toUpperCase()` is value
33
+ * normalization, not copy casing. SwatchRow's dead `edited` prop is dropped;
34
+ * `transparentTone` is an explicit prop, never inferred from the label.
35
+ * Composition only — no color math, no store coupling.
36
36
  *
37
- * @param {string|null} value current color, '#RRGGBB' (3-digit accepted, normalized); null → transparent "None" swatch
38
- * @param {Function} onChange (hex: string) => void — normalized '#RRGGBB' on commit or palette pick
39
- * @param {string} label optional label; when set the row wraps in LabeledControl
40
- * @param {Array} paletteRefs [{ id, label, value }] pre-resolved entries swatch becomes a popover trigger with a ref grid (omit for a plain preview chip)
41
- * @param {Function} onRemove () => void — renders a trailing remove (×) button
42
- * @param {ReactNode} trailing extra trailing affordances (lock toggle, token name, …)
43
- * @param {number} swatchSize chip size in px (default 24)
44
- * @param {string} transparentTone TransparentX stroke tone for the null-value swatch: 'warning' (default) | 'error' | 'info' | 'success'
45
- * @param {string} inputVariant Input chrome: 'filled' (default) | 'ghost' | 'outline'
46
- * @param {boolean} disabled dims the row, blocks pointer, aria-disabled
37
+ * Props:
38
+ * value — current color: '#RRGGBB', a refs entry's `value`, or
39
+ * null (renders the transparent "None" swatch)
40
+ * onChange — (next: string) => void '#'+UPPER hex on typing, or
41
+ * entry.value on a palette pick
42
+ * label — row label (kol-helper-12); also prefixes aria-labels
43
+ * hideLabel — suppress the visible label (aria keeps it)
44
+ * refs — [{ value, label, hex }] pre-resolved palette entries
45
+ * popover mode
46
+ * locked — lock overlay pinned visible, aria-pressed on the swatch
47
+ * onToggleLock — () => void — swatch click toggles the lock
48
+ * tokenName — resolved token readout (kol-helper-10) → grid mode
49
+ * disabled — opacity-30 + pointer-events-none + aria-disabled
50
+ * unused — muted slot: transparent swatch, label/token drop to
51
+ * text-meta/text-subtle, input dims
52
+ * transparentTone — TransparentX stroke tone for the null/unused swatch:
53
+ * 'warning' (default) | 'error' | 'info' | 'success'
47
54
  */
48
55
  export default function ColorInputRow({
49
56
  value,
50
57
  onChange,
51
58
  label,
52
- paletteRefs,
53
- onRemove,
54
- trailing,
55
- swatchSize = 24,
56
- transparentTone = 'warning',
57
- inputVariant = 'filled',
59
+ hideLabel = false,
60
+ refs,
61
+ locked = false,
62
+ onToggleLock,
63
+ tokenName,
58
64
  disabled = false,
65
+ unused = false,
66
+ transparentTone = 'warning',
59
67
  className = '',
60
68
  }) {
61
- const digits = normalizeDigits(value)
62
- const hasRefs = Array.isArray(paletteRefs) && paletteRefs.length > 0
69
+ const hasRefs = Array.isArray(refs) && refs.length > 0
70
+ const isLockToggle = !hasRefs && typeof onToggleLock === 'function'
71
+ const isGrid = tokenName != null
72
+ const labelVisible = label != null && !hideLabel
63
73
 
64
- /* Local typing draft, re-synced whenever the controlled value changes
65
- * (render-time reset no effect, no flicker). */
66
- const [draft, setDraft] = useState(digits ?? '')
67
- const [prevValue, setPrevValue] = useState(value)
68
- if (value !== prevValue) {
69
- setPrevValue(value)
70
- setDraft(normalizeDigits(value) ?? '')
71
- }
74
+ /* Display resolution: when the current value is a refs entry, the swatch and
75
+ * input show the entry's pre-resolved hex; otherwise value IS the hex. */
76
+ const activeRef = hasRefs ? refs.find((r) => r.value === value) : undefined
77
+ const displayHex = activeRef?.hex ?? value ?? null
78
+ const digits = (displayHex ?? '').replace(/^#/, '').toUpperCase()
79
+ const showTransparent = unused || displayHex == null
80
+ const subtitle = displayHex == null ? 'None' : (activeRef?.label ?? '#' + digits)
72
81
 
73
82
  const [open, setOpen] = useState(false)
74
83
  const popover = usePopover({ open, onOpenChange: setOpen, placement: 'bottom-start', offset: 4 })
75
84
 
76
- const commit = () => {
77
- const next = normalizeDigits(draft)
78
- if (next == null) {
79
- setDraft(digits ?? '') // invalid or empty → revert, never emit
80
- return
81
- }
82
- setDraft(next)
83
- if (next !== digits) onChange?.('#' + next)
84
- }
85
-
86
- const pick = (ref) => {
87
- onChange?.('#' + (normalizeDigits(ref.value) ?? ''))
88
- setOpen(false)
89
- }
90
-
91
- /* Swatch subtitle for the trigger's aria-label: the matching ref's label
92
- * when the value is a palette color, else the hex, else 'None'. */
93
- const refLabel = hasRefs
94
- ? paletteRefs.find((r) => normalizeDigits(r.value) === digits)?.label
95
- : undefined
96
- const subtitle = digits == null ? 'None' : (refLabel ?? '#' + digits)
97
-
98
- const swatch = (
85
+ const chip = (size) => (
99
86
  <ColorSwatch
100
- hex={digits ? '#' + digits : null}
101
- size={hasRefs ? 'stretch' : swatchSize}
102
- showTransparent={digits == null}
87
+ hex={showTransparent ? null : displayHex}
88
+ size={size}
89
+ showTransparent={showTransparent}
103
90
  transparentTone={transparentTone}
104
91
  hoverable={false}
105
92
  />
106
93
  )
107
94
 
108
- const row = (
109
- <div
110
- className={`flex items-center gap-2 ${disabled ? 'opacity-30 pointer-events-none' : ''} ${className}`}
111
- aria-disabled={disabled || undefined}
95
+ /* Swatch cell — popover trigger (refs), lock toggle (onToggleLock), or a
96
+ * plain preview chip. The lock overlay is a SIBLING of the swatch: inside
97
+ * it, ColorSwatch's overflow-hidden radius clip would cut the glyph off. */
98
+ const swatchCell = hasRefs ? (
99
+ <button
100
+ type="button"
101
+ ref={popover.refs.setReference}
102
+ {...popover.getReferenceProps()}
103
+ aria-label={`${label ?? 'Color'}: ${subtitle}`}
104
+ className="inline-flex items-center shrink-0"
112
105
  >
113
- {hasRefs ? (
114
- <button
115
- type="button"
116
- ref={popover.refs.setReference}
117
- {...popover.getReferenceProps()}
118
- aria-label={`${label ?? 'Color'}: ${subtitle}`}
119
- className="inline-flex items-center shrink-0"
120
- style={{ width: swatchSize, height: swatchSize }}
106
+ {chip(24)}
107
+ </button>
108
+ ) : isLockToggle ? (
109
+ <button
110
+ type="button"
111
+ onClick={onToggleLock}
112
+ aria-pressed={locked}
113
+ title={locked ? 'Unlock' : 'Lock'}
114
+ className="group relative inline-flex h-6 w-6 shrink-0"
115
+ >
116
+ {chip('stretch')}
117
+ <span
118
+ aria-hidden="true"
119
+ className={`absolute inset-0 inline-flex items-center justify-center rounded-[2px] transition-opacity bg-fg-absolute-48 text-white pointer-events-none ${
120
+ locked ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
121
+ }`}
122
+ >
123
+ <Icon name={locked ? 'lock' : 'unlock'} size={12} />
124
+ </span>
125
+ </button>
126
+ ) : (
127
+ chip(24)
128
+ )
129
+
130
+ const labelCls = `kol-helper-12 truncate ${unused ? 'text-meta' : 'text-emphasis'}`
131
+
132
+ const hexInput = (
133
+ <Input
134
+ variant="filled"
135
+ size="sm"
136
+ prefix="#"
137
+ chars={6}
138
+ maxLength={6}
139
+ uppercase
140
+ value={digits}
141
+ onChange={(e) => onChange?.('#' + e.target.value.replace(/^#/, '').toUpperCase())}
142
+ disabled={disabled}
143
+ aria-label={label ? `${label} hex` : 'Hex color'}
144
+ className={unused ? 'opacity-50' : ''}
145
+ />
146
+ )
147
+
148
+ const stateCls = `items-center gap-2 ${disabled ? 'opacity-30 pointer-events-none' : ''} ${className}`
149
+
150
+ return (
151
+ <>
152
+ {isGrid ? (
153
+ <div
154
+ className={`grid ${stateCls}`}
155
+ style={{ gridTemplateColumns: '24px 1fr 1fr 1fr' }}
156
+ aria-disabled={disabled || undefined}
121
157
  >
122
- {swatch}
123
- </button>
158
+ {swatchCell}
159
+ <span className={labelCls}>{labelVisible ? label : ''}</span>
160
+ <span className={`kol-helper-10 truncate ${unused ? 'text-subtle' : 'text-meta'}`}>
161
+ {tokenName}
162
+ </span>
163
+ {hexInput}
164
+ </div>
124
165
  ) : (
125
- swatch
126
- )}
127
- <Input
128
- variant={inputVariant}
129
- size="sm"
130
- prefix="#"
131
- chars={6}
132
- maxLength={6}
133
- uppercase
134
- value={draft}
135
- onChange={(e) => setDraft(e.target.value.replace(/[^0-9a-fA-F]/g, '').toUpperCase().slice(0, 6))}
136
- onBlur={commit}
137
- onKeyDown={(e) => { if (e.key === 'Enter') commit() }}
138
- disabled={disabled}
139
- aria-label={label ? `${label} hex` : 'Hex color'}
140
- />
141
- {trailing}
142
- {onRemove && (
143
- <button
144
- type="button"
145
- onClick={onRemove}
146
- aria-label="Remove"
147
- title="Remove"
148
- className="inline-flex items-center justify-center w-6 h-6 shrink-0 rounded text-meta hover:text-emphasis hover:bg-fg-08 transition-colors"
149
- >
150
- <Icon name="x" size={12} />
151
- </button>
166
+ <div className={`flex ${stateCls}`} aria-disabled={disabled || undefined}>
167
+ {swatchCell}
168
+ {labelVisible && <span className={`${labelCls} flex-1 min-w-0`}>{label}</span>}
169
+ {hexInput}
170
+ </div>
152
171
  )}
153
172
  {hasRefs && (
154
173
  <PopoverPanel
@@ -159,21 +178,22 @@ export default function ColorInputRow({
159
178
  style={{ minWidth: 200 }}
160
179
  >
161
180
  <div className="grid grid-cols-6 gap-1">
162
- {paletteRefs.map((ref) => (
181
+ {refs.map((entry) => (
163
182
  <ColorSwatch
164
- key={ref.id}
165
- hex={'#' + (normalizeDigits(ref.value) ?? '')}
183
+ key={entry.value}
184
+ hex={entry.hex}
166
185
  size="fill"
167
- selected={normalizeDigits(ref.value) === digits && digits != null}
168
- title={ref.label}
169
- onClick={() => pick(ref)}
186
+ selected={entry.value === value}
187
+ title={entry.label}
188
+ onClick={() => {
189
+ onChange?.(entry.value)
190
+ setOpen(false)
191
+ }}
170
192
  />
171
193
  ))}
172
194
  </div>
173
195
  </PopoverPanel>
174
196
  )}
175
- </div>
197
+ </>
176
198
  )
177
-
178
- return label ? <LabeledControl label={label}>{row}</LabeledControl> : row
179
199
  }
@@ -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
+ }
@@ -2,28 +2,35 @@ import { useEffect, useMemo, useState } from 'react'
2
2
  import Input from '../atoms/Input.jsx'
3
3
 
4
4
  /**
5
- * Slider — range slider with label and an editable value readout.
5
+ * Slider — range slider with label and an editable value readout. The LINEAR
6
+ * VARIANT of RotaryDial (atoms/RotaryDial.jsx): both implement the shared
7
+ * value-control contract — `value` / `min` / `max` / `step` /
8
+ * `onChange(next: number)` / `label` / `size` / `disabled` / `formatValue`.
9
+ * Controlled; onChange always fires with the plain number.
6
10
  *
7
11
  * One bare inline row: label · track · editable readout. (Bordered
8
12
  * `default` and chip `subtle` variants retired 2026-07-08 — minimal is
9
13
  * the only slider.)
10
14
  *
11
15
  * The value readout is an editable <Input> (type a value, commit on
12
- * blur / Enter, revert on Escape). Track color is exposed as the
13
- * `--kol-slider-track` CSS variable on `.slider-black`; override
14
- * per-instance via style={{ '--kol-slider-track': '...' }}.
16
+ * blur / Enter, revert on Escape) a Slider-only extra; RotaryDial's
17
+ * readout is display-only (atoms nest no KOL component). Track color is
18
+ * exposed as the `--kol-slider-track` CSS variable on `.slider-black`;
19
+ * override per-instance via style={{ '--kol-slider-track': '...' }}.
15
20
  *
16
21
  * @param {Object} props
17
22
  * @param {string} props.label - Slider label text
18
- * @param {number} props.min - Minimum value
19
- * @param {number} props.max - Maximum value
20
- * @param {number} props.value - Current value
21
- * @param {Function} props.onChange - Change handler
23
+ * @param {number} props.min - Minimum value (default: 0)
24
+ * @param {number} props.max - Maximum value (default: 100)
25
+ * @param {number} props.value - Controlled value within min–max
26
+ * @param {Function} props.onChange - (next: number) => void change handler
27
+ * @param {number} props.step - Slider step increment (default: 1)
28
+ * @param {number} props.size - Track length in px (mirrors RotaryDial's dial px size); unset = fluid flex-1 track
29
+ * @param {boolean} props.disabled - Disables track + readout and dims the control (default: false)
30
+ * @param {Function} props.formatValue - Optional formatter for displayed value; RotaryDial's default readout is `${value}%`
22
31
  * @param {string} props.className - Additional wrapper classes
23
32
  * @param {number} props.displayWidth - Width of the value readout, in characters (default: 6)
24
33
  * @param {string} props.fontSize - Font size for label/value (e.g., '11px')
25
- * @param {number} props.step - Slider step increment (default: 1)
26
- * @param {Function} props.formatValue - Optional formatter for displayed value
27
34
  */
28
35
  const Slider = ({
29
36
  label,
@@ -31,11 +38,13 @@ const Slider = ({
31
38
  max = 100,
32
39
  value = 0,
33
40
  onChange,
41
+ step = 1,
42
+ size,
43
+ disabled = false,
44
+ formatValue,
34
45
  className = '',
35
46
  displayWidth = 6,
36
47
  fontSize,
37
- step = 1,
38
- formatValue
39
48
  }) => {
40
49
  const handleChange = (e) => {
41
50
  if (onChange) {
@@ -86,7 +95,10 @@ const Slider = ({
86
95
  return (
87
96
  <div className={`control-slider gap-3 shadow-none ${className}`}>
88
97
  {label && (
89
- <label className="kol-helper-12 whitespace-nowrap shrink-0 w-fit" style={fontSize ? { fontSize } : undefined}>
98
+ <label
99
+ className={`kol-helper-12 whitespace-nowrap shrink-0 w-fit ${disabled ? 'opacity-50' : ''}`}
100
+ style={fontSize ? { fontSize } : undefined}
101
+ >
90
102
  {label}
91
103
  </label>
92
104
  )}
@@ -97,7 +109,9 @@ const Slider = ({
97
109
  step={step}
98
110
  value={value}
99
111
  onChange={handleChange}
100
- className="slider-black flex-1 w-full cursor-pointer"
112
+ disabled={disabled}
113
+ className={`slider-black cursor-pointer disabled:cursor-default disabled:opacity-50 ${size ? 'flex-none' : 'flex-1 w-full'}`}
114
+ style={size ? { width: size } : undefined}
101
115
  />
102
116
  <Input
103
117
  type="text"
@@ -106,6 +120,7 @@ const Slider = ({
106
120
  size="sm"
107
121
  chars={displayWidth}
108
122
  value={draft}
123
+ disabled={disabled}
109
124
  onFocus={(e) => { setEditing(true); e.target.select() }}
110
125
  onChange={(e) => setDraft(e.target.value)}
111
126
  onBlur={commit}