@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
@@ -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
@@ -96,8 +96,8 @@ export default function ShellSearchOverlay({
96
96
  onClose,
97
97
  results: rawResults = [],
98
98
  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
99
+ /* EXPANDED — the search modal's second state (user ruling 2026-08-01). Enter
100
+ * commits the query and opens `children` as the results body; the search modal
101
101
  * and the old tag overlay are one surface with two states, not two
102
102
  * components. `chips` are the committed tag facets of the same query. */
103
103
  expanded = false,
@@ -176,7 +176,7 @@ export default function ShellSearchOverlay({
176
176
  if (active >= 0) select(results[active])
177
177
  else onExpand?.()
178
178
  } else if (e.key === 'Tab') {
179
- /* Focus trap — the input is the palette's only tab stop. */
179
+ /* Focus trap — the input is the search modal's only tab stop. */
180
180
  e.preventDefault()
181
181
  }
182
182
  }
@@ -214,7 +214,7 @@ export default function ShellSearchOverlay({
214
214
  ))}
215
215
  </div>
216
216
  )}
217
- {/* A REAL FIELD, inset in the panel (2026-09-30, shadcn's palette as
217
+ {/* A REAL FIELD, inset in the panel (2026-09-30, shadcn's search modal as
218
218
  * the aim) — the flush `bare` strip read as a hole, not a control. */}
219
219
  <div className="kol-tone-grey p-2">
220
220
  <SearchInput
@@ -243,8 +243,8 @@ export default function ShellSearchOverlay({
243
243
  * group section heading the row files under — 'Atoms', 'Documentation', 'Tags'
244
244
  * icon optional leading glyph (kol-icons name)
245
245
  * 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)
246
+ * href a destination; dismisses the search modal
247
+ * action a closure; runs and KEEPS the search modal open (tag rows)
248
248
  * Built by `buildShellSearchItems` (showcase/src/nav/shell-nav.js). */}
