@kolkrabbi/kol-component 0.236.0 → 0.237.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 (51) hide show
  1. package/package.json +4 -4
  2. package/src/atoms/Button.jsx +10 -2
  3. package/src/atoms/Kbd.jsx +6 -3
  4. package/src/index.js +15 -11
  5. package/src/molecules/ColorInputRow.jsx +1 -1
  6. package/src/molecules/ColorRamp.jsx +1 -1
  7. package/src/molecules/CopyButton.jsx +1 -1
  8. package/src/molecules/Dropdown.jsx +5 -2
  9. package/src/molecules/KindPreview.jsx +6 -6
  10. package/src/{atoms/RotaryDial.jsx → molecules/Knob.jsx} +20 -2
  11. package/src/molecules/KnobPanel.jsx +116 -0
  12. package/src/molecules/LabeledControl.jsx +22 -0
  13. package/src/molecules/MediaPlayer.jsx +107 -0
  14. package/src/molecules/Modal.jsx +1 -1
  15. package/src/molecules/MultiSelect.jsx +70 -0
  16. package/src/molecules/OptionRow.jsx +1 -1
  17. package/src/molecules/PlayTile.jsx +70 -0
  18. package/src/molecules/PlaybackBar.jsx +7 -16
  19. package/src/molecules/RowMenuButton.jsx +1 -1
  20. package/src/molecules/ShapeDropdown.jsx +3 -3
  21. package/src/molecules/Slider.jsx +93 -56
  22. package/src/molecules/SliderPanel.jsx +88 -0
  23. package/src/molecules/SplitToolButton.jsx +1 -1
  24. package/src/molecules/SwatchControls.jsx +1 -1
  25. package/src/molecules/TiltBento.jsx +5 -5
  26. package/src/organisms/ColumnBrowser.jsx +1 -1
  27. package/src/organisms/ContentFilters.jsx +1 -1
  28. package/src/organisms/CurveEditor.jsx +2 -2
  29. package/src/organisms/FeaturedCarousel.jsx +4 -4
  30. package/src/organisms/KeyframeEditor.jsx +3 -3
  31. package/src/organisms/LayerStack.jsx +2 -2
  32. package/src/organisms/MediaLibrary.jsx +1 -1
  33. package/src/organisms/MediaLibraryPages.jsx +20 -20
  34. package/src/organisms/RecordManager.jsx +1 -1
  35. package/src/organisms/SectionHero.jsx +3 -3
  36. package/src/organisms/SectionNewsletter.jsx +2 -1
  37. package/src/organisms/ShellSearchOverlay.jsx +8 -8
  38. package/src/organisms/SpectrumGrid.jsx +1 -1
  39. package/src/organisms/ToolPalette.jsx +1 -1
  40. package/src/{atoms/HlsVideo.jsx → utilities/BackgroundVideo.jsx} +6 -2
  41. package/src/utilities/ErrorBoundary.jsx +1 -1
  42. package/src/utilities/QuadrantSync.jsx +4 -1
  43. package/src/utilities/armLongPress.js +18 -0
  44. package/src/utilities/mediaSearch.js +2 -2
  45. package/src/utilities/tone.js +6 -0
  46. package/src/atoms/AudioPlayer.jsx +0 -51
  47. package/src/molecules/AudioPreview.jsx +0 -140
  48. package/src/molecules/AudioSheet.jsx +0 -48
  49. package/src/molecules/VideoSheet.jsx +0 -25
  50. /package/src/{atoms → molecules}/ActionButton.jsx +0 -0
  51. /package/src/{atoms → molecules}/ColorSwatch.jsx +0 -0
