@kolkrabbi/kol-component 0.6.0 → 0.8.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 (38) hide show
  1. package/README.md +8 -0
  2. package/package.json +2 -3
  3. package/src/atoms/RotaryDial.jsx +35 -21
  4. package/src/index.js +6 -18
  5. package/src/molecules/ButtonGroup.jsx +45 -0
  6. package/src/molecules/ColorInputRow.jsx +146 -126
  7. package/src/molecules/Slider.jsx +29 -14
  8. package/src/molecules/SplitToolButton.jsx +133 -0
  9. package/src/organisms/ContentFilters.jsx +2 -2
  10. package/src/organisms/GalleryCarousel.jsx +2 -1
  11. package/src/organisms/LoaderOverlay.jsx +11 -14
  12. package/src/organisms/MediaTileGallery.jsx +57 -0
  13. package/src/organisms/MediaViewer.jsx +89 -64
  14. package/src/atoms/PriceDisplay.jsx +0 -34
  15. package/src/atoms/TextPressure.jsx +0 -331
  16. package/src/atoms/TypeSample.jsx +0 -50
  17. package/src/atoms/TypeSpecCard.jsx +0 -42
  18. package/src/molecules/ArticleCard.jsx +0 -178
  19. package/src/molecules/WorkListItem.jsx +0 -83
  20. package/src/molecules/foundry/SpecimenSectionHeader.jsx +0 -89
  21. package/src/organisms/ArticleHeader.jsx +0 -95
  22. package/src/organisms/ColorLoader.jsx +0 -155
  23. package/src/organisms/DiagonalMarqueeRiver.jsx +0 -138
  24. package/src/organisms/ParallaxShelf.jsx +0 -141
  25. package/src/organisms/PortableTextRenderer.jsx +0 -115
  26. package/src/organisms/ProductDetailLayout.jsx +0 -189
  27. package/src/organisms/ScrollDriftGallery.jsx +0 -214
  28. package/src/organisms/StackHero.jsx +0 -83
  29. package/src/organisms/WorkCard.jsx +0 -120
  30. package/src/organisms/WorkViewToggle.jsx +0 -170
  31. package/src/organisms/foundry/FontPreviewSection.jsx +0 -187
  32. package/src/organisms/foundry/FoundryCharacterSets.jsx +0 -113
  33. package/src/organisms/foundry/GlyphMetricsGrid.jsx +0 -335
  34. package/src/organisms/foundry/TypefaceHero.jsx +0 -107
  35. package/src/organisms/foundry/TypefaceStyleSection.jsx +0 -163
  36. package/src/organisms/foundry/VariableFontSection.jsx +0 -158
  37. package/src/organisms/foundry/glyphData.js +0 -30
  38. package/src/organisms/foundry/index.js +0 -21
package/README.md CHANGED
@@ -11,6 +11,14 @@ npm i @kolkrabbi/kol-component @kolkrabbi/kol-theme
11
11
 
12
12
  Requires a **Vite + Tailwind v4** app and the theme CSS imported (see the theme package).
13
13
 
14
+ ## Tailwind v4 consumers
15
+
16
+ Tailwind skips `node_modules` when scanning, so point it at this package's source — otherwise the utility classes inside the components never generate and layouts collapse:
17
+
18
+ ```css
19
+ @source "../node_modules/@kolkrabbi/kol-component/src";
20
+ ```
21
+
14
22
  ## Use
15
23
 
