@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,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
  }
@@ -1,13 +1,13 @@
1
1
  import { isValidElement, useCallback, useEffect, useRef, useState } from 'react'
2
2
  import useEmblaCarousel from 'embla-carousel-react'
3
3
  import Image from '../atoms/Image.jsx'
4
- import HlsVideo from '../atoms/HlsVideo.jsx'
4
+ import BackgroundVideo from '../utilities/BackgroundVideo.jsx'
5
5
  import EmblaNav from '../molecules/EmblaNav.jsx'
6
6
  import OverlayGlassPanel from '../utilities/OverlayGlassPanel.jsx'
7
7
 
8
8
  /**
9
9
  * Per-slide media layer: a `{ src, kind }` descriptor becomes a cover-fit
10
- * Image / HlsVideo / inert <video> pinned over the frame
10
+ * Image / BackgroundVideo / inert <video> pinned over the frame
11
11
  * (`.kol-featured-carousel-media` — absolute inset-0 object-cover, CSS in
12
12
  * @kol/theme, out-cascades Image's baked `h-auto`). A ready ReactNode renders
13
13
  * as-is and must position itself (give it that same class). `onEnded` /
@@ -36,7 +36,7 @@ function SlideMedia({ media, active = true, onEnded, onTimeUpdate }) {
36
36
 
37
37
  if (kind === 'video') {
38
38
  // Non-HLS file (mp4/webm): a plain inert video. HLS manifests and the
39
- // poster-only case go through HlsVideo, which owns hls.js attachment and
39
+ // poster-only case go through BackgroundVideo, which owns hls.js attachment and
40
40
  // the non-interactive hardening set. `loop` is dropped once `onEnded` is
41
41
  // wired so the active slide plays once, then advances.
42
42
  if (src && !src.endsWith('.m3u8')) {
@@ -57,7 +57,7 @@ function SlideMedia({ media, active = true, onEnded, onTimeUpdate }) {
57
57
  )
58
58
  }
59
59
  return (
60
- <HlsVideo
60
+ <BackgroundVideo
61
61
  src={src}
62
62
  poster={poster}
63
63
  active={active}
@@ -105,7 +105,7 @@ export default function KeyframeEditor({
105
105
  {kfs.map((kf, i) => (
106
106
  <Button
107
107
  key={i}
108
- variant={i === sel ? 'primary' : 'secondary'}
108
+ tone={i === sel ? 'primary' : 'inverted'}
109
109
  size="sm"
110
110
  className="w-full"
111
111
  style={{ justifyContent: 'space-between' }}
@@ -117,8 +117,8 @@ export default function KeyframeEditor({
117
117
  ))}
118
118
  </div>
119
119
  <div className="flex gap-2">
120
- <Button variant="primary" size="sm" className="flex-1" onClick={onAdd}>Add @ playhead</Button>
121
- <Tooltip label="Delete keyframe"><Button variant="ghost" size="sm" onClick={onDelete} disabled={kfs.length <= 1}>Delete</Button></Tooltip>
120
+ <Button tone="primary" size="sm" className="flex-1" onClick={onAdd}>Add @ playhead</Button>
121
+ <Tooltip label="Delete keyframe"><Button tone="ghost" size="sm" onClick={onDelete} disabled={kfs.length <= 1}>Delete</Button></Tooltip>
122
122
  </div>
123
123
 
124
124
  <span className="kol-helper-10 text-meta">Pose</span>
@@ -425,7 +425,7 @@ export default function LayerStack({
425
425
  <Tooltip label={`Group ${layerSelectionCount} selected layers`}>
426
426
  <Button
427
427
  iconComponent={IconC}
428
- variant="primary"
428
+ tone="primary"
429
429
  size="sm"
430
430
  iconLeft="layers"
431
431
  onClick={() => onGroup(layerSelectedIds)}
@@ -476,7 +476,7 @@ export function AddLayerButton({
476
476
  <Tooltip label="Add layer">
477
477
  <Button
478
478
  iconComponent={IconC}
479
- variant="primary"
479
+ tone="primary"
480
480
  size="sm"
481
481
  quiet
482
482
  iconOnly="plus"
@@ -1,6 +1,6 @@
1
1
  import { createContext, useContext, useEffect, useMemo, useState } from 'react'
2
2
  import { Icon } from '@kolkrabbi/kol-icons'
3
- import ActionButton from '../atoms/ActionButton.jsx'
3
+ import ActionButton from '../molecules/ActionButton.jsx'
4
4
  import Button from '../atoms/Button.jsx'
5
5
  import Divider from '../atoms/Divider.jsx'
6
6
  import Input from '../atoms/Input.jsx'
@@ -8,7 +8,7 @@ import Divider from '../atoms/Divider.jsx'
8
8
  import Input from '../atoms/Input.jsx'
9
9
  import Tag from '../atoms/Tag.jsx'
10
10
  import IconFrame from '../atoms/IconFrame.jsx'
11
- import ActionButton from '../atoms/ActionButton.jsx'
11
+ import ActionButton from '../molecules/ActionButton.jsx'
12
12
  import SizeOrDownload from '../atoms/SizeOrDownload.jsx'
13
13
  import ToggleCheckbox from '../atoms/ToggleCheckbox.jsx'
14
14
  import ViewToggle from '../atoms/ViewToggle.jsx'
@@ -29,10 +29,10 @@ import useMarquee from '../hooks/useMarquee.js'
29
29
  import useLongPress from '../hooks/useLongPress.js'
30
30
  import useMediaQuery from '../hooks/useMediaQuery.js'
31
31
  import KindPreview from '../molecules/KindPreview.jsx'
32
- import AudioSheet from '../molecules/AudioSheet.jsx'
33
- import VideoSheet from '../molecules/VideoSheet.jsx'
32
+ import MediaPlayer from '../molecules/MediaPlayer.jsx'
33
+ import Slider from '../molecules/Slider.jsx'
34
34
  import QuickLookFrame from '../molecules/QuickLookFrame.jsx'
35
- import { formatLength } from '../molecules/AudioPreview.jsx'
35
+ import { formatLength } from '../molecules/PlayTile.jsx'
36
36
  import FullscreenOverlay from '../utilities/FullscreenOverlay.jsx'
37
37
  import { useModal } from '../molecules/Modal.jsx'
38
38
  import ContentFilters from './ContentFilters.jsx'
@@ -65,7 +65,7 @@ import { nearestRatio } from '../utilities/ratios.js'
65
65
  * SortControls · the selection bar while selecting), the
66
66
  * wall on ContentCard / ContentRow default, paging, the
67
67
  * per-bucket list cache, the stats line, the inspector
68
- * lightbox (image · VideoSheet · AudioSheet · DocPage via
68
+ * lightbox (image · MediaPlayer · DocPage via
69
69
  * KindPreview)
70
70
  *
71
71
  * Both take the injected `client` (`{ listMedia, mediaUrl, proxied?, buckets? }`
@@ -686,9 +686,9 @@ export function MediaInspector({ files, index, onClose, onPrev, onNext, mediaUrl
686
686
  onLoad={(e) => setDims({ key: o.key, w: e.target.naturalWidth, h: e.target.naturalHeight })} />
687
687
  </QuickLookFrame>
688
688
  ) : isVideo(o.contentType) ? (
689
- <VideoSheet key={o.key} src={mediaUrl(o.key)} poster={poster ? mediaUrl(poster) : undefined} onMeta={(m) => setDims({ key: o.key, ...m })} frame={frame} />
689
+ <MediaPlayer key={o.key} variant="video" src={mediaUrl(o.key)} poster={poster ? mediaUrl(poster) : undefined} onMeta={(m) => setDims({ key: o.key, ...m })} frame={frame} />
690
690
  ) : kind === 'audio' ? (
691
- <AudioSheet key={o.key} src={mediaUrl(o.key)} ext={extOf(o.key)} onDuration={(len) => setDims({ key: o.key, len })} frame={frame} />
691
+ <MediaPlayer key={o.key} variant="audio" src={mediaUrl(o.key)} ext={extOf(o.key)} onMeta={(m) => setDims({ key: o.key, ...m })} frame={frame} />
692
692
  ) : (
693
693
  <QuickLookFrame {...frame}>
694
694
  <KindPreview o={o} fit="sheet" urlOf={(x) => mediaUrl(x.key)} poster={poster ? mediaUrl(poster) : undefined} />
@@ -849,7 +849,7 @@ function TrashPanel({ trash, onClose, run, confirm }) {
849
849
  <SettingsPanel variant="drawer" title="Trash" onClose={onClose}
850
850
  footer={items.length > 0 && trash.empty ? (
851
851
  <SettingsFooter>
852
- <Button variant="primary" size="sm" onClick={async () => {
852
+ <Button tone="primary" size="sm" onClick={async () => {
853
853
  if (confirm && !(await confirm(`Empty the trash? ${items.length} item${items.length === 1 ? '' : 's'} will be gone for good.`, { okLabel: 'Empty trash' }))) return
854
854
  run(async () => { await trash.empty(); setSel(new Set()) })
855
855
  }}>Empty trash</Button>
@@ -1069,9 +1069,9 @@ export function MediaLibraryBrowse({
1069
1069
  * media-hub, "two too many"). Given, the gear calls it and this page opens no drawer of its own —
1070
1070
  * inside a Hub the Hub's Settings is the one place. Absent, the gear opens the drawer as always. */