@@ -0,0 +1,70 @@
1
+ import { useState } from 'react'
2
+ import { Icon } from '@kolkrabbi/kol-icons'
3
+ import { MenuDropdownItem } from './MenuItem.jsx'
4
+ import { PopoverPanel, usePopover } from '../utilities/Popover.jsx'
5
+ import { indicatorSize } from '../hooks/glyphLadders.js'
6
+
7
+ /**
8
+ * MultiSelect — Several settings in one dropdown. closed it is the Dropdown's trigger, reading the
9
+ * current value of every setting; open it is a popover with one column per setting, each under
10
+ * its name, and it stays open while you pick across columns.
11
+ *
12
+ * WHY IT EXISTS (user, 2026-10-01, on the preview card's knob bar — variant, tone and size as
13
+ * three controls in a row: *"or make a new component with multi select dropdown toggle, like an
14
+ * 'overlay multi settings' component"*, then on the first mock: *"it should popover … I want this
15
+ * component available for further development"*). First cut — the shape is open.
16
+ *
17
+ * Each setting is pick-one; the component holds several of them. For many-of-N in one list use
18
+ * `SettingsMulti`; for one setting use `Dropdown`.
19
+ *
20
+ * @param {Array} groups `[{ id, label, options: [{ value, label }] }]` — one column each
21
+ * @param {Object} value `{ [group.id]: value }` (controlled)
22
+ * @param {Function} onChange `(groupId, value) => void`
23
+ * @param {ReactNode} [label] the trigger's text; default is every current value, joined
24
+ * @param {'xs'|'sm'|'md'|'lg'} [size='sm']
25
+ * @param {string} [className]
26
+ */
27
+ const SIZE_TYPE = { xs: 'kol-mono-8', sm: 'kol-mono-12', md: 'kol-mono-14', lg: 'kol-mono-16' }
28
+
29
+ export default function MultiSelect({ groups = [], value = {}, onChange, label, size = 'sm', defaultOpen = false, className = '' }) {
30
+ const [open, setOpen] = useState(defaultOpen)
31
+ const popover = usePopover({ open, onOpenChange: setOpen, placement: 'bottom-start', offset: 4, role: 'dialog' })
32
+ const labelOf = (g) => g.options.find((o) => o.value === value[g.id])?.label ?? value[g.id]
33
+ return (
34
+ <div className={`relative inline-block align-middle ${className}`.trim()}>
35
+ <button
36
+ ref={popover.refs.setReference}
37
+ {...popover.getReferenceProps()}
38
+ type="button"
39
+ className={`kol-btn kol-btn-${size} ${SIZE_TYPE[size] ?? SIZE_TYPE.sm} kol-dd-trigger`}
40
+ aria-haspopup="dialog"
41
+ aria-expanded={open}
42
+ data-state={open ? 'open' : 'closed'}
43
+ >
44
+ <span className="kol-dd-label"><span>{label ?? groups.map(labelOf).filter(Boolean).join(' · ')}</span></span>
45
+ <Icon name="chevron-down" size={indicatorSize(size)} className="kol-dd-caret" />
46
+ </button>
47
+ <PopoverPanel popover={popover} className="w-max p-1">
48
+ <div className="flex">
49
+ {groups.map((g) => (
50
+ <div key={g.id} role="listbox" aria-label={g.label} className="flex min-w-32 flex-col">
51
+ {/* the columns touch, so the headings' rules read as one line */}
52
+ <div className="kol-helper-12 mb-1 flex h-8 shrink-0 items-center border-b border-oq-08 px-3 text-meta">{g.label}</div>
53
+ {g.options.map((o) => (
54
+ <MenuDropdownItem
55
+ key={o.value}
56
+ hover={false}
57
+ size={size}
58
+ onClick={() => onChange?.(g.id, o.value)}
59
+ shortcut={o.value === value[g.id] ? <Icon name="check" size={11} /> : null}
60
+ >
61
+ {o.label ?? o.value}
62
+ </MenuDropdownItem>
63
+ ))}
64
+ </div>
65
+ ))}
66
+ </div>
67
+ </PopoverPanel>
68
+ </div>
69
+ )
70
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * OptionRow — One row in a list you move through. one row in a list you move through: the palette's results, a browser's rows, a
2
+ * OptionRow — One row in a list you move through. one row in a list you move through: the search modal's results, a browser's rows, a
3
3
  * dropdown's options. Lifted out of ShellSearchOverlay (2026-09-30), where it was hand-rolled next to
4
4
  * DropdownTagFilter's, FieldRow's and ColumnBrowser's own — four rows for one job.
5
5
  *
@@ -0,0 +1,70 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import IconFrame from '../atoms/IconFrame.jsx';
3
+ import { readCover } from '../utilities/id3.js';
4
+
5
+ /* taxonomy-ok: molecule — nests IconFrame (atom). */
6
+
7
+ /**
8
+ * AudioTile · VideoTile — kol-r2b2's tiles, promoted verbatim (ColumnBrowserMediaFacts
9
+ * 2026-08-27; re-ruled the same day, PlayDiscAndVideoBar). Finder model (user ruling
10
+ * 2026-08-27): the COLUMN gets a square tile over full-bleed artwork with ONE control —
11
+ * the Finder play/pause disc (`.kol-play-disc`: round, hidden at rest, shown on hover of
12
+ * `.kol-media-tile`) — `AudioTile` (artwork = the file's embedded ID3 cover, `readCover`) /
13
+ * `VideoTile` (artwork = the poster; the media element is hidden — the tile never shows a
14
+ * decoded video frame). Timeline + volume belong to the player, `MediaPlayer`.
15
+ *
16
+ * They lived in `AudioPreview.jsx` beside a player of the same name until 2026-10-02, when
17
+ * that player became `MediaPlayer`; this file is what was left.
18
+ *
19
+ * @param {string} src the media URL
20
+ * @param {string} poster VideoTile — the poster URL
21
+ * @param {ReactNode} fallback AudioTile — shown when the file carries no cover art (2026-09-23:
22
+ * it was an empty square, so a WAV read as a missing file)
23
+ */
24
+ export const formatLength = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`;
25
+
26
+ /* the file's embedded cover, or null — shared with MediaPlayer's audio window */
27
+ export function useCover(src) {
28
+ const [cover, setCover] = useState(null);
29
+ useEffect(() => {
30
+ if (!src) return undefined;
31
+ let live = true;
32
+ readCover(src).then((url) => { if (live) setCover(url); }).catch(() => {});
33
+ return () => { live = false; };
34
+ }, [src]);
35
+ return cover;
36
+ }
37
+
38
+ /* One disc for both tiles — the ruled Finder play/pause. The theme's
39
+ * `.kol-play-disc` paints it after the IconFrame variants, so nothing is restated here. */
40
+ function PlayDisc({ playing, onClick }) {
41
+ return (
42
+ <IconFrame name={playing ? 'pause' : 'play'} variant="secondary" size="lg" radius="full" onClick={onClick} aria-label={playing ? 'Pause' : 'Play'} className="relative kol-play-disc" />
43
+ );
44
+ }
45
+
46
+ export function AudioTile({ src, fallback = null }) {
47
+ const ref = useRef(null);
48
+ const [playing, setPlaying] = useState(false);
49
+ const cover = useCover(src);
50
+ return (
51
+ <div className="kol-media-tile relative w-full aspect-square rounded overflow-hidden flex items-center justify-center">
52
+ <audio ref={ref} src={src} preload="metadata" onPlay={() => setPlaying(true)} onPause={() => setPlaying(false)} onEnded={() => setPlaying(false)} />
53
+ {cover ? <img src={cover} alt="" className="absolute inset-0 w-full h-full object-cover" /> : fallback && <div className="absolute inset-0 flex items-center justify-center">{fallback}</div>}
54
+ <PlayDisc playing={playing} onClick={() => (playing ? ref.current.pause() : ref.current.play())} />
55
+ </div>
56
+ );
57
+ }
58
+
59
+ export function VideoTile({ src, poster }) {
60
+ const ref = useRef(null);
61
+ const [playing, setPlaying] = useState(false);
62
+ return (
63
+ <div className="kol-media-tile relative w-full aspect-square rounded overflow-hidden flex items-center justify-center">
64
+ {/* Same as audio: the media element is hidden, the artwork (the poster) is the tile. */}
65
+ <video ref={ref} src={src} playsInline preload="metadata" className={poster ? 'hidden' : 'absolute inset-0 w-full h-full object-cover'} onPlay={() => setPlaying(true)} onPause={() => setPlaying(false)} onEnded={() => setPlaying(false)} />
66
+ {poster && <img src={poster} alt="" className="absolute inset-0 w-full h-full object-cover" />}
67
+ <PlayDisc playing={playing} onClick={() => (playing ? ref.current.pause() : ref.current.play())} />
68
+ </div>
69
+ );
70
+ }
@@ -1,9 +1,10 @@
1
1
  /* eslint-disable react-hooks/refs -- floating-ui's `refs.setReference` is a callback ref, as in the DS Tooltip */
2
2
  import { useState } from 'react'
3
3
  import IconFrame from '../atoms/IconFrame.jsx'
4
+ import Slider from './Slider.jsx'
4
5
  import { usePopover, PopoverPanel } from '../utilities/Popover.jsx'
5
6
 
6
- /* taxonomy-ok: molecule — nests IconFrame (atom) + PopoverPanel (relative). */
7
+ /* taxonomy-ok: molecule — nests IconFrame (atom) + Slider / PopoverPanel (relative). */
7
8
 
8
9
  /* mm:ss — the reference bar shows two-digit minutes (00:12 · 00:25) */
9
10
  export const clock = (s) => `${String(Math.floor((s || 0) / 60)).padStart(2, '0')}:${String(Math.floor((s || 0) % 60)).padStart(2, '0')}`
@@ -18,9 +19,9 @@ const RATES = [1, 1.5, 2]
18
19
  * gap-7` — white glyphs whatever the theme (`.kol-playback-bar`, kol-theme ≥0.75.0:
19
20
  * opacity .8, 1 on hover / focus-visible), the transport cluster (`skip-back-15` ·
20
21
  * play / pause · `skip-forward-15`, ghost IconFrames, gap-5), elapsed as `mm:ss`
21
- * (`kol-mono-16 tabular-nums`), a native range scrubber (2px track at white 40 %,
22
- * a 4 × 28 white pill knob — `.kol-playback-scrub`), the TOTAL length (not the
23
- * remaining), volume behind `speaker` (the vertical `slider-black` range in a
22
+ * (`kol-mono-16 tabular-nums`), the scrubber (`Slider variant="scrub"`: a 2px
23
+ * track, a 4 × 28 pill knob — white over media), the TOTAL length (not the
24
+ * remaining), volume behind `speaker` (`Slider direction="vertical"` in a
24
25
  * PopoverPanel), and `>>` (`chevrons-right`) cycling the speed 1 → 1.5 → 2.
25
26
  *
26
27
  * PRESENTATIONAL — the sheet owns the media element (`usePlayback`); this bar
@@ -59,23 +60,13 @@ export default function PlaybackBar({ playing, time, duration, rate = 1, onToggl
59
60
  <IconFrame name="skip-forward-15" variant="ghost" size="md" iconSize={22} onClick={() => onSeek(time + 15)} aria-label="Forward 15 seconds" />
60
61
  </div>
61
62
  <span className="kol-mono-16 tabular-nums opacity-80">{clock(time)}</span>
62
- <input type="range" className="kol-playback-scrub flex-1 min-w-0" min={0} max={duration || 0} step={0.1} value={time} onChange={(e) => onSeek(Number(e.target.value))} aria-label="Scrub" />
63
+ <Slider variant="scrub" readout="none" className="flex-1 min-w-0" min={0} max={duration || 0} step={0.1} value={time} onChange={onSeek} aria-label="Scrub" />
63
64
  <span className="kol-mono-16 tabular-nums opacity-80">{clock(duration)}</span>
64
65
  <span ref={pop.refs.setReference} {...pop.getReferenceProps()} className="inline-flex">
65
66
  <IconFrame name="speaker" variant={open ? 'secondary' : 'ghost'} size="md" onClick={() => {}} aria-label="Volume" />
66
67
  </span>
67
68
  <PopoverPanel popover={pop} className="p-2">
68
- <div className="w-6 h-24 flex items-center justify-center">
69
- <input
70
- type="range"
71
- min={0}
72
- max={100}
73
- value={volume}
74
- onChange={(e) => { const v = Number(e.target.value); onVolume?.(v / 100); setVolume(v) }}
75
- className="slider-black cursor-pointer w-24 -rotate-90"
76
- aria-label="Volume"
77
- />
78
- </div>
69
+ <Slider direction="vertical" readout="none" min={0} max={100} value={volume} onChange={(v) => { onVolume?.(v / 100); setVolume(v) }} aria-label="Volume" />
79
70
  </PopoverPanel>
80
71
  {onRate && (
81
72
  <span className="flex items-center gap-1">
@@ -16,5 +16,5 @@ import useCoarsePointer from '../hooks/useCoarsePointer.js'
16
16
  export default function RowMenuButton({ onOpen, variant = 'ghost', className = '' }) {
17
17
  const coarse = useCoarsePointer()
18
18
  if (!coarse || !onOpen) return null
19
- return <Button variant={variant} size="md" iconOnly="more" aria-label="More actions" className={`shrink-0 ${className}`.trim()} onClick={onOpen} />
19
+ return <Button tone={variant} size="md" iconOnly="more" aria-label="More actions" className={`shrink-0 ${className}`.trim()} onClick={onOpen} />
20
20
  }
@@ -46,7 +46,7 @@ const ShapeDropdown = ({ options = [], value, onChange, onAction, className = ''
46
46
  {current?.icon ? (
47
47
  <Tooltip label={current.label}>
48
48
  <Button
49
- variant="ghost"
49
+ tone="ghost"
50
50
  size="sm"
51
51
  quiet
52
52
  iconOnly={current.icon}
@@ -55,7 +55,7 @@ const ShapeDropdown = ({ options = [], value, onChange, onAction, className = ''
55
55
  />
56
56
  </Tooltip>
57
57
  ) : (
58
- <Button variant="ghost" size="sm" quiet onClick={() => onAction?.(current?.id)}>
58
+ <Button tone="ghost" size="sm" quiet onClick={() => onAction?.(current?.id)}>
59
59
  {current?.label}
60
60
  </Button>
61
61
  )}
@@ -67,7 +67,7 @@ const ShapeDropdown = ({ options = [], value, onChange, onAction, className = ''
67
67
  {...popover.getReferenceProps()}
68
68
  className="inline-flex"
69
69
  >
70
- <Button variant="ghost" size="sm" quiet iconOnly="chevron-down" iconSize={10} aria-label="Variants" />
70
+ <Button tone="ghost" size="sm" quiet iconOnly="chevron-down" iconSize={10} aria-label="Variants" />
71
71
  </span>
72
72
  {/* w-max: a float sizes to its CONTENT (floating-ui contract) — without
73
73
  * it the absolutely-positioned panel can stretch against its containing
@@ -1,9 +1,10 @@
1
1
  import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
2
2
  import Input from '../atoms/Input.jsx'
3
+ import SliderPanel from './SliderPanel.jsx'
3
4
 
4
5
  /**
5
6
  * Slider — range slider with label and a value readout. The LINEAR VARIANT of
6
- * RotaryDial (atoms/RotaryDial.jsx): both implement the shared value-control
7
+ * Knob (molecules/Knob.jsx): both implement the shared value-control
7
8
  * contract — `value` / `min` / `max` / `step` / `onChange(next: number)` /
8
9
  * `label` / `size` / `disabled` / `formatValue` / `defaultValue`.
9
10
  * Controlled; onChange always fires with the plain number.
@@ -33,16 +34,20 @@ import Input from '../atoms/Input.jsx'
33
34
  * @param {number} props.value - Controlled value within min–max
34
35
  * @param {Function} props.onChange - (next: number) => void change handler
35
36
  * @param {number} props.step - Slider step increment (default: 1)
36
- * @param {number} props.size - Track length in px (mirrors RotaryDial's dial px size); unset = fluid flex-1 track
37
+ * @param {number} props.size - Track length in px (mirrors Knob's dial px size); unset = fluid flex-1 track
37
38
  * @param {boolean} props.disabled - Disables track + readout and dims the control (default: false)
38
- * @param {Function} props.formatValue - Optional formatter for displayed value; RotaryDial's default readout is `${value}%`
39
+ * @param {Function} props.formatValue - Optional formatter for displayed value; Knob's default readout is `${value}%`
39
40
  * @param {string} props.className - Additional wrapper classes
40
41
  * @param {number} props.displayWidth - Width of the value readout, in characters (default: 6)
41
42
  * @param {string} props.fontSize - Font size for label/value (e.g., '11px')
42
43
  * @param {Object} props.style - forwarded to the wrapper; the seam for `--kol-slider-track` / `--kol-slider-playhead`
43
- * @param {number} props.defaultValue - alt-click the control resets to this (falls back to `min`). Same contract on RotaryDial
44
- * @param {'input'|'value'|'none'} props.readout - `input` (default) an editable Input · `value` a plain right-aligned span, RotaryDial's own display-only readout · `none`
45
- * @param {'minimal'|'dual'} props.variant - `dual` = two thumbs on one rail
44
+ * @param {number} props.defaultValue - alt-click the control resets to this (falls back to `min`). Same contract on Knob
45
+ * @param {'input'|'value'|'none'} props.readout - `input` (default) an editable Input · `value` a plain right-aligned span, Knob's own display-only readout · `none`
46
+ * @param {'minimal'|'dual'|'scrub'|'panel'} props.variant - `dual` = two thumbs on one rail · `scrub` = a media scrubber: the thin track with the 4 × 28 pill knob (`.kol-playback-scrub`; `PlaybackBar`'s, 2026-10-02) · `panel` = the rack slider (was kol-hardware `Fader`, merged 2026-10-01): a drawn 2px track for a hardware panel; takes `value · onChange · min · max · step · label` plus `onHold`
47
+ * @param {'horizontal'|'vertical'} props.direction - `vertical` stands the track upright at `height`, label above and readout below (2026-10-02 — the volume ranges were hand-drawn for want of it). On `panel`, horizontal takes the row and shows a readout
48
+ * @param {number} props.height - vertical only — the track's length in px (default 96; panel 60)
49
+ * @param {string} props['aria-label'] - names the range when there is no visible `label`
50
+ * @param {Function} props.onHold - panel only — touch hold, 500ms: `({ label, value, min, max, step }) => void`; the holder opens its sheet
46
51
  * @param {number} props.value2 - dual only — the out value
47
52
  * @param {Function} props.onChange2 - dual only — (next: number) => void for the out thumb
48
53
  * @param {string} props.label1 - dual only — replaces the formatted in value above the rail
@@ -73,6 +78,10 @@ const Slider = ({
73
78
  playhead = null,
74
79
  onPlayheadChange,
75
80
  style,
81
+ direction,
82
+ height,
83
+ onHold,
84
+ 'aria-label': ariaLabel,
76
85
  }) => {
77
86
  /* Label ↔ input pairing — the <label> is a sibling of the range input, so
78
87
  * without an htmlFor/id pair the visible label confers no accessible name. */
@@ -160,7 +169,7 @@ const Slider = ({
160
169
  if (e.key === 'Escape') { setDraft(displayValue); setEditing(false); e.currentTarget.blur() }
161
170
  }
162
171
 
163
- /* alt-click anywhere on the control resets — RotaryDial carries the same
172
+ /* alt-click anywhere on the control resets — Knob carries the same
164
173
  * gesture, so a reset that worked on the knob and not the fader would be the
165
174
  * shared value-control contract splitting again. */
166
175
  const onAltReset = (e) => {
@@ -169,6 +178,10 @@ const Slider = ({
169
178
  onChange(defaultValue ?? min)
170
179
  }
171
180
 
181
+ if (variant === 'panel') {
182
+ return <SliderPanel value={value} onChange={onChange} min={min} max={max} step={step} label={label} direction={direction} height={height} onHold={onHold} />
183
+ }
184
+
172
185
  if (variant === 'dual') {
173
186
  const v1 = value ?? min
174
187
  const v2 = value2 ?? max
@@ -236,56 +249,80 @@ const Slider = ({
236
249
  )
237
250
  }
238
251
 
252
+ const vertical = direction === 'vertical'
253
+ const scrub = variant === 'scrub'
254
+ const length = vertical ? height ?? 96 : size
255
+
256
+ const labelNode = label && (
257
+ <label
258
+ htmlFor={sliderId}
259
+ className={`kol-helper-12 whitespace-nowrap shrink-0 w-fit ${disabled ? 'opacity-50' : ''}`}
260
+ style={fontSize ? { fontSize } : undefined}
261
+ >
262
+ {label}
263
+ </label>
264
+ )
265
+
266
+ const range = (
267
+ <input
268
+ id={sliderId}
269
+ type="range"
270
+ min={min}
271
+ max={max}
272
+ step={step}
273
+ value={value}
274
+ onChange={handleChange}
275
+ disabled={disabled}
276
+ aria-label={ariaLabel}
277
+ className={`${scrub ? 'kol-playback-scrub' : 'slider-black cursor-pointer disabled:cursor-default'} disabled:opacity-50 ${vertical ? '-rotate-90 flex-none' : length ? 'flex-none' : 'flex-1 w-full min-w-0'}`}
278
+ /* upright, the drag is vertical — so the page's pan is the horizontal one */
279
+ style={length ? { width: length, ...(vertical && { touchAction: 'pan-x' }) } : undefined}
280
+ />
281
+ )
282
+
283
+ const readoutNode = readout === 'input' ? (
284
+ <Input
285
+ type="text"
286
+ inputMode="decimal"
287
+ variant="filled"
288
+ size="sm"
289
+ chars={displayWidth}
290
+ value={draft}
291
+ disabled={disabled}
292
+ onFocus={(e) => { setEditing(true); e.target.select() }}
293
+ onChange={(e) => setDraft(e.target.value)}
294
+ onBlur={commit}
295
+ onKeyDown={onKeyDown}
296
+ inputClassName="text-center"
297
+ />
298
+ ) : readout === 'value' ? (
299
+ /* `value` is Knob's readout, not a second design — a mixer running
300
+ * ~23 faders in a 24px row cannot afford an input chip on every one, and
301
+ * that is why the easy call sites could not move. */
302
+ <span
303
+ className={`kol-helper-12 shrink-0 w-fit text-right ${disabled ? 'opacity-50' : ''}`}
304
+ style={fontSize ? { fontSize } : undefined}
305
+ >
306
+ {displayValue}
307
+ </span>
308
+ ) : null
309
+
310
+ if (vertical) {
311
+ return (
312
+ <div className={`inline-flex flex-col items-center gap-2 ${className}`} style={style} onClick={onAltReset}>
313
+ {labelNode}
314
+ {/* a native range turned upright: the box is the track's length tall, the input that long */}
315
+ <div className="w-6 flex items-center justify-center" style={{ height: length }}>{range}</div>
316
+ {readoutNode}
317
+ </div>
318
+ )
319
+ }
320
+
239
321
  return (
240
- <div className={`control-slider gap-3 shadow-none ${className}`} style={style} onClick={onAltReset}>
241
- {label && (
242
- <label
243
- htmlFor={sliderId}
244
- className={`kol-helper-12 whitespace-nowrap shrink-0 w-fit ${disabled ? 'opacity-50' : ''}`}
245
- style={fontSize ? { fontSize } : undefined}
246
- >
247
- {label}
248
- </label>
249
- )}
250
- <input
251
- id={sliderId}
252
- type="range"
253
- min={min}
254
- max={max}
255
- step={step}
256
- value={value}
257
- onChange={handleChange}
258
- disabled={disabled}
259
- className={`slider-black cursor-pointer disabled:cursor-default disabled:opacity-50 ${size ? 'flex-none' : 'flex-1 w-full'}`}
260
- style={size ? { width: size } : undefined}
261
- />
262
- {readout === 'input' && (
263
- <Input
264
- type="text"
265
- inputMode="decimal"
266
- variant="filled"
267
- size="sm"
268
- chars={displayWidth}
269
- value={draft}
270
- disabled={disabled}
271
- onFocus={(e) => { setEditing(true); e.target.select() }}
272
- onChange={(e) => setDraft(e.target.value)}
273
- onBlur={commit}
274
- onKeyDown={onKeyDown}
275
- inputClassName="text-center"
276
- />
277
- )}
278
- {/* `value` is RotaryDial's readout, not a second design — a mixer running
279
- * ~23 faders in a 24px row cannot afford an input chip on every one, and
280
- * that is why the easy call sites could not move. */}
281
- {readout === 'value' && (
282
- <span
283
- className={`kol-helper-12 shrink-0 w-fit text-right ${disabled ? 'opacity-50' : ''}`}
284
- style={fontSize ? { fontSize } : undefined}
285
- >
286
- {displayValue}
287
- </span>
288
- )}
322
+ <div className={`control-slider gap-3 shadow-none ${scrub ? 'h-7' : ''} ${className}`} style={style} onClick={onAltReset}>
323
+ {labelNode}
324
+ {range}
325
+ {readoutNode}
289
326
  </div>
290
327
  )
291
328
  }
@@ -0,0 +1,88 @@
1
+ import { useRef, useCallback } from 'react'
2
+ import { armLongPress } from '../utilities/armLongPress.js'
3
+
4
+ const NO_HOLD = { move() {}, cancel() {} }
5
+
6
+ /**
7
+ * SliderPanel — Slider's `variant="panel"`: the rack slider. A 2px track and an 8px thumb for a
8
+ * 24px hardware panel, drawn rather than a native range, with a vertical direction. It was
9
+ * kol-hardware's `Fader` (kol-monitor's rack, lifted 2026-09-01) and is carried here class for
10
+ * class — merged into Slider 2026-10-01 (user ruling: one slider, not two that look alike).
11
+ * Not exported: reach it through `<Slider variant="panel">`.
12
+ */
13
+ export default function SliderPanel({ value, onChange, min = 0, max = 100, step = 1, label, direction = 'horizontal', height, onHold }) {
14
+ const trackRef = useRef(null)
15
+ const vertical = direction === 'vertical'
16
+
17
+ const handlePointerDown = useCallback((e) => {
18
+ e.preventDefault()
19
+ const track = trackRef.current
20
+ if (!track) return
21
+
22
+ const update = (e) => {
23
+ const rect = track.getBoundingClientRect()
24
+ const ratio = vertical
25
+ ? Math.max(0, Math.min(1, 1 - (e.clientY - rect.top) / rect.height))
26
+ : Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
27
+ const raw = min + ratio * (max - min)
28
+ onChange(Math.round(raw / step) * step)
29
+ }
30
+
31
+ update(e)
32
+ const id = e.pointerId
33
+ const onMove = (e) => { if (e.pointerId === id) { hold.move(e); update(e) } } // a second finger is a pan
34
+ const onUp = () => {
35
+ hold.cancel()
36
+ window.removeEventListener('pointermove', onMove)
37
+ window.removeEventListener('pointerup', onUp)
38
+ }
39
+ // Touch: hold still 500ms → the holder's sheet instead of the drag
40
+ const hold = onHold ? armLongPress(e, () => { onUp(); onHold({ label, value, min, max, step }) }) : NO_HOLD
41
+ window.addEventListener('pointermove', onMove)
42
+ window.addEventListener('pointerup', onUp)
43
+ }, [min, max, step, onChange, vertical, onHold, label, value])
44
+
45
+ const norm = max > min ? ((value ?? 0) - min) / (max - min) : 0
46
+
47
+ if (vertical) {
48
+ return (
49
+ <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
50
+ {label && (
51
+ <span className="kol-helper-8 text-fg-32" style={{ textTransform: 'uppercase', lineHeight: 1 }}>
52
+ {label}
53
+ </span>
54
+ )}
55
+ <div
56
+ ref={trackRef}
57
+ onPointerDown={handlePointerDown}
58
+ className="bg-fg-16"
59
+ style={{ width: 2, height: height ?? 60, position: 'relative', cursor: 'ns-resize', touchAction: 'none' }}
60
+ >
61
+ <div className="bg-fg-16" style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: `${norm * 100}%` }} />
62
+ <div className="bg-fg-72" style={{ position: 'absolute', bottom: `calc(${norm * 100}% - 4px)`, left: -3, width: 8, height: 8, borderRadius: '50%' }} />
63
+ </div>
64
+ </div>
65
+ )
66
+ }
67
+
68
+ return (
69
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1 }}>
70
+ {label && (
71
+ <span className="kol-helper-8 text-fg-32" style={{ textTransform: 'uppercase', lineHeight: 1, flexShrink: 0, minWidth: 20 }}>
72
+ {label}
73
+ </span>
74
+ )}
75
+ <div
76
+ ref={trackRef}
77
+ onPointerDown={handlePointerDown}
78
+ className="bg-fg-16"
79
+ style={{ flex: 1, height: 2, position: 'relative', cursor: 'pointer', touchAction: 'none' }}
80
+ >
81
+ <div className="bg-fg-72" style={{ position: 'absolute', left: `calc(${norm * 100}% - 4px)`, top: -3, width: 8, height: 8, borderRadius: '50%' }} />
82
+ </div>
83
+ <span className="kol-helper-8 text-fg-32" style={{ lineHeight: 1, flexShrink: 0, minWidth: 16, textAlign: 'right' }}>
84
+ {Math.round(value ?? 0)}
85
+ </span>
86
+ </div>
87
+ )
88
+ }
@@ -17,7 +17,7 @@ import { glyphSize } from '../hooks/glyphLadders.js'
17
17
  * corner fold inside its own box, and `blurOnClick` must blur the button
18
18
  * itself. The class output — kol-btn kol-btn-ghost kol-btn-icon kol-btn-{size}
19
19
  * + kol-btn-quiet / kol-btn-pressed — is exactly what
20
- * `<Button variant="ghost" quiet pressed iconOnly>` emits, so the box and the
20
+ * `<Button tone="ghost" quiet pressed iconOnly>` emits, so the box and the
21
21
  * visual contract stay Button's.
22
22
  *
23
23
  * `size` was a raw px number (28) with a transcribed 14px glyph until 0.182.0:
@@ -1,6 +1,6 @@
1
1
  import { useEffect, useState } from 'react'
2
2
  import { Icon } from '@kolkrabbi/kol-icons'
3
- import ColorSwatch from '../atoms/ColorSwatch.jsx'
3
+ import ColorSwatch from './ColorSwatch.jsx'
4
4
  import { Tooltip } from '../utilities/Popover.jsx'
5
5
 
6
6
  /*
@@ -1,7 +1,7 @@
1
1
  import { useEffect, useState } from 'react'
2
2
  import useInViewAttention from '../hooks/useInViewAttention.js'
3
3
  import { motion } from 'framer-motion'
4
- import HlsVideo from '../atoms/HlsVideo.jsx'
4
+ import BackgroundVideo from '../utilities/BackgroundVideo.jsx'
5
5
  import AssetPlaceholder from '../utilities/AssetPlaceholder.jsx'
6
6
  import Button from '../atoms/Button.jsx'
7
7
  import Image from '../atoms/Image.jsx'
@@ -9,7 +9,7 @@ import useTilt from '../hooks/useTilt.js'
9
9
  import usePrefersReducedMotion from '../hooks/usePrefersReducedMotion.js'
10
10
 
11
11
  /* taxonomy-ok: nests the same-file Media helper plus DS atoms/molecules
12
- * (HlsVideo, AssetPlaceholder, Button, Image) it composes — an organism. */
12
+ * (BackgroundVideo, AssetPlaceholder, Button, Image) it composes — an organism. */
13
13
 
14
14
  /**
15
15
  * True on coarse-pointer (touch/no-hover) devices. Local to TiltBento, mirrors
@@ -37,7 +37,7 @@ function useCoarsePointer() {
37
37
 
38
38
  /**
39
39
  * Media — internal, NOT exported. Sniffs `src` by extension and renders the
40
- * right full-bleed media element: `.m3u8` → HlsVideo, `.mov|.mp4|.webm` →
40
+ * right full-bleed media element: `.m3u8` → BackgroundVideo, `.mov|.mp4|.webm` →
41
41
  * autoplay <video>, any other src → the Image molecule (its own broken-asset
42
42
  * fallback), and no src → AssetPlaceholder. Positioning/fit arrive via
43
43
  * `className`; Image gets an inline height so its base `h-auto` can't beat the
@@ -45,7 +45,7 @@ function useCoarsePointer() {
45
45
  */
46
46
  function Media({ src, poster, className }) {
47
47
  if (!src) return <AssetPlaceholder className={className} />
48
- if (/\.m3u8$/i.test(src)) return <HlsVideo src={src} poster={poster} className={className} />
48
+ if (/\.m3u8$/i.test(src)) return <BackgroundVideo src={src} poster={poster} className={className} />
49
49
  if (/\.(mov|mp4|webm)$/i.test(src)) {
50
50
  return (
51
51
  <video
@@ -198,7 +198,7 @@ export default function TiltBento({
198
198
  <div className={revealClass}>
199
199
  <Button
200
200
  href={href}
201
- variant="primary"
201
+ tone="primary"
202
202
  size="sm"
203
203
  {...(isExternal
204
204
  ? { target: '_blank', rel: 'noreferrer noopener' }
@@ -4,7 +4,7 @@ import KindPreview, { KIND_GLYPH } from '../molecules/KindPreview.jsx'
4
4
  import FileIcon from '../atoms/FileIcon.jsx'
5
5
  import RowMenuButton from '../molecules/RowMenuButton.jsx'
6
6
  import OptionRow from '../molecules/OptionRow.jsx'
7
- import { formatLength } from '../molecules/AudioPreview.jsx'
7
+ import { formatLength } from '../molecules/PlayTile.jsx'
8
8
  import { kindOf as dsKindOf, KIND_LABEL as DS_KIND_LABEL } from '../utilities/mediaKinds.js'
9
9
  import useGrabEdge from '../hooks/useGrabEdge.js'
10
10
  import useMediaQuery from '../hooks/useMediaQuery.js'
@@ -117,7 +117,7 @@ const ContentFilters = ({
117
117
  searchKeys = ['label', 'name', 'title', 'type'],
118
118
  /* CONTROLLED SEARCH (2026-09-30) — same seam as `filtersOpen`: a consumer that must know the
119
119
  * query (media swaps the tree for a flat list while one is typed) or hand one in (⌘Enter from its
120
- * palette) owns it. Absent, the text stays private, exactly as before. */
120
+ * search modal) owns it. Absent, the text stays private, exactly as before. */
121
121
  searchValue,
122
122
  onSearchChange,
123
123
  headerActions,
@@ -79,7 +79,7 @@ function TermsEditor({ def, commit }) {
79
79
  <div className="flex flex-col gap-2">
80
80
  <div className="flex items-center justify-between">
81
81
  <span className="kol-helper-10 text-meta">Vectors</span>
82
- <Button variant="primary" size="sm" onClick={addTerm}>Add</Button>
82
+ <Button tone="primary" size="sm" onClick={addTerm}>Add</Button>
83
83
  </div>
84
84
  <div className="grid grid-cols-[1fr_1fr_1fr_auto] gap-1.5 items-center min-w-0 [&>*]:min-w-0">
85
85
  <span className="kol-helper-10 text-fg-48 text-center">amp</span>
@@ -103,7 +103,7 @@ function TermRow({ term, onSet, onRemove, canRemove }) {
103
103
  {cell('amp', 1)}
104
104
  {cell('freq', 1)}
105
105
  {cell('phase', 0)}
106
- <Button variant="ghost" size="sm" onClick={onRemove} disabled={!canRemove} aria-label="Remove vector">×</Button>
106
+ <Button tone="ghost" size="sm" onClick={onRemove} disabled={!canRemove} aria-label="Remove vector">×</Button>
107
107
  </>
108
108
  )
109
109
  }