@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-component",
3
- "version": "0.236.0",
3
+ "version": "0.238.0",
4
4
  "description": "The core component library",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -8,6 +8,7 @@
8
8
  "module": "./src/index.js",
9
9
  "exports": {
10
10
  ".": "./src/index.js",
11
+ "./atoms/RotaryDial": "./src/molecules/Knob.jsx",
11
12
  "./atoms/*": "./src/atoms/*.jsx",
12
13
  "./molecules/*": "./src/molecules/*.jsx",
13
14
  "./organisms/*": "./src/organisms/*.jsx",
@@ -26,7 +27,7 @@
26
27
  "pdfjs-dist": "^6.3.289",
27
28
  "react-syntax-highlighter": "^16.1.1",
28
29
  "@kolkrabbi/kol-markdown": "^0.1.2",
29
- "@kolkrabbi/kol-search": "^0.2.0"
30
+ "@kolkrabbi/kol-search": "^0.3.0"
30
31
  },
31
32
  "peerDependencies": {
32
33
  "@kolkrabbi/kol-icons": ">=0.22.0",
@@ -37,7 +38,7 @@
37
38
  "react-dom": "^18.3.0 || ^19.0.0"
38
39
  },
39
40
  "devDependencies": {
40
- "@kolkrabbi/kol-icons": "^0.32.0"
41
+ "@kolkrabbi/kol-icons": "^0.33.0"
41
42
  },