1071
1071
  onOpenSettings,
1072
- /* `searchSuggestions` — what the palette lists before you type. Absent: favourites, smart
1072
+ /* `searchSuggestions` — what the search modal lists before you type. Absent: favourites, smart
1073
1073
  * folders, then the bucket's top-level folders. An array replaces them; a function receives them
1074
- * and returns the list. Rows are the palette's shape; a row with `run` calls it instead of
1074
+ * and returns the list. Rows are the search modal's shape; a row with `run` calls it instead of
1075
1075
  * jumping to `id` as a file key. */
1076
1076
  searchSuggestions,
1077
1077
  /* `bucketLevel` — keep title → bucket → folders with ONE bucket (kol-client-olina 2026-09-23).
@@ -1604,13 +1604,13 @@ export function MediaLibraryBrowse({
1604
1604
  openQuickLook({ files, index })
1605
1605
  }
1606
1606
 
1607
- /* SEARCH IS THE DS'S OWN PALETTE (2026-09-22). `/` is the file-manager habit and ⌘K the app one,
1607
+ /* SEARCH IS THE DS'S OWN MODAL (2026-09-22). `/` is the file-manager habit and ⌘K the app one,
1608
1608
  * so both open it; it searches the bucket's keys and JUMPS — it does not filter the list under
1609
1609
  * you, which is what the filter bar is for. */
