@kolkrabbi/kol-component 0.236.0 → 0.238.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 (54) hide show
  1. package/package.json +4 -3
  2. package/src/atoms/Button.jsx +10 -2
  3. package/src/atoms/Input.jsx +5 -2
  4. package/src/atoms/Kbd.jsx +6 -3
  5. package/src/index.js +15 -11
  6. package/src/molecules/ColorInputRow.jsx +1 -1
  7. package/src/molecules/ColorRamp.jsx +1 -1
  8. package/src/molecules/CopyButton.jsx +1 -1
  9. package/src/molecules/Dropdown.jsx +48 -7
  10. package/src/molecules/KindPreview.jsx +6 -6
  11. package/src/{atoms/RotaryDial.jsx → molecules/Knob.jsx} +20 -2
  12. package/src/molecules/KnobPanel.jsx +116 -0
  13. package/src/molecules/LabeledControl.jsx +22 -0
  14. package/src/molecules/MediaPlayer.jsx +107 -0
  15. package/src/molecules/MenuItem.jsx +5 -2
  16. package/src/molecules/Modal.jsx +1 -1
  17. package/src/molecules/MultiSelect.jsx +70 -0
  18. package/src/molecules/OptionRow.jsx +1 -1
  19. package/src/molecules/PlayTile.jsx +70 -0
  20. package/src/molecules/PlaybackBar.jsx +7 -16
  21. package/src/molecules/RowMenuButton.jsx +1 -1
  22. package/src/molecules/ShapeDropdown.jsx +3 -3
  23. package/src/molecules/Slider.jsx +93 -56
  24. package/src/molecules/SliderPanel.jsx +88 -0
  25. package/src/molecules/SplitToolButton.jsx +1 -1
  26. package/src/molecules/SwatchControls.jsx +1 -1
  27. package/src/molecules/TiltBento.jsx +5 -5
  28. package/src/organisms/ColumnBrowser.jsx +1 -1
  29. package/src/organisms/ContentFilters.jsx +1 -1
  30. package/src/organisms/CurveEditor.jsx +2 -2
  31. package/src/organisms/FeaturedCarousel.jsx +4 -4
  32. package/src/organisms/KeyframeEditor.jsx +3 -3
  33. package/src/organisms/LayerStack.jsx +2 -2
  34. package/src/organisms/MediaLibrary.jsx +1 -1
  35. package/src/organisms/MediaLibraryPages.jsx +20 -20
  36. package/src/organisms/RecordManager.jsx +1 -1
  37. package/src/organisms/SectionHero.jsx +3 -3
  38. package/src/organisms/SectionNewsletter.jsx +2 -1
  39. package/src/organisms/ShellSearchOverlay.jsx +13 -9
  40. package/src/organisms/SpectrumGrid.jsx +1 -1
  41. package/src/organisms/ToolPalette.jsx +1 -1
  42. package/src/{atoms/HlsVideo.jsx → utilities/BackgroundVideo.jsx} +6 -2
  43. package/src/utilities/ErrorBoundary.jsx +1 -1
  44. package/src/utilities/Popover.jsx +3 -1
  45. package/src/utilities/QuadrantSync.jsx +4 -1
  46. package/src/utilities/armLongPress.js +18 -0
  47. package/src/utilities/mediaSearch.js +2 -2
  48. package/src/utilities/tone.js +6 -0
  49. package/src/atoms/AudioPlayer.jsx +0 -51
  50. package/src/molecules/AudioPreview.jsx +0 -140
  51. package/src/molecules/AudioSheet.jsx +0 -48
  52. package/src/molecules/VideoSheet.jsx +0 -25
  53. /package/src/{atoms → molecules}/ActionButton.jsx +0 -0
  54. /package/src/{atoms → molecules}/ColorSwatch.jsx +0 -0