249
249
  {expanded ? (
250
250
  <div className="border-t border-fg-08 max-h-[70vh] overflow-y-auto">{children}</div>
@@ -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>
@@ -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
 
@@ -0,0 +1,18 @@
1
+ // armLongPress — the touch hold a value control reports (Slider/Knob `variant="panel"`; kol-monitor 2026-09-01,
2
+ // moved here from kol-hardware 2026-10-01 with the controls that arm it).
3
+
4
+ const HOLD_MS = 500
5
+ const SLOP_PX = 6
6
+
7
+ /* Arm a long-press on a touch pointerdown. Returns `{ move, cancel }`: feed
8
+ * pointermoves to `move` (past SLOP it disarms — that is a drag, not a hold),
9
+ * call `cancel` on pointerup. `fire` runs once when the hold lands. A mouse
10
+ * never arms — desktop keeps drag + ⌥-click and nothing else. */
11
+ export function armLongPress(e, fire) {
12
+ if (e.pointerType !== 'touch') return { move: () => {}, cancel: () => {} }
13
+ const x = e.clientX, y = e.clientY
14
+ let t = setTimeout(() => { t = null; fire() }, HOLD_MS)
15
+ const cancel = () => { if (t) { clearTimeout(t); t = null } }
16
+ const move = (ev) => { if (t && Math.hypot(ev.clientX - x, ev.clientY - y) > SLOP_PX) cancel() }
17
+ return { move, cancel }
18
+ }
@@ -2,7 +2,7 @@
2
2
  * mediaSearch — the media library's search, on the KOL engine (`@kolkrabbi/kol-search`).
3
3
  *
4
4
  * Four places in the media pages each ran their own `key.toLowerCase().includes(q)` — the browse
5
- * filter, the ⌘K palette, the smart-folder text match, the picker's filter (apps review,
5
+ * filter, the ⌘K search modal, the smart-folder text match, the picker's filter (apps review,
6
6
  * 2026-09-29). One helper now, so the media search speaks the same language as every other KOL
7
7
  * search: several words must ALL match, `-word` must not, `"a phrase"`, `tag:x` / `#x`,
8
8
  * `kind:image` / `is:video`, `after:2026-09-01`. A plain substring still finds what it found.
@@ -24,7 +24,7 @@ const toItem = (o, nameOf) => ({
24
24
  o,
25
25
  })
26
26
 
27
- /** ranked hits, best first — the palette's order. `nameOf(o)` overrides the title (a display key). */
27
+ /** ranked hits, best first — the search modal's order. `nameOf(o)` overrides the title (a display key). */
28
28
  export function rankMedia(objects, query, { nameOf, limit } = {}) {
29
29
  const q = String(query ?? '').trim()
30
30
  if (!q) return limit ? objects.slice(0, limit) : objects
@@ -26,6 +26,12 @@ export const TONES = ['primary', 'secondary', 'inverted', 'outline', 'ghost', 'g
26
26
  export const toneClass = (tone) =>
27
27
  tone === 'inverse' ? 'kol-tone-sunken' : TONES.includes(tone) ? `kol-tone-${tone}` : ''
28
28
 
29
+ /* THE SIX VARIANTS THAT WERE TONES (user ruling 2026-10-01). Button's `variant` named five grounds
30
+ * and a legacy alias of one; each is a tone under a second name — the theme's variant classes are
31
+ * aliases of the tone bundles. `variant` keeps `accent · danger · nav`; these six are deprecated
32
+ * aliases on the retirement ledger. `secondary` is the misleading one: it paints `inverted`. */
33
+ export const VARIANT_TONE = { primary: 'primary', secondary: 'inverted', outline: 'outline', ghost: 'ghost', grey: 'grey', control: 'ghost' }
34
+
29
35
  /* the properties a bundle sets — Dropdown copies them from its trigger onto
30
36
  * the PORTALLED panel, which the cascade cannot reach */
31
37
  export const TONE_VARS = [
@@ -1,51 +0,0 @@
1
- /**
2
- * AudioPlayer — A native audio player with a label. the interactive audio atom: one native <audio> with the UA's
3
- * own control strip, and an optional label line above it.
4
- *
5
- * WHY IT EXISTS. Audio was the one media kind the design system had nothing for,
6
- * so every consumer hand-rolled a bare `<audio controls>` and inherited whatever
7
- * the browser painted. The MediaLibrary widening (kol-component 0.39.0) made
8
- * audio objects arrive — 116 sound files in `kol-vault-media` alone — and left
9
- * minting this as the open taxonomy call; this is that call made: an ATOM,
10
- * beside HlsVideo.
11
- *
12
- * THE CONTRAST WITH HlsVideo, which is the thing to read before touching either.
13
- * Same tier, same shape — one native media element, all layout via `className`,
14
- * no composition — and **inverted intent**. HlsVideo is deliberately inert:
15
- * `pointer-events: none`, `controls={false}`, plus the hardening set (no PiP, no
16
- * download, no fullscreen, no remote playback, no context menu). It is
17
- * decorative. This atom exists **to be operated**, so none of that hardening
18
- * crosses over, and `controls` is not a prop — an audio player without controls
19
- * is not a variant, it is a different component.
20
- *
21
- * The native strip is UA-painted and not themeable past `color-scheme`. Do not
22
- * reach for pseudo-element hacks; a branded transport would be a separate
23
- * `AudioTransport` molecule built on this atom.
24
- *
25
- * Layout is the call site's: the padding, the centring and the width that lived
26
- * in the consumer source are all call-site concerns and none of them are baked
27
- * in here.
28
- *
29
- * @param {string} src audio URL; renders nothing when absent (the guard
30
- * style the other media atoms use)
31
- * @param {string} [label] line above the player; the element is omitted entirely
32
- * when absent. Authored casing — no text-transform
33
- * @param {'none'|'metadata'|'auto'} [preload='metadata'] native preload hint
34
- * @param {string} [className] all layout/sizing (consumer-supplied)
35
- */
36
- export default function AudioPlayer({
37
- src,
38
- label,
39
- preload = 'metadata',
40
- className = '',
41
- ...props
42
- }) {
43
- if (!src) return null
44
-
45
- return (
46
- <figure className={`kol-audio-player flex flex-col gap-3 ${className}`.trim()}>
47
- {label && <figcaption className="kol-mono-12 text-fg-48">{label}</figcaption>}
48
- <audio src={src} preload={preload} controls className="w-full" {...props} />
49
- </figure>
50
- )
51
- }
@@ -1,140 +0,0 @@
1
- /* eslint-disable react-hooks/refs -- floating-ui's `refs.setReference` is a callback ref, not a ref read; the DS Tooltip wires it the same way */
2
- import { useEffect, useRef, useState } from 'react';
3
- import IconFrame from '../atoms/IconFrame.jsx';
4
- import Slider from './Slider.jsx';
5
- import { usePopover, PopoverPanel } from '../utilities/Popover.jsx';
6
- import { readCover } from '../utilities/id3.js';
7
-
8
- /* taxonomy-ok: molecule — nests IconFrame (atom) + Slider / PopoverPanel (relative). */
9
-
10
- /**
11
- * AudioPreview · AudioTile · VideoTile — kol-r2b2's players, promoted verbatim
12
- * (ColumnBrowserMediaFacts 2026-08-27; the tiles re-ruled the same day,
13
- * PlayDiscAndVideoBar): players composed from DS parts (IconFrame · Slider ·
14
- * Popover) — the DS AudioPlayer is a native <audio controls>, whose one-row
15
- * layout can't be reshaped. Finder model (user ruling 2026-08-27): the COLUMN
16
- * gets a square tile over full-bleed artwork with ONE control — the Finder
17
- * play/pause disc (`.kol-play-disc`: round, hidden at rest, shown on hover of
18
- * `.kol-media-tile`) — `AudioTile` (artwork = the file's embedded ID3 cover,
19
- * `readCover`) / `VideoTile` (artwork = the poster; the media element is hidden
20
- * — the tile never shows a decoded video frame). Timeline + volume belong to
21
- * the OVERLAY / Quick Look — `AudioPreview` (play/pause · Slider seek with an
22
- * m:ss readout · volume behind `slider-01`, a vertical `slider-black` range in
23
- * a PopoverPanel, placement top; no title line — the name sits in the facts)
24
- * and `VideoSheet` (the QuickTime bar).
25
- *
26
- * @param {string} src the media URL
27
- * @param {string} poster VideoTile — the poster URL
28
- * @param {Function} onDuration AudioPreview — (seconds) => void once metadata lands
29
- * @param {string} className AudioPreview — wrapper classes (the width is the consumer's)
30
- * @param {ReactNode} fallback AudioTile — shown when the file carries no cover art (2026-09-23:
31
- * it was an empty square, so a WAV read as a missing file)
32
- */
33
- export const formatLength = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`;
34
- const fmt = formatLength;
35
-
36
- function useCover(src) {
37
- const [cover, setCover] = useState(null);
38
- useEffect(() => {
39
- let live = true;
40
- readCover(src).then((url) => { if (live) setCover(url); });
41
- return () => { live = false; };
42
- }, [src]);
43
- return cover;
44
- }
45
-
46
- /* One disc for both tiles — the ruled Finder play/pause. The theme's
47
- * `.kol-play-disc` paints it after the IconFrame variants, so nothing is restated here. */
48
- function PlayDisc({ playing, onClick }) {
49
- return (
50
- <IconFrame name={playing ? 'pause' : 'play'} variant="secondary" size="lg" radius="full" onClick={onClick} aria-label={playing ? 'Pause' : 'Play'} className="relative kol-play-disc" />
51
- );
52
- }
53
-
54
- export function AudioTile({ src, fallback = null }) {
55
- const ref = useRef(null);
56
- const [playing, setPlaying] = useState(false);
57
- const cover = useCover(src);
58
- return (
59
- <div className="kol-media-tile relative w-full aspect-square rounded overflow-hidden flex items-center justify-center">
60
- <audio ref={ref} src={src} preload="metadata" onPlay={() => setPlaying(true)} onPause={() => setPlaying(false)} onEnded={() => setPlaying(false)} />
61
- {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>}
62
- <PlayDisc playing={playing} onClick={() => (playing ? ref.current.pause() : ref.current.play())} />
63
- </div>
64
- );
65
- }
66
-
67
- export function VideoTile({ src, poster }) {
68
- const ref = useRef(null);
69
- const [playing, setPlaying] = useState(false);
70
- return (
71
- <div className="kol-media-tile relative w-full aspect-square rounded overflow-hidden flex items-center justify-center">
72
- {/* Same as audio: the media element is hidden, the artwork (the poster) is the tile. */}
73
- <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)} />
74
- {poster && <img src={poster} alt="" className="absolute inset-0 w-full h-full object-cover" />}
75
- <PlayDisc playing={playing} onClick={() => (playing ? ref.current.pause() : ref.current.play())} />
76
- </div>
77
- );
78
- }
79
-
80
- function useAudio(onDuration) {
81
- const ref = useRef(null);
82
- const [playing, setPlaying] = useState(false);
83
- const [time, setTime] = useState(0);
84
- const [duration, setDuration] = useState(0);
85
- const toggle = () => (playing ? ref.current.pause() : ref.current.play());
86
- const element = (src) => (
87
- <audio
88
- ref={ref}
89
- src={src}
90
- preload="metadata"
91
- onPlay={() => setPlaying(true)}
92
- onPause={() => setPlaying(false)}
93
- onEnded={() => setPlaying(false)}
94
- onTimeUpdate={(e) => setTime(e.target.currentTime)}
95
- onLoadedMetadata={(e) => { setDuration(e.target.duration); onDuration?.(e.target.duration); }}
96
- />
97
- );
98
- return { ref, playing, time, duration, toggle, element, setTime };
99
- }
100
-
101
- export default function AudioPreview({ src, className = '', onDuration }) {
102
- const { ref, playing, time, duration, toggle, element, setTime } = useAudio(onDuration);
103
- const [volume, setVolume] = useState(100);
104
- const [open, setOpen] = useState(false);
105
- const pop = usePopover({ open, onOpenChange: setOpen, placement: 'top' });
106
-
107
- return (
108
- <div className={`flex items-center gap-2 ${className}`}>
109
- {element(src)}
110
- <IconFrame name={playing ? 'pause' : 'play'} variant="ghost" size="sm" onClick={toggle} />
111
- <Slider
112
- className="flex-1"
113
- min={0}
114
- max={duration || 0}
115
- step={0.1}
116
- value={time}
117
- onChange={(v) => { ref.current.currentTime = v; setTime(v); }}
118
- formatValue={fmt}
119
- displayWidth={5}
120
- />
121
- <span ref={pop.refs.setReference} {...pop.getReferenceProps()} className="inline-flex">
122
- <IconFrame name="slider-01" variant={open ? 'secondary' : 'ghost'} size="sm" onClick={() => {}} />
123
- </span>
124
- <PopoverPanel popover={pop} className="p-2">
125
- {/* DS track class on a native range, turned upright: the one thing the DS Slider can't do. */}
126
- <div className="w-6 h-24 flex items-center justify-center">
127
- <input
128
- type="range"
129
- min={0}
130
- max={100}
131
- value={volume}
132
- onChange={(e) => { const v = Number(e.target.value); ref.current.volume = v / 100; setVolume(v); }}
133
- className="slider-black cursor-pointer w-24 -rotate-90"
134
- aria-label="Volume"
135
- />
136
- </div>
137
- </PopoverPanel>
138
- </div>
139
- );
140
- }