@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-component",
3
- "version": "0.236.0",
3
+ "version": "0.237.0",
4
4
  "description": "The core component library",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -25,8 +25,8 @@
25
25
  "embla-carousel-react": "^8.6.0",
26
26
  "pdfjs-dist": "^6.3.289",
27
27
  "react-syntax-highlighter": "^16.1.1",
28
- "@kolkrabbi/kol-markdown": "^0.1.2",
29
- "@kolkrabbi/kol-search": "^0.2.0"
28
+ "@kolkrabbi/kol-search": "^0.3.0",
29
+ "@kolkrabbi/kol-markdown": "^0.1.2"
30
30
  },
31
31
  "peerDependencies": {
32
32
  "@kolkrabbi/kol-icons": ">=0.22.0",
@@ -37,7 +37,7 @@
37
37
  "react-dom": "^18.3.0 || ^19.0.0"
38
38
  },
39
39
  "devDependencies": {
40
- "@kolkrabbi/kol-icons": "^0.32.0"
40
+ "@kolkrabbi/kol-icons": "^0.33.0"
41
41
  },
42
42
  "files": [
43
43
  "src",
@@ -1,5 +1,5 @@
1
1
  import { isValidElement } from 'react'
2
- import { toneClass } from '../utilities/tone.js'
2
+ import { toneClass, VARIANT_TONE } from '../utilities/tone.js'
3
3
  import { Icon } from '@kolkrabbi/kol-icons'
4
4
  import { glyphSize } from '../hooks/glyphLadders.js'
5
5
 
@@ -12,7 +12,7 @@ import { glyphSize } from '../hooks/glyphLadders.js'
12
12
  *
13
13
  * @param {Object} props
14
14
  * @param {ReactNode} props.children - Button content
15
- * @param {'primary'|'secondary'|'accent'|'outline'|'ghost'|'nav'|'danger'|'grey'|'control'} props.variant - Visual variant. UNSET (the default) inherits the tone of the nearest `kol-tone-*` wrapper, else renders primary; the five ground variants are aliases of `tone`. `danger` is the destructive treatment (--ui-error fill); `nav` is the chrome rung — transparent, oq-64 ink, one step brighter than `ghost`; `control` is an alias for `ghost` (legacy call-sites).
15
+ * @param {'accent'|'danger'|'nav'} props.variant - The intent; the GROUND is `tone`. `primary · secondary · outline · ghost · grey · control` are deprecated aliases of tones (2026-10-01, on the retirement ledger): pass `tone` — `secondary` is `tone="inverted"`, `control` is `tone="ghost"`, the rest keep their name. Visual variant. UNSET (the default) inherits the tone of the nearest `kol-tone-*` wrapper, else renders primary; the five ground variants are aliases of `tone`. `danger` is the destructive treatment (--ui-error fill); `nav` is the chrome rung — transparent, oq-64 ink, one step brighter than `ghost`; `control` is an alias for `ghost` (legacy call-sites).
16
16
  * @param {'xs'|'sm'|'md'|'lg'} props.size - Button size (default: 'md'); xs is the panel rung (ControlsXsRung, 2026-09-01) — kol-mono-8 in a 22px shell (20 icon-only), opt-in by prop
17
17
  * @param {string} props.iconLeft - Icon name to display on the left
18
18
  * @param {string} props.iconRight - Icon name to display on the right
@@ -36,6 +36,8 @@ import { glyphSize } from '../hooks/glyphLadders.js'
36
36
  * @param {ElementType|ReactNode} props.iconComponent - Icon renderer seam — a component that receives `{ name, size, className, style }` in place of the DS Icon (custom icon registries plug in here), or a pre-rendered node dropped in verbatim where the glyph would go. Defaults to DS Icon.
37
37
  * @param {boolean} props.pressed - Toggle state — sets `aria-pressed` (true/false) and `kol-btn-pressed`; a `quiet` button drops its dimming while pressed. Leave undefined for non-toggle buttons.
38
38
  */
39
+ const warned = new Set()
40
+
39
41
  const Button = ({
40
42
  children,
41
43
  variant,
@@ -85,6 +87,12 @@ const Button = ({
85
87
  * `kol-btn-secondary` (the text color as fill) until 2026-09-29, so a misspelt variant
86
88
  * shipped inverted. It now stamps nothing, like an unset variant: the wrapper's tone,
87
89
  * else primary — and says so in dev. */
90
+ /* the six tone-duplicates still render exactly as they did — an alias gains nothing and loses
91
+ * nothing — and say once in dev what replaces them */
92
+ if (import.meta.env.DEV && VARIANT_TONE[variant] && !warned.has(variant)) {
93
+ warned.add(variant)
94
+ console.warn(`Button: variant="${variant}" is deprecated — use tone="${VARIANT_TONE[variant]}"`)
95
+ }
88
96
  const KNOWN = ['primary', 'secondary', 'accent', 'outline', 'ghost', 'nav', 'danger', 'grey']
89
97
  const known = !resolvedVariant || KNOWN.includes(resolvedVariant)
90
98
  if (!known && import.meta.env.DEV) console.warn(`Button: unknown variant "${resolvedVariant}" — rendering the inherited tone (primary)`)
package/src/atoms/Kbd.jsx CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Kbd — A key cap for a shortcut. a key cap: one key or chord (↵, ⌘K, Esc) shown as an affordance.
3
3
  * Replaces the two hand-rolled <kbd> chips (SearchInput's shortcut hint and
4
- * the palette footer) that had drifted apart (2026-09-30). A plate, so it
4
+ * the search modal footer) that had drifted apart (2026-09-30). A plate, so it
5
5
  * takes oq — never fg (icons-use-oq law).
6
6
  *
7
7
  * aria-hidden by default: a cap is a hint beside a label, not the label.
@@ -10,7 +10,9 @@
10
10
  * md = beside helper-12 text (20px)
11
11
  * @param {string} icon kol-icons name drawn before the children — a key
12
12
  * with a symbol (↵ `corner-down-left`, ⌘ `command`)
13
- * is a GLYPH, never a typed character in the mono face
13
+ * is a GLYPH, never a typed character in the mono face.
14
+ * It follows the cap: 10 in `sm`, 12 in `md` (2026-10-02 —
15
+ * it was 12 in both, so in the small ⌘K cap the ⌘ outgrew the K)
14
16
  */
15
17
  import { Icon } from '@kolkrabbi/kol-icons'
16
18
 
@@ -18,6 +20,7 @@ const SIZE = {
18
20
  sm: 'h-4 min-w-4 px-1 kol-helper-10',
19
21
  md: 'h-5 min-w-5 px-1.5 kol-helper-12',
20
22
  }
23
+ const GLYPH = { sm: 10, md: 12 }
21
24
 
22
25
  export default function Kbd({ size = 'md', icon, className = '', children, ...rest }) {
23
26
  return (
@@ -26,7 +29,7 @@ export default function Kbd({ size = 'md', icon, className = '', children, ...re
26
29
  className={`inline-flex items-center justify-center gap-0.5 shrink-0 rounded-[var(--kol-radius-xs)] bg-oq-08 text-oq-64 ${SIZE[size] ?? SIZE.md} ${className}`.trim()}
27
30
  {...rest}
28
31
  >
29
- {icon && <Icon name={icon} size={12} />}
32
+ {icon && <Icon name={icon} size={GLYPH[size] ?? GLYPH.md} />}
30
33
  {children}
31
34
  </kbd>
32
35
  )
package/src/index.js CHANGED
@@ -23,7 +23,7 @@ export { default as Avatar } from './atoms/Avatar.jsx'
23
23
  export { default as Badge } from './atoms/Badge.jsx'
24
24
  export { default as Button } from './atoms/Button.jsx'
25
25
  export { default as CloseButton } from './atoms/CloseButton.jsx'
26
- export { default as ActionButton } from './atoms/ActionButton.jsx'
26
+ export { default as ActionButton } from './molecules/ActionButton.jsx'
27
27
  export { default as FileIcon } from './atoms/FileIcon.jsx'
28
28
  export { default as SizeOrDownload } from './atoms/SizeOrDownload.jsx'
29
29
  export { default as SortHeader } from './atoms/SortHeader.jsx'
@@ -38,21 +38,22 @@ export { default as IconFrame } from './atoms/IconFrame.jsx'
38
38
  export { default as ExitPreview } from './utilities/ExitPreview.jsx'
39
39
  export { default as Figure } from './atoms/Figure.jsx'
40
40
  export { default as FullscreenOverlay } from './utilities/FullscreenOverlay.jsx'
41
- export { default as HlsVideo } from './atoms/HlsVideo.jsx'
42
- /* AudioPlayer — HlsVideo's opposite number: same tier and shape, inverted
43
- * intent (built to be operated, not decorative). See its header before editing
44
- * either one. */
45
- export { default as AudioPlayer } from './atoms/AudioPlayer.jsx'
41
+ /* BackgroundVideo — renamed from HlsVideo and moved to utilities 2026-10-01 (user ruling: no UI,
42
+ * and the name said how it streams, not what it is for). `HlsVideo` is the alias, on the ledger. */
43
+ export { default as BackgroundVideo, default as HlsVideo } from './utilities/BackgroundVideo.jsx'
46
44
  export { default as Input } from './atoms/Input.jsx'
47
45
  export { default as Label } from './atoms/Label.jsx'
48
46
  export { default as LabeledControl } from './molecules/LabeledControl.jsx'
49
47
  export { default as OverlayGlassPanel } from './utilities/OverlayGlassPanel.jsx'
50
48
  export { default as Pill } from './atoms/Pill.jsx'
51
49
  export { usePopover, PopoverPanel, Tooltip } from './utilities/Popover.jsx'
50
+ /* armLongPress — the touch hold the panel value controls report (moved from kol-hardware 2026-10-01) */
51
+ export { armLongPress } from './utilities/armLongPress.js'
52
52
  export { default as ContextMenu, useContextMenu } from './molecules/ContextMenu.jsx'
53
53
  export { default as ProsePreview } from './utilities/ProsePreview.jsx'
54
54
  export { default as QuantityInput } from './molecules/QuantityInput.jsx'
55
- export { default as RotaryDial } from './atoms/RotaryDial.jsx'
55
+ /* Knob — the survivor's name (2026-10-02): `variant` dial · panel. `RotaryDial` is the alias, on the ledger. */
56
+ export { default as Knob, default as RotaryDial } from './molecules/Knob.jsx'
56
57
  export { default as SearchInput } from './molecules/SearchInput.jsx'
57
58
  export { default as InspectorSection, default as Section } from './molecules/InspectorSection.jsx'
58
59
  export { default as SectionText } from './molecules/SectionText.jsx'
@@ -96,8 +97,10 @@ export { default as SectionCardItem, default as CardFeatureItem } from './molecu
96
97
  export { default as CodeBlock } from './molecules/CodeBlock.jsx'
97
98
  export { default as ColorInputRow } from './molecules/ColorInputRow.jsx'
98
99
  export { default as ColorRamp } from './molecules/ColorRamp.jsx'
99
- export { default as ColorSwatch } from './atoms/ColorSwatch.jsx'
100
+ export { default as ColorSwatch } from './molecules/ColorSwatch.jsx'
100
101
  export { default as Dropdown } from './molecules/Dropdown.jsx'
102
+ /* MultiSelect — several pick-one settings in one dropdown, a column each (2026-10-01, first cut) */
103
+ export { default as MultiSelect } from './molecules/MultiSelect.jsx'
101
104
  export { default as FieldRow, StatusChip } from './molecules/FieldRow.jsx'
102
105
  export { default as FramedMediaBand } from './organisms/FramedMediaBand.jsx'
103
106
  export { default as Image } from './atoms/Image.jsx'
@@ -185,10 +188,11 @@ export { default as SectionNewsletter } from './organisms/SectionNewsletter.jsx'
185
188
  export { default as NewsletterBand } from './organisms/NewsletterBand.jsx'
186
189
  export { default as ColumnBrowser } from './organisms/ColumnBrowser.jsx'
187
190
  export { default as KindPreview } from './molecules/KindPreview.jsx'
188
- export { default as AudioPreview, AudioTile, VideoTile, formatLength } from './molecules/AudioPreview.jsx'
189
- export { default as VideoSheet } from './molecules/VideoSheet.jsx'
191
+ /* MediaPlayer — the one player (2026-10-02): `variant` video · audio, `frame` for the Quick Look
192
+ * window. VideoSheet · AudioSheet · AudioPreview · AudioPlayer are its aliases, on the ledger. */
193
+ export { default as MediaPlayer, VideoSheet, AudioSheet, AudioPreview, AudioPlayer } from './molecules/MediaPlayer.jsx'
194
+ export { AudioTile, VideoTile, formatLength } from './molecules/PlayTile.jsx'
190
195
  export { default as PlaybackBar } from './molecules/PlaybackBar.jsx'
191
- export { default as AudioSheet } from './molecules/AudioSheet.jsx'
192
196
  export { default as QuickLookFrame } from './molecules/QuickLookFrame.jsx'
193
197
  export { default as MediaTile } from './molecules/MediaTile.jsx'
194
198
  export { default as RowMenuButton } from './molecules/RowMenuButton.jsx'
@@ -1,6 +1,6 @@
1
1
  import { useState } from 'react'
2
2
  import { Icon } from '@kolkrabbi/kol-icons'
3
- import ColorSwatch from '../atoms/ColorSwatch'
3
+ import ColorSwatch from './ColorSwatch.jsx'
4
4
  import Input from '../atoms/Input'
5
5
  import { usePopover, PopoverPanel, Tooltip } from '../utilities/Popover'
6
6
 
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useState } from 'react'
2
- import ColorSwatch from '../atoms/ColorSwatch.jsx'
2
+ import ColorSwatch from './ColorSwatch.jsx'
3
3
  import { resolveCssVar, isLight } from '../hooks/cssVar.js'
4
4
 
5
5
  /**
@@ -1,4 +1,4 @@
1
- import ActionButton from '../atoms/ActionButton.jsx'
1
+ import ActionButton from './ActionButton.jsx'
2
2
 
3
3
  /**
4
4
  * CopyButton — Copy text to the clipboard. THE copy-to-clipboard control (2026-08-09 user ruling): the
@@ -163,7 +163,9 @@ const Dropdown = ({
163
163
  /* `{ divider: true }` in `options` draws a hairline between groups (FoundationsTones, 2026-09-26 —
164
164
  * user: none on top, the filled tones in order, outline and ghost apart). It is never a choice:
165
165
  * the lookup, the fallback and the trigger's width all read the choices only. */
166
- const choices = options.filter((opt) => !opt?.divider)
166
+ /* `{ heading: 'Variant' }` names the list (2026-10-01 — user: "first item 'variant' then divider
167
+ * then the list"): a row that says what the dropdown sets, never a choice. */
168
+ const choices = options.filter((opt) => !opt?.divider && !opt?.heading)
167
169
  const currentOption = choices.find((opt) => opt.value === value) || choices[0]
168
170
 
169
171
  /* A clamped list (Popover caps the panel to the viewport) can open with the
@@ -172,7 +174,7 @@ const Dropdown = ({
172
174
  const listRef = useRef(null)
173
175
  useEffect(() => {
174
176
  if (!isOpen) return
175
- const idx = options.findIndex((opt) => !opt?.divider && opt.value === currentOption?.value)
177
+ const idx = options.findIndex((opt) => !opt?.divider && !opt?.heading && opt.value === currentOption?.value)
176
178
  listRef.current?.children[idx]?.scrollIntoView({ block: 'nearest' })
177
179
  }, [isOpen]) // eslint-disable-line react-hooks/exhaustive-deps
178
180
 
@@ -253,6 +255,7 @@ const Dropdown = ({
253
255
  <div ref={listRef} className="kol-dd-list" role="listbox">
254
256
  {options.map((option, i) => {
255
257
  if (option?.divider) return <MenuDropdownDivider key={`divider-${i}`} />
258
+ if (option?.heading) return <div key={`heading-${i}`} className={`${resolvedSize === 'xs' ? 'kol-mono-8 px-2 h-5' : 'kol-helper-12 px-3 h-8'} flex shrink-0 items-center text-meta`}>{option.heading}</div>
256
259
  const isActive = option.value === currentOption?.value
257
260
  return (
258
261
  <MenuDropdownItem
@@ -1,6 +1,6 @@
1
1
  import { useEffect, useState } from 'react'
2
- import { AudioTile, VideoTile } from './AudioPreview.jsx'
3
- import HlsVideo from '../atoms/HlsVideo.jsx'
2
+ import { AudioTile, VideoTile } from './PlayTile.jsx'
3
+ import BackgroundVideo from '../utilities/BackgroundVideo.jsx'
4
4
  import CodeBlock from './CodeBlock.jsx'
5
5
  import AssetPlaceholder from '../utilities/AssetPlaceholder.jsx'
6
6
  import FileIcon from '../atoms/FileIcon.jsx'
@@ -16,11 +16,11 @@ import DocPage from './DocPage.jsx'
16
16
  * KindPreview — A preview for any kind of file. a preview for any kind of file: kol-r2b2's `KindPreview.jsx`,
17
17
  * promoted 2026-08-27 (SettingsPanelChromeAndColumnPreview). Before it, anything
18
18
  * that was not an image or a video showed a grey box with the word "text".
19
- * HLS → `HlsVideo` (inert — the DS's background-video atom, preview only),
19
+ * HLS → `BackgroundVideo` (inert — the DS's background-video atom, preview only),
20
20
  * video → `VideoTile` and audio → `AudioTile` (ColumnBrowserMediaFacts
21
21
  * 2026-08-27 — the square column tile with one play/pause control; no Figure, no
22
22
  * border — `VideoBlock`'s Figure border is why kol-r2b2 bypassed it; the overlay
23
- * player is `AudioPreview`), markdown → rendered prose in `.kol-prose`
23
+ * player is `MediaPlayer`), markdown → rendered prose in `.kol-prose`
24
24
  * (markdownToHtml, KindPreviewMarkdown 2026-08-27), json / yaml / text / code →
25
25
  * `CodeBlock` (language by kind or extension) — every document on ONE plate,
26
26
  * `DocPage` (DocPageAndKindShowcase 2026-08-27: the overlay's A-series page, the
@@ -171,10 +171,10 @@ export default function KindPreview({
171
171
 
172
172
  if (kind === 'playlist') {
173
173
  /* the caption is pane chrome — in a tile it is a line of prose over the picture */
174
- if (fit !== 'pane') return <HlsVideo src={url} poster={poster} className="w-full h-full object-cover" />
174
+ if (fit !== 'pane') return <BackgroundVideo src={url} poster={poster} className="w-full h-full object-cover" />
175
175
  return (
176
176
  <div className="flex flex-col items-center gap-2">
177
- <HlsVideo src={url} poster={poster} className="max-w-full max-h-[70vh] rounded" />
177
+ <BackgroundVideo src={url} poster={poster} className="max-w-full max-h-[70vh] rounded" />
178
178
  <span className="kol-mono-12 text-meta">HLS stream · playback is preview-only, no controls</span>
179
179
  </div>
180
180
  )
@@ -1,4 +1,5 @@
1
1
  import { useEffect, useRef, useState } from 'react'
2
+ import KnobPanel from './KnobPanel.jsx'
2
3
 
3
4
  /** Clamp to min–max, snapped to the nearest step from min. */
4
5
  const snapTo = (v, min, max, step) => {
@@ -7,12 +8,17 @@ const snapTo = (v, min, max, step) => {
7
8
  }
8
9
 
9
10
  /**
10
- * RotaryDial — drag-to-set rotary knob numeric input. The KNOB VARIANT of
11
+ * Knob — drag-to-set rotary knob numeric input. The ROTARY TWIN of
11
12
  * Slider (molecules/Slider.jsx): both implement the shared value-control
12
13
  * contract — `value` / `min` / `max` / `step` / `onChange(next: number)` /
13
14
  * `label` / `size` / `disabled` / `formatValue`. Controlled; onChange always
14
15
  * fires with the plain number.
15
16
  *
17
+ * ONE KNOB, TWO VARIANTS (user ruling 2026-10-02: *"knob being the more simple form, rotary dial
18
+ * being more of a variant"*). It was `RotaryDial` with kol-hardware's `Knob` wrapped over it; the
19
+ * name is `Knob` now, `dial` is the ring-and-disc drawing that name used to mean, and `RotaryDial`
20
+ * is the alias on the retirement ledger.
21
+ *
16
22
  * Vertical pointer drag rotates the whole dial over a 270° sweep (−135° to
17
23
  * +135°) mapped onto min–max; a full sweep is ~200px of drag, movement
18
24
  * measured absolute from drag start. A local visual buffer keeps rotation
@@ -30,9 +36,12 @@ const snapTo = (v, min, max, step) => {
30
36
  * @param {number} size dial px size (default 80); derives ring + disc radii
31
37
  * @param {boolean} disabled blocks drag + keyboard and dims the control (default false)
32
38
  * @param {Function} formatValue optional readout formatter (value: number) => string; default `${value}%`
39
+ * @param {'dial'|'panel'} variant `dial` (default) = the ring and disc · `panel` = the rack knob (kol-hardware's, merged 2026-10-01): a cap in the hardware colors;
40
+ * there `size` is `sm · md · lg · xl` (24 · 32 · 40 · 64) and it also takes `labelPlacement`
41
+ * (`column · row · row-left · row-right`), `bipolar`, `labelMinWidth` and `onHold`
33
42
  * @param {number} defaultValue alt-click the dial resets to this (falls back to `min`) — Slider carries the same gesture
34
43
  */
35
- export default function RotaryDial({
44
+ export default function Knob({
36
45
  label,
37
46
  value = 0,
38
47
  onChange,
@@ -43,6 +52,11 @@ export default function RotaryDial({
43
52
  disabled = false,
44
53
  formatValue,
45
54
  defaultValue,
55
+ variant = 'dial',
56
+ labelPlacement,
57
+ bipolar,
58
+ labelMinWidth,
59
+ onHold,
46
60
  }) {
47
61
  const [isDragging, setIsDragging] = useState(false)
48
62
  const [localValue, setLocalValue] = useState(value) // visual buffer — updates every move
@@ -119,6 +133,10 @@ export default function RotaryDial({
119
133
  handlePointerDown(e)
120
134
  }
121
135
 
136
+ if (variant === 'panel') {
137
+ return <KnobPanel value={value} onChange={onChange} min={bipolar && min === 0 ? undefined : min} max={max} label={label} variant={labelPlacement} bipolar={bipolar} labelMinWidth={labelMinWidth} size={typeof size === 'string' ? size : undefined} defaultValue={defaultValue} onHold={onHold} />
138
+ }
139
+
122
140
  const outerRadius = size / 2
123
141
  const innerRadius = (size * 0.7) / 2
124
142
  const strokeWidth = 2
@@ -0,0 +1,116 @@
1
+ import { useCallback } from 'react'
2
+ import { armLongPress } from '../utilities/armLongPress.js'
3
+
4
+ const NO_HOLD = { move() {}, cancel() {} }
5
+
6
+ const SIZES = { sm: 24, md: 32, lg: 40, xl: 64 }
7
+
8
+ /**
9
+ * KnobPanel — Knob's `variant="panel"`: the rack knob. A cap and a pointer line in the
10
+ * hardware cap colors, sized 24 · 32 · 40 · 64 by name, with the four label placements and the
11
+ * bipolar legend. It was kol-hardware's own `Knob` (kol-monitor's rack, lifted 2026-09-01) and is
12
+ * carried here class for class — merged in 2026-10-01 (user ruling). Not exported:
13
+ * reach it through `<Knob variant="panel">`.
14
+ */
15
+ export default function KnobPanel({ value, onChange, min, max, label, variant = 'column', bipolar = false, labelMinWidth, size: sizeProp = 'sm', defaultValue, onHold }) {
16
+ const size = SIZES[sizeProp] || SIZES.sm
17
+ const effMin = min ?? (bipolar ? -100 : 0)
18
+ const effMax = max ?? 100
19
+ const effDefault = defaultValue ?? (bipolar ? 0 : 50)
20
+ const angle = ((value - effMin) / (effMax - effMin)) * 270 - 135
21
+ const r = size / 2
22
+ const ir = r * 0.56
23
+
24
+ const handlePointerDown = useCallback((e) => {
25
+ e.preventDefault()
26
+ // Alt/Option + click resets to default instead of starting a drag
27
+ if (e.altKey) {
28
+ onChange(effDefault)
29
+ return
30
+ }
31
+ const startY = e.clientY
32
+ const startVal = value
33
+ const range = effMax - effMin
34
+
35
+ const id = e.pointerId
36
+ const handleMove = (e) => {
37
+ if (e.pointerId !== id) return // a second finger is a pan, not this knob
38
+ hold.move(e)
39
+ const delta = (startY - e.clientY) * (range / 200)
40
+ const next = Math.round(Math.max(effMin, Math.min(effMax, startVal + delta)))
41
+ onChange(next)
42
+ }
43
+ const handleUp = () => {
44
+ hold.cancel()
45
+ window.removeEventListener('pointermove', handleMove)
46
+ window.removeEventListener('pointerup', handleUp)
47
+ }
48
+ // Touch: hold still 500ms → the holder's sheet instead of the drag
49
+ const hold = onHold ? armLongPress(e, () => { handleUp(); onHold({ label, value, min: effMin, max: effMax, defaultValue: effDefault }) }) : NO_HOLD
50
+ window.addEventListener('pointermove', handleMove)
51
+ window.addEventListener('pointerup', handleUp)
52
+ }, [value, effMin, effMax, effDefault, onChange, onHold, label])
53
+
54
+ const knobSvg = (
55
+ <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
56
+ <circle cx={r} cy={r} r={r * 0.75} style={{ fill: 'var(--kol-ctl-hw-cap)', stroke: 'var(--kol-fg-24)' }} strokeWidth="1" />
57
+ <line
58
+ x1={r} y1={r}
59
+ x2={r + ir * Math.cos((angle - 90) * Math.PI / 180)}
60
+ y2={r + ir * Math.sin((angle - 90) * Math.PI / 180)}
61
+ style={{ stroke: 'var(--kol-ctl-hw-on-cap)' }} strokeWidth="1.5" strokeLinecap="round"
62
+ />
63
+ </svg>
64
+ )
65
+
66
+ if (variant === 'row-left' || variant === 'row') {
67
+ return (
68
+ <div
69
+ onPointerDown={handlePointerDown}
70
+ style={{ cursor: 'ns-resize', touchAction: 'none', display: 'flex', alignItems: 'center', gap: 4 }}
71
+ >
72
+ {label && (
73
+ <span className="kol-helper-8 text-fg-32" style={{ textTransform: 'uppercase', lineHeight: 1 }}>
74
+ {label}
75
+ </span>
76
+ )}
77
+ {knobSvg}
78
+ </div>
79
+ )
80
+ }
81
+
82
+ if (variant === 'row-right') {
83
+ return (
84
+ <div
85
+ onPointerDown={handlePointerDown}
86
+ style={{ cursor: 'ns-resize', touchAction: 'none', display: 'flex', alignItems: 'center', gap: 4 }}
87
+ >
88
+ {knobSvg}
89
+ {label && (
90
+ <span className="kol-helper-8 text-fg-32" style={{ textTransform: 'uppercase', lineHeight: 1, minWidth: labelMinWidth }}>
91
+ {label}
92
+ </span>
93
+ )}
94
+ </div>
95
+ )
96
+ }
97
+
98
+ return (
99
+ <div
100
+ onPointerDown={handlePointerDown}
101
+ style={{ cursor: 'ns-resize', touchAction: 'none', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}
102
+ >
103
+ {bipolar && (
104
+ <span className="kol-helper-8 text-fg-32" style={{ lineHeight: 1 }}>
105
+ -/+
106
+ </span>
107
+ )}
108
+ {knobSvg}
109
+ {label && (
110
+ <span className="kol-helper-8 text-fg-32" style={{ textTransform: 'uppercase', lineHeight: 1 }}>
111
+ {label}
112
+ </span>
113
+ )}
114
+ </div>
115
+ )
116
+ }
@@ -23,6 +23,9 @@
23
23
  * kol-monitor 2026-09-01 — in a narrow multi-column grid a fixed
24
24
  * 160 label left the control 4px, and a nowrap combo painted into
25
25
  * the neighbour column; the 48px column gap had been hiding it).
26
+ * variant — `'panel'` is the hardware panel label (was kol-hardware `PanelLabel`, merged
27
+ * 2026-10-01): `kol-helper-8`, uppercase, hugging the control; it takes
28
+ * `labelPosition` (`top · bottom · left · right`, default bottom), `labelClass` and `gap`.
26
29
  * children — the control body.
27
30
  * className — additional classes on the wrapper.
28
31
  */
@@ -33,7 +36,26 @@ export default function LabeledControl({
33
36
  labelWidth = 48,
34
37
  children,
35
38
  className = '',
39
+ variant,
40
+ labelPosition = 'bottom',
41
+ labelClass = 'kol-helper-8',
42
+ gap = 0,
36
43
  }) {
44
+ if (variant === 'panel') {
45
+ const isRow = labelPosition === 'left' || labelPosition === 'right'
46
+ const labelEl = label && (
47
+ <span className={`${labelClass} text-fg-32`} style={{ textTransform: 'uppercase', lineHeight: 1 }}>
48
+ {label}
49
+ </span>
50
+ )
51
+ return (
52
+ <div style={{ display: 'inline-flex', flexDirection: isRow ? 'row' : 'column', alignItems: 'center', gap }}>
53
+ {(labelPosition === 'top' || labelPosition === 'left') && labelEl}
54
+ {children}
55
+ {(labelPosition === 'bottom' || labelPosition === 'right') && labelEl}
56
+ </div>
57
+ )
58
+ }
37
59
  const showLabel = !!label
38
60
  const labelInner = (
39
61
  <>
@@ -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
+ }
@@ -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>