@@ -0,0 +1,107 @@
1
+ import usePlayback from '../hooks/usePlayback.js'
2
+ import PlaybackBar, { clock } from './PlaybackBar.jsx'
3
+ import QuickLookFrame from './QuickLookFrame.jsx'
4
+ import FileIcon from '../atoms/FileIcon.jsx'
5
+ import { useCover } from './PlayTile.jsx'
6
+
7
+ /* taxonomy-ok: molecule — nests PlaybackBar + QuickLookFrame (relative) + FileIcon (atom). */
8
+
9
+ /**
10
+ * MediaPlayer — Play a video or an audio file. the media, `PlaybackBar` and the `usePlayback` hook
11
+ * as ONE player (user ruling 2026-10-02: *"I was referring to the two players in the quicklook in
12
+ * media"*). It was four components — `VideoSheet` and `AudioSheet` (the Quick Look pair),
13
+ * `AudioPreview` (its own transport) and `AudioPlayer` (the browser's strip) — and each is an
14
+ * alias of this one now, on the retirement ledger.
15
+ *
16
+ * `variant` is what plays. `frame` is where: given, the player draws inside `QuickLookFrame` (the
17
+ * Quick Look window — the bar docked in its footer); absent, it plays inline (*"why would media
18
+ * player not just prop the window, its not called WINDOWmediaplayer"*).
19
+ *
20
+ * video the picture at its own ratio; click toggles play. Inline, the bar floats over
21
+ * its bottom edge (inset 16, radius 12)
22
+ * audio inline, the bar alone. In the window, Finder's layout: the artwork square (the
23
+ * file's embedded ID3 cover; none → `FileIcon`) beside `Time: mm:ss`
24
+ *
25
+ * No native controls, no autoplay (Quick Look opens paused), `playsInline`.
26
+ *
27
+ * @param {'video'|'audio'} variant what plays (default `video`)
28
+ * @param {string} src the media URL
29
+ * @param {string} poster video — the poster URL
30
+ * @param {string} ext audio — the extension, for the no-cover icon
31
+ * @param {Function} onMeta ({ w, h, len }) => void once metadata lands (audio reports `len` only)
32
+ * @param {Object} frame the window's header — `QuickLookFrame`'s title · meta · actions · onClose · nav · size
33
+ * @param {'none'|'metadata'|'auto'} preload the native preload hint (default `metadata`)
34
+ * @param {string} className inline only — the player's box (its width is the caller's)
35
+ */
36
+ export default function MediaPlayer({ variant = 'video', src, poster, ext, onMeta, frame, preload = 'metadata', className = '' }) {
37
+ const audio = variant === 'audio'
38
+ const { ref, handlers, bar } = usePlayback((el) => onMeta?.(audio ? { len: el.duration } : { w: el.videoWidth, h: el.videoHeight, len: el.duration }))
39
+ const cover = useCover(audio && frame ? src : null)
40
+ const media = audio
41
+ ? <audio ref={ref} src={src} preload={preload} {...handlers} />
42
+ : <video ref={ref} src={src} poster={poster} playsInline preload={preload} className={frame ? 'kol-quicklook-media' : 'block w-full'} onClick={bar.onToggle} {...handlers} />
43
+
44
+ if (frame) {
45
+ return (
46
+ <QuickLookFrame {...frame} footer={<PlaybackBar {...bar} docked />}>
47
+ {media}
48
+ {audio && (
49
+ <div className="kol-quicklook-audio">
50
+ <div className="kol-quicklook-art">
51
+ {cover
52
+ ? <img src={cover} alt="" className="w-full h-full object-cover" />
53
+ : <FileIcon ext={ext} glyph="music-note" className="w-[52%]" />}
54
+ </div>
55
+ <span className="kol-mono-16 text-meta">Time: <b className="text-strong">{clock(bar.duration)}</b></span>
56
+ </div>
57
+ )}
58
+ </QuickLookFrame>
59
+ )
60
+ }
61
+
62
+ return (
63
+ <div className={`relative ${className}`.trim()}>
64
+ {media}
65
+ <PlaybackBar {...bar} docked={audio} />
66
+ </div>
67
+ )
68
+ }
69
+
70
+ /**
71
+ * @deprecated 2026-10-02 — use `<MediaPlayer variant="video" frame={…}>`. Drops when nobody imports it (04-retirements.md).
72
+ * VideoSheet — the video in the Quick Look window. Same props.
73
+ */
74
+ export function VideoSheet({ frame, ...props }) {
75
+ return <MediaPlayer variant="video" frame={frame ?? {}} {...props} />
76
+ }
77
+
78
+ /**
79
+ * @deprecated 2026-10-02 — use `<MediaPlayer variant="audio" frame={…}>`; `onDuration(seconds)` is `onMeta({ len })`. Drops when nobody imports it (04-retirements.md).
80
+ * AudioSheet — audio in the Quick Look window.
81
+ */
82
+ export function AudioSheet({ frame, onDuration, ...props }) {
83
+ return <MediaPlayer variant="audio" frame={frame ?? {}} onMeta={onDuration && ((m) => onDuration(m.len))} {...props} />
84
+ }
85
+
86
+ /**
87
+ * @deprecated 2026-10-02 — use `<MediaPlayer variant="audio">`; `onDuration(seconds)` is `onMeta({ len })`. Drops when nobody imports it (04-retirements.md).
88
+ * AudioPreview — the inline audio player. Its own transport is the `PlaybackBar` now.
89
+ */
90
+ export function AudioPreview({ onDuration, ...props }) {
91
+ return <MediaPlayer variant="audio" onMeta={onDuration && ((m) => onDuration(m.len))} {...props} />
92
+ }
93
+
94
+ /**
95
+ * @deprecated 2026-10-02 — use `<MediaPlayer variant="audio">`. Drops when nobody imports it (04-retirements.md).
96
+ * AudioPlayer — the native audio strip with a label. The strip is the
97
+ * `PlaybackBar` now; the label is the caller's to draw.
98
+ */
99
+ export function AudioPlayer({ src, label, preload, className = '' }) {
100
+ if (!src) return null
101
+ return (
102
+ <figure className={`kol-audio-player flex flex-col gap-3 ${className}`.trim()}>
103
+ {label && <figcaption className="kol-mono-12 text-fg-48">{label}</figcaption>}
104
+ <MediaPlayer variant="audio" src={src} preload={preload} />
105
+ </figure>
106
+ )
107
+ }
@@ -122,7 +122,10 @@ export function MenuItem({
122
122
  * stack reserves the widest label in its own face, and a row set in a different
123
123
  * one measured 3px wider and clipped the selected row beside its check. */
124
124
  const ROW_BY_SIZE = { xs: 'kol-mono-8 px-2 h-5', sm: 'kol-helper-12 px-3 h-8', md: 'kol-helper-12 px-3 h-8', lg: 'kol-helper-12 px-3 h-8' }
125
- export function MenuDropdownItem({ onClick, onPointerEnter, onPointerLeave, disabled, prefix, iconLeft, shortcut, hover = true, height, size = 'sm', children }) {
125
+ /* `rowClass` — the row's type, side padding and height, whole, in place of the size's own
126
+ * (Dropdown passes it, 2026-10-02: its rows wear the trigger's face and sit on the trigger's
127
+ * text edge; a menu's rows keep `ROW_BY_SIZE`). */
128
+ export function MenuDropdownItem({ onClick, onPointerEnter, onPointerLeave, disabled, prefix, iconLeft, shortcut, hover = true, height, size = 'sm', rowClass, children }) {
126
129
  return (
127
130
  <button
128
131
  type="button"
@@ -133,7 +136,7 @@ export function MenuDropdownItem({ onClick, onPointerEnter, onPointerLeave, disa
133
136
  disabled={disabled}
134
137
  role="menuitem"
135
138
  style={height != null ? { height } : undefined}
136
- className={`kol-menu-btn w-full ${ROW_BY_SIZE[size] ?? ROW_BY_SIZE.sm} shrink-0 inline-flex items-center gap-2 text-body ${hover ? 'hover:text-emphasis' : ''} disabled:opacity-40 disabled:cursor-not-allowed text-left`}
139
+ className={`kol-menu-btn w-full ${rowClass ?? ROW_BY_SIZE[size] ?? ROW_BY_SIZE.sm} shrink-0 inline-flex items-center gap-2 text-body ${hover ? 'hover:text-emphasis' : ''} disabled:opacity-40 disabled:cursor-not-allowed text-left`}
137
140
  >
138
141
  {prefix && <span className="shrink-0 inline-flex items-center">{prefix}</span>}
139
142
  {iconLeft && <span className="shrink-0 w-4 inline-flex items-center justify-center">{iconLeft}</span>}
@@ -120,7 +120,7 @@ function ModalView({ state, closeWith }) {
120
120
  )}
121
121
  <div className="flex gap-2 justify-end">
122
122
  {state.kind !== 'alert' && <Button size="sm" onClick={cancel}>{state.cancelLabel ?? 'Cancel'}</Button>}
123
- <Button variant="primary" size="sm" onClick={submit}>{state.okLabel ?? 'OK'}</Button>
123
+ <Button tone="primary" size="sm" onClick={submit}>{state.okLabel ?? 'OK'}</Button>
124
124
  </div>
125
125
  </div>
126
126
  </div>
@@ -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
  }