42
43
  "files": [
43
44
  "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)`)
@@ -159,8 +159,11 @@ export default function Input({
159
159
  * from the font's ascender+descender (font-metric), not strictly from
160
160
  * CSS line-height. Result: kol-control-sm ends up 26.5px instead of 26.
161
161
  * h-4 / h-[18px] / h-[22px] match the kol-mono-12 / -14 / -16 line-heights; on touch every size is
162
- * the 16 / 22 rung (D5, 2026-09-29 — see SearchInput) */
163
- const heightCls = `${size === 'xs' ? 'h-3' : size === 'sm' ? 'h-4' : size === 'md' ? 'h-[18px]' : 'h-[22px]'} pointer-coarse:h-[22px]`
162
+ * the 16 / 22 rung (D5, 2026-09-29 — see SearchInput) — except inside a hardware panel
163
+ * (`.kol-hw-panel`, 2026-10-02): a module's field keeps its desk height on touch, as the rest of
164
+ * the plate does (kol-components-controls.css). The type still goes to 16px there, so iOS does
165
+ * not zoom into it. */
166
+ const heightCls = `${size === 'xs' ? 'h-3' : size === 'sm' ? 'h-4' : size === 'md' ? 'h-[18px]' : 'h-[22px]'} pointer-coarse:not-[.kol-hw-panel_*]:h-[22px]`
164
167
 
165
168
  const inputCls = [
166
169
  'min-w-0 bg-transparent border-none outline-none text-auto',
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
@@ -36,6 +36,19 @@ import { glyphSize, indicatorSize } from '../hooks/glyphLadders.js'
36
36
 
37
37
  /* xs (ControlsXsRung, 2026-09-01): the panel rung, opt-in by prop — the default stays sm (2026-07-28 law) */
38
38
  const SIZE_TYPE = { xs: 'kol-mono-8', sm: 'kol-mono-12', md: 'kol-mono-14', lg: 'kol-mono-16' }
39
+ /* THE LIST IS THE TRIGGER'S, CONTINUED (2026-10-02 — user: *"the list has different padding then the
40
+ * button"*). Two things were off at every size but one. The rows were set in `kol-helper-12`
41
+ * whatever the trigger wore, and the helper is wider — "Newest first" fitted its trigger and was
42
+ * cut off in its own row. And the rows sat 4px in from the trigger's text, because the list pads
43
+ * itself by 4 on top of the row's padding. So: the row wears the trigger's face, and its side
44
+ * padding is the trigger's (8 · 12 · 16 · 20) less the list's 4. */
45
+ const ROW_PAD = { xs: 'px-1', sm: 'px-2', md: 'px-3', lg: 'px-4' }
46
+ /* …AND THE TRIGGER'S HEIGHT (same day — user: *"is it always the same regardless of size?"*). It was:
47
+ * 32px rows at sm · md · lg whatever the trigger stood (26 · 32 · 40), 20 at xs. A row is the
48
+ * trigger's box now, on the control ramp (22 · 26 · 32 · 40 — `--kol-ctl-*`, so the touch rung and
49
+ * a hardware panel move rows and trigger together). */
50
+ const ROW_H = { xs: 'h-[var(--kol-ctl-xs)]', sm: 'h-[var(--kol-ctl-sm)]', md: 'h-[var(--kol-ctl-md)]', lg: 'h-[var(--kol-ctl-lg)]' }
51
+ const ROW = { xs: `kol-mono-8 ${ROW_PAD.xs} ${ROW_H.xs}`, sm: `kol-mono-12 ${ROW_PAD.sm} ${ROW_H.sm}`, md: `kol-mono-14 ${ROW_PAD.md} ${ROW_H.md}`, lg: `kol-mono-16 ${ROW_PAD.lg} ${ROW_H.lg}` }
39
52
  /* Caret size comes from the INDICATOR ladder (glyphLadders.js) — the private
40
53
  * map that lived here was a transcription of ADJACENT, which is the wrong
41
54
  * ladder for a decoration: it put a caret one rung HEAVIER than the label
@@ -95,6 +108,11 @@ const Dropdown = ({
95
108
  * has one implementation and the drawing stays the caller's. */
96
109
  triggerAdornment,
97
110
  defaultOpen = false,
111
+ /* `stayOpen` — a click outside does not close the list (the trigger and Esc still do). For a
112
+ * surface where the open list is the thing being looked at while other controls are worked:
113
+ * the showcase's preview, with its toolbar pickers beside it (user 2026-10-02: *"can we
114
+ * disclude the preview from that behaviour?"*). */
115
+ stayOpen = false,
98
116
  className = ''
99
117
  }) => {
100
118
  const [isOpen, setIsOpen] = useState(defaultOpen)
@@ -107,12 +125,26 @@ const Dropdown = ({
107
125
  * ambient case is the one only this can serve. */
108
126
  const triggerRef = useRef(null)
109
127
  const [ambient, setAmbient] = useState(null)
128
+ /* …AND SO IS A ZOOM (2026-10-02, kol-monitor's rack — user: *"what about the dropdown in general?
129
+ * its kinda wrong no"*). A trigger inside a zoomed surface (the rack at 80%) is drawn smaller,
130
+ * but the portalled list was not: it kept its full-size type inside a panel pinned to the
131
+ * trigger's SHRUNKEN width, so the names were cut off — 18 of monitor's 40 presets at 80%, all
132
+ * of them at 50%. The trigger's scale is its drawn width over its layout width; the list's
133
+ * content takes the same zoom, so trigger and list stay one piece at any scale. */
134
+ const [scale, setScale] = useState(1)
135
+ const [inPanel, setInPanel] = useState(false)
110
136
  useLayoutEffect(() => {
111
137
  if (!isOpen || !triggerRef.current) return
112
138
  const cs = getComputedStyle(triggerRef.current)
113
139
  const vars = {}
114
140
  for (const v of TONE_VARS) { const val = cs.getPropertyValue(v).trim(); if (val) vars[v] = val }
115
141
  setAmbient(vars)
142
+ const laid = triggerRef.current.offsetWidth
143
+ const s = laid ? triggerRef.current.getBoundingClientRect().width / laid : 1
144
+ setScale(Math.abs(s - 1) > 0.01 ? s : 1)
145
+ /* and the panel it opened from: a hardware plate keeps its desk sizes on touch, and the
146
+ * portalled list would otherwise take the touch rung its trigger skipped */
147
+ setInPanel(!!triggerRef.current.closest('.kol-hw-panel'))
116
148
  }, [isOpen])
117
149
 
118
150
  // sm everywhere unless explicitly overridden (see docblock size law).
@@ -132,6 +164,7 @@ const Dropdown = ({
132
164
  flip: false,
133
165
  matchReferenceWidth: true,
134
166
  role: 'listbox',
167
+ dismiss: stayOpen ? { outsidePress: false } : true,
135
168
  })
136
169
 
137
170
  /* Width belongs to the CALL SITE (2026-08-09 user call — "width without any
@@ -163,7 +196,9 @@ const Dropdown = ({
163
196
  /* `{ divider: true }` in `options` draws a hairline between groups (FoundationsTones, 2026-09-26 —
164
197
  * user: none on top, the filled tones in order, outline and ghost apart). It is never a choice:
165
198
  * the lookup, the fallback and the trigger's width all read the choices only. */
166
- const choices = options.filter((opt) => !opt?.divider)
199
+ /* `{ heading: 'Variant' }` names the list (2026-10-01 — user: "first item 'variant' then divider
200
+ * then the list"): a row that says what the dropdown sets, never a choice. */
201
+ const choices = options.filter((opt) => !opt?.divider && !opt?.heading)
167
202
  const currentOption = choices.find((opt) => opt.value === value) || choices[0]
168
203
 
169
204
  /* A clamped list (Popover caps the panel to the viewport) can open with the
@@ -172,7 +207,7 @@ const Dropdown = ({
172
207
  const listRef = useRef(null)
173
208
  useEffect(() => {
174
209
  if (!isOpen) return
175
- const idx = options.findIndex((opt) => !opt?.divider && opt.value === currentOption?.value)
210
+ const idx = options.findIndex((opt) => !opt?.divider && !opt?.heading && opt.value === currentOption?.value)
176
211
  listRef.current?.children[idx]?.scrollIntoView({ block: 'nearest' })
177
212
  }, [isOpen]) // eslint-disable-line react-hooks/exhaustive-deps
178
213
 
@@ -239,20 +274,25 @@ const Dropdown = ({
239
274
  popover={popover}
240
275
  panel={false}
241
276
  focus={false}
242
- className={`kol-dd-panel ${resolvedVariant ? `kol-dd-panel--${resolvedVariant}` : ''} ${toneClass(tone)}`.replace(/\s+/g, ' ').trim()}
277
+ className={`kol-dd-panel ${resolvedVariant ? `kol-dd-panel--${resolvedVariant}` : ''} ${toneClass(tone)} ${inPanel ? 'kol-hw-panel' : ''}`.replace(/\s+/g, ' ').trim()}
243
278
  style={{
244
279
  ...ambient,
245
280
  '--kol-dd-max-rows': maxRows ?? 10,
246
- /* xs rows are 20px; the panel's max-height reads the row height */
247
- ...(resolvedSize === 'xs' && rowHeight == null ? { '--kol-dd-row-h': '20px' } : null),
281
+ /* a row is the trigger's height; the panel's max-height reads it */
282
+ ...(rowHeight == null ? { '--kol-dd-row-h': `var(--kol-ctl-${resolvedSize})` } : null),
248
283
  ...(rowHeight != null ? { '--kol-dd-row-h': typeof rowHeight === 'number' ? `${rowHeight}px` : rowHeight } : null),
249
284
  }}
250
285
  >
251
- {(resolvedVariant === 'primary' || resolvedVariant === 'grey') && <div className="kol-dd-div" />}
286
+ {/* THE HAIRLINE UNDER THE TRIGGER, ALWAYS (2026-10-02 — user: *"I see it sometimes and sometimes
287
+ * not"*). It was drawn only for `variant="primary" | "grey"`: a dropdown toned by `tone=`
288
+ * got none, and an outline one lost its divider too — the panel sits 1px over the trigger's
289
+ * bottom border, which was meant to be it. One rule: an open dropdown has the line. */}
290
+ <div className="kol-dd-div" />
252
291
 
253
- <div ref={listRef} className="kol-dd-list" role="listbox">
292
+ <div ref={listRef} className="kol-dd-list" role="listbox" style={scale !== 1 ? { zoom: scale } : undefined}>
254
293
  {options.map((option, i) => {
255
294
  if (option?.divider) return <MenuDropdownDivider key={`divider-${i}`} />
295
+ if (option?.heading) return <div key={`heading-${i}`} className={`${resolvedSize === 'xs' ? 'kol-mono-8' : 'kol-helper-12'} ${ROW_H[resolvedSize] ?? ROW_H.sm} ${ROW_PAD[resolvedSize] ?? ROW_PAD.sm} flex shrink-0 items-center text-meta`}>{option.heading}</div>
256
296
  const isActive = option.value === currentOption?.value
257
297
  return (
258
298
  <MenuDropdownItem
@@ -265,6 +305,7 @@ const Dropdown = ({
265
305
  * brighten. The check mark is what marks the current value. */
266
306
  hover={false}
267
307
  size={resolvedSize}
308
+ rowClass={ROW[resolvedSize] ?? ROW.sm}
268
309
  height={rowHeight}
269
310
  onPointerEnter={onOptionHover ? () => reportHover(option.value) : undefined}
270
311
  onPointerLeave={onOptionHover ? () => reportHover(null) : undefined}
@@ -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
  <>