1610
1610
  const [searchOpen, setSearchOpen] = useState(false)
1611
1611
  const [searchQuery, setSearchQuery] = useState('')
1612
1612
  /* the filter bar's own query — lifted so a typed one swaps the tree for a flat list, and so the
1613
- * palette's ⌘Enter can hand its query over */
1613
+ * search modal's ⌘Enter can hand its query over */
1614
1614
  const [barQuery, setBarQuery] = useState('')
1615
1615
  /* THE ARROWS IN THE GRID AND THE ROWS (user 2026-09-23: *"arrow navigation doesnt work in grid
1616
1616
  * mode? nor in row mode"*). The columns have always had theirs (`ColumnBrowser`); these are the
@@ -2197,12 +2197,12 @@ export function MediaLibraryBrowse({
2197
2197
  * do in grid view"*) — in steps of the control ramp, not pixels, so a row is always a control height */
2198
2198
  const RAMP = ['xs', 'sm', 'md', 'lg']
2199
2199
  const rowSlider = (
2200
- <input type="range" className="slider-black w-32 cursor-pointer" min={0} max={RAMP.length - 1} step={1} value={Math.max(0, RAMP.indexOf(rowSize))}
2201
- onChange={(e) => setSettings({ ...settings, rowSize: RAMP[Number(e.target.value)] })} aria-label="Row size" />
2200
+ <Slider readout="none" size={128} min={0} max={RAMP.length - 1} step={1} value={Math.max(0, RAMP.indexOf(rowSize))}
2201
+ onChange={(v) => setSettings({ ...settings, rowSize: RAMP[v] })} aria-label="Row size" />
2202
2202
  )