16
24
  ```jsx
package/package.json CHANGED
@@ -1,14 +1,13 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-component",
3
- "version": "0.6.0",
3
+ "version": "0.8.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",
7
7
  "main": "./src/index.js",
8
8
  "module": "./src/index.js",
9
9
  "exports": {
10
- ".": "./src/index.js",
11
- "./foundry": "./src/organisms/foundry/index.js"
10
+ ".": "./src/index.js"
12
11
  },
13
12
  "files": [
14
13
  "src",
@@ -7,22 +7,29 @@ const snapTo = (v, min, max, step) => {
7
7
  }
8
8
 
9
9
  /**
10
- * RotaryDial — drag-to-set rotary knob numeric input. Vertical pointer drag
11
- * rotates the whole dial over a 270° sweep (−135° to +135°) mapped onto
12
- * min–max; a full sweep is ~200px of drag, movement measured absolute from
13
- * drag start. Controlled (`value`/`onChange`) with a local visual buffer so
14
- * rotation stays smooth while parent updates are RAF-throttled; the final
15
- * value fires on release. Focus + arrow keys step the value
16
- * (role="slider" with aria-valuemin/max/now). The label and `%` readout
17
- * rows render only when `label` is set; label text renders as authored.
10
+ * RotaryDial — drag-to-set rotary knob numeric input. The KNOB VARIANT of
11
+ * Slider (molecules/Slider.jsx): both implement the shared value-control
12
+ * contract `value` / `min` / `max` / `step` / `onChange(next: number)` /
13
+ * `label` / `size` / `disabled` / `formatValue`. Controlled; onChange always
14
+ * fires with the plain number.
18
15
  *
19
- * @param {string} label text under the dial; also gates the label + readout rows
20
- * @param {number} value controlled value within min–max
21
- * @param {Function} onChange (next: number) => void RAF-throttled while dragging, final on release
22
- * @param {number} min minimum value (default 0)
23
- * @param {number} max maximum value (default 100)
24
- * @param {number} step drag snap / arrow-key increment (default 1)
25
- * @param {number} size dial px size (default 80); derives ring + disc radii
16
+ * Vertical pointer drag rotates the whole dial over a 270° sweep (−135° to
17
+ * +135°) mapped onto min–max; a full sweep is ~200px of drag, movement
18
+ * measured absolute from drag start. A local visual buffer keeps rotation
19
+ * smooth while parent updates are RAF-throttled; the final value fires on
20
+ * release. Focus + arrow keys step the value (role="slider" with
21
+ * aria-valuemin/max/now). The label and readout rows render only when
22
+ * `label` is set; label text renders as authored.
23
+ *
24
+ * @param {string} label text under the dial; also gates the label + readout rows
25
+ * @param {number} value controlled value within min–max
26
+ * @param {Function} onChange (next: number) => void — RAF-throttled while dragging, final on release
27
+ * @param {number} min minimum value (default 0)
28
+ * @param {number} max maximum value (default 100)
29
+ * @param {number} step drag snap / arrow-key increment (default 1)
30
+ * @param {number} size dial px size (default 80); derives ring + disc radii
31
+ * @param {boolean} disabled blocks drag + keyboard and dims the control (default false)
32
+ * @param {Function} formatValue optional readout formatter (value: number) => string; default `${value}%`
26
33
  */
27
34
  export default function RotaryDial({
28
35
  label,
@@ -32,6 +39,8 @@ export default function RotaryDial({
32
39
  max = 100,
33
40
  step = 1,
34
41
  size = 80,
42
+ disabled = false,
43
+ formatValue,
35
44
  }) {
36
45
  const [isDragging, setIsDragging] = useState(false)
37
46
  const [localValue, setLocalValue] = useState(value) // visual buffer — updates every move
@@ -99,23 +108,24 @@ export default function RotaryDial({
99
108
  const angle = -135 + ((localValue - min) / (max - min || 1)) * 270
100
109
 
101
110
  return (
102
- <div className="flex flex-col items-center gap-2">
111
+ <div className={`flex flex-col items-center gap-2 ${disabled ? 'opacity-50' : ''}`}>
103
112
  <div
104
113
  role="slider"
105
- tabIndex={0}
114
+ tabIndex={disabled ? -1 : 0}
106
115
  aria-label={label}
107
116
  aria-valuemin={min}
108
117
  aria-valuemax={max}
109
118
  aria-valuenow={localValue}
110
- className="relative cursor-pointer select-none touch-none"
119
+ aria-disabled={disabled || undefined}
120
+ className={`relative select-none touch-none ${disabled ? 'cursor-default' : 'cursor-pointer'}`}
111
121
  style={{
112
122
  width: size,
113
123
  height: size,
114
124
  transform: `rotate(${angle}deg)`,
115
125
  willChange: isDragging ? 'transform' : 'auto',
116
126
  }}
117
- onPointerDown={handlePointerDown}
118
- onKeyDown={handleKeyDown}
127
+ onPointerDown={disabled ? undefined : handlePointerDown}
128
+ onKeyDown={disabled ? undefined : handleKeyDown}
119
129
  >
120
130
  <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} style={{ overflow: 'visible' }}>
121
131
  {/* outer dashed tick ring */}
@@ -144,7 +154,11 @@ export default function RotaryDial({
144
154
  </svg>
145
155
  </div>
146
156
  {label && <div className="kol-helper-12 text-fg-64">{label}</div>}
147
- {label && <div className="kol-helper-12 text-fg-96">{value}%</div>}
157
+ {label && (
158
+ <div className="kol-helper-12 text-fg-96">
159
+ {formatValue ? String(formatValue(value)) : `${value}%`}
160
+ </div>
161
+ )}
148
162
  </div>
149
163
  )
150
164
  }
package/src/index.js CHANGED
@@ -38,7 +38,6 @@ export { default as LabeledControl } from './atoms/LabeledControl.jsx'
38
38
  export { default as OverlayGlassPanel } from './atoms/OverlayGlassPanel.jsx'
39
39
  export { default as Pill } from './atoms/Pill.jsx'
40
40
  export { usePopover, PopoverPanel, Tooltip } from './atoms/Popover.jsx'
41
- export { default as PriceDisplay } from './atoms/PriceDisplay.jsx'
42
41
  export { default as ProsePreview } from './atoms/ProsePreview.jsx'
43
42
  export { default as QuantityInput } from './atoms/QuantityInput.jsx'
44
43
  export { default as RotaryDial } from './atoms/RotaryDial.jsx'
@@ -52,22 +51,18 @@ export { default as Textarea } from './atoms/Textarea.jsx'
52
51
  export { default as ToggleBracket } from './atoms/ToggleBracket.jsx'
53
52
  export { default as ToggleCheckbox } from './atoms/ToggleCheckbox.jsx'
54
53
  export { default as ToggleSwitch } from './atoms/ToggleSwitch.jsx'
55
- export { default as TextPressure } from './atoms/TextPressure.jsx'
56
54
  export { default as TiltCard } from './atoms/TiltCard.jsx'
57
55
  export { default as TransparentX } from './atoms/TransparentX.jsx'
58
- export { default as TypeSample } from './atoms/TypeSample.jsx'
59
- export { default as TypeSpecCard } from './atoms/TypeSpecCard.jsx'
60
56
  export { default as ViewToggle } from './atoms/ViewToggle.jsx'
61
57
 
62
58
  // molecules
63
59
  export { Accordion, AccordionPanel } from './molecules/Accordion.jsx'
60
+ export { default as ButtonGroup } from './molecules/ButtonGroup.jsx'
64
61
  /* monorepo sets (P6–P10) — molecule members */
65
62
  export { default as AlignmentGrid } from './molecules/AlignmentGrid.jsx'
66
- export { default as ArticleCard } from './molecules/ArticleCard.jsx'
67
63
  export { default as ImageBlock } from './molecules/ImageBlock.jsx'
68
64
  export { default as SelectionOverlay } from './molecules/SelectionOverlay.jsx'
69
65
  export { default as VideoBlock, getEmbedUrl } from './molecules/VideoBlock.jsx'
70
- export { default as WorkListItem } from './molecules/WorkListItem.jsx'
71
66
  export { default as CardFeatureItem } from './molecules/CardFeatureItem.jsx'
72
67
  export { default as CodeBlock } from './molecules/CodeBlock.jsx'
73
68
  export { default as ColorInputRow } from './molecules/ColorInputRow.jsx'
@@ -88,28 +83,20 @@ export { default as ShellSearchOverlay } from './molecules/ShellSearchOverlay.js
88
83
  export { default as Slider } from './molecules/Slider.jsx'
89
84
  export { default as SpecList } from './molecules/SpecList.jsx'
90
85
  export { default as SpectrumControls, HueStrip, SBSquare, WheelTriangle } from './molecules/SpectrumControls.jsx'
86
+ export { default as SplitToolButton } from './molecules/SplitToolButton.jsx'
91
87
  export { default as SwatchControls, SwatchStack, EyedropPick } from './molecules/SwatchControls.jsx'
92
88
  export { default as TabsRow } from './molecules/TabsRow.jsx'
93
89
 
94
90
  // organisms
95
- /* monorepo sets (P6–P10) — organism members. Foundry members are isolated
96
- under the './foundry' subpath (opentype-heavy) not re-exported here. */
97
- export { default as ArticleHeader } from './organisms/ArticleHeader.jsx'
91
+ /* monorepo sets (P6–P10) — organism members. Foundry members live in the
92
+ standalone @kolkrabbi/kol-foundry package (with the type-specimen kit +
93
+ live-font effects moved there 2026-07-09) never re-exported here. */
98
94
  export { default as Canvas, CanvasFrame, PanViewport, CANVAS_VIRTUAL_W, DEFAULT_ASPECTS, CANVAS_DEFAULTS } from './organisms/Canvas.jsx'
99
- export { default as DiagonalMarqueeRiver } from './organisms/DiagonalMarqueeRiver.jsx'
100
95
  export { default as EditorShell } from './organisms/EditorShell.jsx'
101
96
  export { default as GalleryCarousel } from './organisms/GalleryCarousel.jsx'
102
- export { default as ParallaxShelf } from './organisms/ParallaxShelf.jsx'
103
- export { default as PortableTextRenderer, slugify } from './organisms/PortableTextRenderer.jsx'
104
- export { default as ProductDetailLayout } from './organisms/ProductDetailLayout.jsx'
105
- export { default as ScrollDriftGallery } from './organisms/ScrollDriftGallery.jsx'
106
- export { default as StackHero } from './organisms/StackHero.jsx'
107
- export { default as WorkCard } from './organisms/WorkCard.jsx'
108
- export { default as WorkViewToggle } from './organisms/WorkViewToggle.jsx'
109
97
  export { default as AsciiCursor } from './organisms/AsciiCursor.jsx'
110
98
  export { default as BentoCard } from './organisms/BentoCard.jsx'
111
99
  export { default as Carousel } from './organisms/Carousel.jsx'
112
- export { default as ColorLoader } from './organisms/ColorLoader.jsx'
113
100
  export { default as ContentFilters } from './organisms/ContentFilters.jsx'
114
101
  export { default as CtaGlobal } from './organisms/CtaGlobal.jsx'
115
102
  export { default as ErrorBoundary } from './organisms/ErrorBoundary.jsx'
@@ -118,6 +105,7 @@ export { default as FeaturedCarousel } from './organisms/FeaturedCarousel.jsx'
118
105
  export { default as FeaturesCardSection } from './organisms/FeaturesCardSection.jsx'
119
106
  export { default as FullBleedHero } from './organisms/FullBleedHero.jsx'
120
107
  export { default as LoaderOverlay } from './organisms/LoaderOverlay.jsx'
108
+ export { default as MediaTileGallery } from './organisms/MediaTileGallery.jsx'
121
109
  export { default as MediaViewer } from './organisms/MediaViewer.jsx'
122
110
  export { default as NewsletterBand } from './organisms/NewsletterBand.jsx'
123
111
  export { default as SpectrumGrid } from './organisms/SpectrumGrid.jsx'
@@ -0,0 +1,45 @@
1
+ /* taxonomy-ok: composes consumer-passed Buttons via children — nesting happens at the call site */
2
+
3
+ /**
4
+ * ButtonGroup — responsive layout wrapper for a group of Buttons: stacked
5
+ * full-width on mobile, horizontal row from `sm` up, aligned left / center /
6
+ * right. Pure layout — children compose at the call site and render as-is
7
+ * (nothing is cloned, no props injected); all button behavior lives on each
8
+ * Button. `center` (default) hugs its content and self-centers; `left` /
9
+ * `right` stay full-width block. Alignment classes come from static lookup
10
+ * maps of complete literals — never interpolated — so Tailwind's scanner
11
+ * sees them.
12
+ *
13
+ * @param {'left'|'center'|'right'} align horizontal alignment + container layout mode (default 'center')
14
+ * @param {string} title optional heading rendered above the group, authored at the call site
15
+ * @param {string} className extra container classes (appended)
16
+ * @param {ReactNode} children the Buttons
17
+ */
18
+
19
+ const CONTAINER = {
20
+ left: 'flex flex-col w-full',
21
+ center: 'flex sm:inline-flex flex-col w-full sm:w-auto sm:mx-auto',
22
+ right: 'flex flex-col w-full',
23
+ }
24
+
25
+ const JUSTIFY = {
26
+ left: 'sm:justify-start',
27
+ center: 'sm:justify-center',
28
+ right: 'sm:justify-end',
29
+ }
30
+
31
+ export default function ButtonGroup({ align = 'center', title, className = '', children }) {
32
+ return (
33
+ <div className={[CONTAINER[align] || CONTAINER.center, className].filter(Boolean).join(' ')}>
34
+ {title && <h3 className="kol-sans-heading-05 text-emphasis mb-6">{title}</h3>}
35
+ <div
36
+ className={[
37
+ 'flex flex-col gap-4 sm:flex-row sm:items-center',
38
+ JUSTIFY[align] || JUSTIFY.center,
39
+ ].join(' ')}
40
+ >
41
+ {children}
42
+ </div>
43
+ </div>
44
+ )
45
+ }
@@ -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
  }