2203
2203
  const sizeSlider = (
2204
- <input type="range" className="slider-black w-32 cursor-pointer" min={100} max={360} step={20} value={tileSize}
2205
- onChange={(e) => setSettings({ ...settings, tileSize: Number(e.target.value) })} aria-label="Tile size" />
2204
+ <Slider readout="none" size={128} min={100} max={360} step={20} value={tileSize}
2205
+ onChange={(v) => setSettings({ ...settings, tileSize: v })} aria-label="Tile size" />
2206
2206
  )
2207
2207
  /* `hits` = a query's results: no folders, no root level, each file named by its whole path —
2208
2208
  * in the tree views as a list, the rows' shape; the wall keeps its tiles */
@@ -2256,7 +2256,7 @@ export function MediaLibraryBrowse({
2256
2256
  </div>
2257
2257
  )
2258
2258
 
2259
- /* The palette's own ruling (2026-08-01) is that Enter COMMITS the query rather than navigating
2259
+ /* The search modal's own ruling (2026-08-01) is that Enter COMMITS the query rather than navigating
2260
2260
  * to whatever happened to be first — so `onExpand` is where a committed query lands, and here
2261
2261
  * that means the top hit. Arrow-then-Enter and a click go through `onSelect`, same jump. */
2262
2262
  const searchResults = rankMedia(objects, searchQuery, { limit: 50 }).map((o) => ({
@@ -2265,7 +2265,7 @@ export function MediaLibraryBrowse({
2265
2265
  hint: o.key.slice(0, o.key.lastIndexOf('/') + 1) || 'the bucket root',
2266
2266
  group: KIND_LABEL[kindOf(o)] || 'File',
2267
2267
  }))
2268
- /* THE PALETTE OPENS ON SOMEWHERE TO GO (user 2026-09-30: it opened blank in olina). */
2268
+ /* THE SEARCH MODAL OPENS ON SOMEWHERE TO GO (user 2026-09-30: it opened blank in olina). */
2269
2269
  const openFolder = (path) => { setAppRoot(false); goFolder(path) }
2270
2270
  const leafOf = (path) => path.replace(/\/$/, '').split('/').pop()
2271
2271
  const hintOf = (path) => dirOf(path) || 'the bucket root'
@@ -3034,8 +3034,8 @@ export function MediaLibraryLibrary({
3034
3034
  if (editingKey === o.key) {
3035
3035
  return (
3036
3036
  <div className="flex gap-2">
3037
- <Button variant="primary" size="sm" onClick={commitRename} disabled={renaming}>{renaming ? 'Saving…' : 'Save'}</Button>
3038
- <Button variant="ghost" size="sm" onClick={cancelRename} disabled={renaming}>Cancel</Button>
3037
+ <Button tone="primary" size="sm" onClick={commitRename} disabled={renaming}>{renaming ? 'Saving…' : 'Save'}</Button>
3038
+ <Button tone="ghost" size="sm" onClick={cancelRename} disabled={renaming}>Cancel</Button>
3039
3039
  </div>
3040
3040
  )
3041
3041
  }
@@ -355,7 +355,7 @@ export default function RecordManager({
355
355
  )}
356
356
  {saveState != null && <span className="kol-helper-10 text-meta truncate leading-normal">{saveState}</span>}
357
357
  {onPublish && (
358
- <Button variant="primary" size="sm" onClick={() => onPublish(record)}>
358
+ <Button tone="primary" size="sm" onClick={() => onPublish(record)}>
359
359
  {publishLabel}
360
360
  </Button>
361
361
  )}
@@ -1,7 +1,7 @@
1
1
  import { isValidElement } from 'react'
2
2
  import { FULL_BLEED, bleedClass } from './sectionBleed.js'
3
3
  import { surfaceClass } from '../utilities/sectionSurface.js'
4
- import HlsVideo from '../atoms/HlsVideo.jsx'
4
+ import BackgroundVideo from '../utilities/BackgroundVideo.jsx'
5
5
  import Image from '../atoms/Image.jsx'
6
6
  import OverlayGlassPanel from '../utilities/OverlayGlassPanel.jsx'
7
7
  import AssetPlaceholder from '../utilities/AssetPlaceholder.jsx'
@@ -42,7 +42,7 @@ const SPLIT_HEIGHTS = {
42
42
  }
43
43
 
44
44
  /**
45
- * Background layer: a media descriptor becomes a cover-fit Image / HlsVideo /
45
+ * Background layer: a media descriptor becomes a cover-fit Image / BackgroundVideo /
46
46
  * inert <video> (`.kol-full-bleed-hero-media` pins it absolute + object-cover
47
47
  * — CSS in @kol/theme); a ready ReactNode renders as-is and must position
48
48
  * itself (give it that same class).
@@ -69,7 +69,7 @@ function MediaLayer({ media }) {
69
69
  />
70
70
  )
71
71
  }
72
- return <HlsVideo src={src} poster={poster} className="kol-full-bleed-hero-media" />
72
+ return <BackgroundVideo src={src} poster={poster} className="kol-full-bleed-hero-media" />
73
73
  }
74
74
 
75
75
  return (
@@ -1,4 +1,5 @@
1
1
  import { useId, useState } from 'react'
2
+ import { VARIANT_TONE } from '../utilities/tone.js'
2
3
  import { bleedClass } from './sectionBleed.js'
3
4
  import { surfaceClass } from '../utilities/sectionSurface.js'
4
5
  import Input from '../atoms/Input.jsx'
@@ -185,7 +186,7 @@ export default function SectionNewsletter({
185
186
  />
186
187
  <Button
187
188
  type="submit"
188
- variant={submitVariant}
189
+ {...(VARIANT_TONE[submitVariant] ? { tone: VARIANT_TONE[submitVariant] } : { variant: submitVariant })}
189
190
  size={controlSize}
190
191
  disabled={status === 'submitting'}
191
192
  className="w-full sm:w-auto"
@@ -59,7 +59,7 @@ export function HighlightMatch({ label, query, ranges }) {
59
59
  }
60
60
 
61
61
  /**
62
- * ShellSearchOverlay — The ⌘K search palette. the ⌘K command palette: fullscreen dim + centered
62
+ * ShellSearchOverlay — The ⌘K search modal. the ⌘K search modal: fullscreen dim + centered
63
63
  * panel, a bare SearchInput on top, result rows beneath (HighlightMatch
64
64
  * label, dim hint line, right-aligned group label). Distinct from Modal
65
65
  * (prompt/confirm only) — this is the search/command primitive.
@@ -78,7 +78,7 @@ export function HighlightMatch({ label, query, ranges }) {
78
78
  * @param {Function} onClose () => void — backdrop click, Escape, post-select
79
79
  * @param {Array} results pre-filtered rows: { id, label, group?, hint?, icon? }
80
80
  * @param {Array} [suggestions] rows shown while the query is empty (same shape) —
81
- * the palette opens on somewhere to go, not a blank box
81
+ * the search modal opens on somewhere to go, not a blank box
82
82
  * @param {string} query controlled query (drives the highlight slice)
83
83
  * @param {Function} onQueryChange (string) => void — input change
84
84
  * @param {Function} onSelect (item) => void — row click / Enter; consumer navigates
@@ -90,14 +90,17 @@ export function HighlightMatch({ label, query, ranges }) {
90
90
  * @param {Function} [onOpenResults] (query) => void — ⌘/Ctrl+Enter: every hit, on the consumer's
91
91
  * own results surface. Plain Enter keeps its meaning.
92
92
  * @param {string} [resultsLabel] the footer's second line for ⌘Enter (e.g. `All results`)
93
+ * @param {string} [selectLabel] the footer's Enter line while a row is highlighted (default
94
+ * `Go to page`) — a modal whose rows are not pages says what
95
+ * Enter does instead (the rack's module search: `Add module`)
93
96
  */
94
97
  export default function ShellSearchOverlay({
95
98
  open,
96
99
  onClose,
97
100
  results: rawResults = [],
98
101
  suggestions = [],
99
- /* EXPANDED — the palette's second state (user ruling 2026-08-01). Enter
100
- * commits the query and opens `children` as the results body; the palette
102
+ /* EXPANDED — the search modal's second state (user ruling 2026-08-01). Enter
103
+ * commits the query and opens `children` as the results body; the search modal
101
104
  * and the old tag overlay are one surface with two states, not two
102
105
  * components. `chips` are the committed tag facets of the same query. */
103
106
  expanded = false,
@@ -112,6 +115,7 @@ export default function ShellSearchOverlay({
112
115
  enterLabel,
113
116
  onOpenResults,
114
117
  resultsLabel = 'All results',
118
+ selectLabel = 'Go to page',
115
119
  }) {
116
120
  const panelRef = useRef(null)
117
121
  const listRef = useRef(null)
@@ -176,7 +180,7 @@ export default function ShellSearchOverlay({
176
180
  if (active >= 0) select(results[active])
177
181
  else onExpand?.()
178
182
  } else if (e.key === 'Tab') {
179
- /* Focus trap — the input is the palette's only tab stop. */
183
+ /* Focus trap — the input is the search modal's only tab stop. */
180
184
  e.preventDefault()
181
185
  }
182
186
  }
@@ -214,7 +218,7 @@ export default function ShellSearchOverlay({
214
218
  ))}
215
219
  </div>
216
220
  )}
217
- {/* A REAL FIELD, inset in the panel (2026-09-30, shadcn's palette as
221
+ {/* A REAL FIELD, inset in the panel (2026-09-30, shadcn's search modal as
218
222
  * the aim) — the flush `bare` strip read as a hole, not a control. */}
219
223
  <div className="kol-tone-grey p-2">
220
224
  <SearchInput
@@ -243,8 +247,8 @@ export default function ShellSearchOverlay({
243
247
  * group section heading the row files under — 'Atoms', 'Documentation', 'Tags'
244
248
  * icon optional leading glyph (kol-icons name)
245
249
  * hint subtext shown when the LABEL was not what matched
246
- * href a destination; dismisses the palette
247
- * action a closure; runs and KEEPS the palette open (tag rows)
250
+ * href a destination; dismisses the search modal
251
+ * action a closure; runs and KEEPS the search modal open (tag rows)
248
252
  * Built by `buildShellSearchItems` (showcase/src/nav/shell-nav.js). */}
249
253
  {expanded ? (
250
254
  <div className="border-t border-fg-08 max-h-[70vh] overflow-y-auto">{children}</div>
@@ -289,7 +293,7 @@ export default function ShellSearchOverlay({
289
293
  {(results.length > 0 || enterLabel) && (
290
294
  <p className="flex items-center gap-2 kol-helper-12 text-fg-48 mx-2 px-4 border-x border-transparent">
291
295
  <Kbd icon="corner-down-left" />
292
- {results.length > 0 ? 'Go to page' : enterLabel}
296
+ {results.length > 0 ? selectLabel : enterLabel}
293
297
  </p>
294
298
  )}
295
299
  {/* THE RESULTS LINE IS A LINK (user 2026-10-01: *"make 'all results...' at the bottom
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useState } from 'react'
2
- import ColorSwatch from '../atoms/ColorSwatch.jsx'
2
+ import ColorSwatch from '../molecules/ColorSwatch.jsx'
3
3
  import { resolveCssVar, resolveCssColor, isLight } from '../hooks/cssVar.js'
4
4
  import { Tooltip } from '../utilities/Popover.jsx'
5
5
 
@@ -60,7 +60,7 @@ const ToolPalette = ({
60
60
  return (
61
61
  /* the DS tooltip carries the key as its own chip (the native-title sweep, 2026-09-26) */
62
62
  <Tooltip key={it.id} label={it.label} shortcut={it.shortcut}>
63
- <Button variant="ghost" size={size} quiet iconOnly={it.icon} iconComponent={iconComponent}
63
+ <Button tone="ghost" size={size} quiet iconOnly={it.icon} iconComponent={iconComponent}
64
64
  pressed={isTool ? activeId === it.id : undefined} disabled={it.disabled}
65
65
  aria-label={it.label}
66
66
  onClick={(e) => { (isTool ? onSelect : onAction)?.(it.id); blur(e) }} />
@@ -2,7 +2,7 @@ import { useEffect, useRef } from 'react'
2
2
  import Hls from 'hls.js'
3
3
 
4
4
  /**
5
- * HlsVideo — background/decorative HLS-streaming video atom. A single native
5
+ * BackgroundVideo — A background video with no controls. the background/decorative HLS-streaming video. A single native
6
6
  * <video>: the effect attaches an .m3u8 stream via hls.js (Safari-native HLS
7
7
  * when hls.js isn't supported) and destroys the instance on unmount/src
8
8
  * change. Deliberately inert — pointer-events none plus the full hardening
@@ -11,6 +11,10 @@ import Hls from 'hls.js'
11
11
  * these without explicit approval. All layout/sizing arrives via `className`
12
12
  * (consumer decides `absolute inset-0 object-cover`, etc.).
13
13
  *
14
+ * NAMED FOR WHAT IT IS FOR (user ruling 2026-10-01): it was `HlsVideo`, which says how it streams,
15
+ * and it sat in atoms. It has no UI — nothing to operate — so it is a utility; `HlsVideo` is the
16
+ * alias on the retirement ledger.
17
+ *
14
18
  * @param {string} src HLS manifest URL (.m3u8); attach effect re-runs on change
15
19
  * @param {string} poster native poster frame shown before playback
16
20
  * @param {string} className all layout/sizing/positioning (consumer-supplied)
@@ -19,7 +23,7 @@ import Hls from 'hls.js'
19
23
  * off-stage carousel slide pauses instead of autoplaying beside the
20
24
  * active one (FeaturedCarouselFullWidth, 2026-08-26)
21
25
  */
22
- export default function HlsVideo({ src, poster, className, onEnded, active = true, ...props }) {
26
+ export default function BackgroundVideo({ src, poster, className, onEnded, active = true, ...props }) {
23
27
  const videoRef = useRef(null)
24
28
 
25
29
  useEffect(() => {
@@ -60,7 +60,7 @@ export default class ErrorBoundary extends Component {
60
60
  )}
61
61
 
62
62
  <div className="flex gap-4 justify-center flex-wrap">
63
- <Button variant="primary" onClick={this.handleReset}>Try again</Button>
63
+ <Button tone="primary" onClick={this.handleReset}>Try again</Button>
64
64
  <Button href={this.props.homeHref ?? '/'}>Go home</Button>
65
65
  </div>
66
66
  </div>
@@ -123,7 +123,9 @@ export function usePopover({
123
123
  useClick(data.context, { enabled: click }),
124
124
  useHover(data.context, { enabled: hover, delay: hoverDelay, move: false }),
125
125
  useFocus(data.context, { enabled: focus }),
126
- useDismiss(data.context, { enabled: dismiss }),
126
+ /* `dismiss` may be an object of floating-ui's own options — `{ outsidePress: false }` keeps Esc
127
+ * and drops the outside click (Dropdown `stayOpen`) */
128
+ useDismiss(data.context, typeof dismiss === 'object' && dismiss ? { enabled: true, ...dismiss } : { enabled: dismiss }),
127
129
  useRole(data.context, { role }),
128
130
  ])
129
131
 
@@ -469,7 +469,10 @@ export default function QuadrantSync ({
469
469
  <input
470
470
  id="kq-fade" type="range" min={15} max={100} value={Math.round(fade * 100)}
471
471
  onChange={(e) => setFade(Number(e.target.value) / 100)}
472
- style={{ width: '100%', accentColor: handleColor, display: 'block' }}
472
+ /* Slider's own track, not the browser's range (2026-10-02): a utility cannot import the
473
+ * Slider molecule, so the range wears its class and its `--kol-slider-track` seam */
474
+ className="slider-black w-full block cursor-pointer"
475
+ style={{ '--kol-slider-track': handleColor }}
473
476
  />
474
477
  </div>
475
478