@kolkrabbi/kol-component 0.244.0 → 0.246.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-component",
3
- "version": "0.244.0",
3
+ "version": "0.246.0",
4
4
  "description": "The core component library",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -26,8 +26,8 @@
26
26
  "embla-carousel-react": "^8.6.0",
27
27
  "pdfjs-dist": "^6.3.289",
28
28
  "react-syntax-highlighter": "^16.1.1",
29
- "@kolkrabbi/kol-search": "^0.3.0",
30
- "@kolkrabbi/kol-markdown": "^0.1.3"
29
+ "@kolkrabbi/kol-markdown": "^0.1.3",
30
+ "@kolkrabbi/kol-search": "^0.3.0"
31
31
  },
32
32
  "peerDependencies": {
33
33
  "@kolkrabbi/kol-icons": ">=0.22.0",
@@ -29,8 +29,12 @@
29
29
  * strip (canvas alignment, transform cluster); onChange is the
30
30
  * action dispatch.
31
31
  * onChange — handler (newValue) => void
32
- * options — [{ value, label, ariaLabel?, tooltip? }] — a cell whose label is not a string
33
- * (a glyph) shows its `ariaLabel` as a KOL Tooltip; `tooltip` overrides it
32
+ * options — [{ value, label, ariaLabel?, tooltip?, disabled? }] — a cell whose label is not a string
33
+ * (a glyph) shows its `ariaLabel` as a KOL Tooltip; `tooltip` overrides it.
34
+ * `disabled` (kol-fxr 2026-10-09): the cell draws the family's disabled ink
35
+ * (`aria-disabled`, not the attribute — the tooltip still gives the reason),
36
+ * refuses the press, and ←/→ step over it. It may still be the current `value`
37
+ * (the ground changed under it): selected + disabled, no throw.
34
38
  * variant — 'default' (shipped chrome: shared outer stroke + dividers,
35
39
  * filled active cell) | 'filled' (the state law's tiles: every
36
40
  * cell a surface-secondary tile with 1px transparent gaps, NO
@@ -66,7 +70,12 @@ export default function SegmentedToggle({ value, onChange, options = [], variant
66
70
  const dir = { ArrowLeft: -1, ArrowUp: -1, ArrowRight: 1, ArrowDown: 1 }[e.key]
67
71
  if (!dir || !options.length) return
68
72
  e.preventDefault()
69
- const next = (focusIdx + dir + options.length) % options.length
73
+ let next = focusIdx
74
+ for (let step = 0; step < options.length; step++) {
75
+ next = (next + dir + options.length) % options.length
76
+ if (!options[next].disabled) break
77
+ }
78
+ if (options[next].disabled) return
70
79
  onChange?.(options[next].value)
71
80
  e.currentTarget.children[next]?.focus()
72
81
  }
@@ -94,8 +103,9 @@ export default function SegmentedToggle({ value, onChange, options = [], variant
94
103
  role={stateless ? undefined : 'radio'}
95
104
  aria-checked={stateless ? undefined : isActive}
96
105
  aria-label={opt.ariaLabel}
106
+ aria-disabled={opt.disabled || undefined}
97
107
  tabIndex={stateless ? 0 : (i === focusIdx ? 0 : -1)}
98
- onClick={() => onChange?.(opt.value)}
108
+ onClick={opt.disabled ? undefined : () => onChange?.(opt.value)}
99
109
  className={['kol-seg-cell', cellType, isActive && 'is-active'].filter(Boolean).join(' ')}
100
110
  >
101
111
  {opt.label}
@@ -0,0 +1,51 @@
1
+ import { useRef, useState } from 'react'
2
+
3
+ /**
4
+ * usePointerSort — a vertical reorder by pointer, the ~50 lines `RecordManager` carried inline
5
+ * (2026-08-09, "util before any dependency"), lifted so `StepList` is not a second copy (kol-fxr
6
+ * StepList, 2026-10-09). Pointer events, so a finger works where HTML drag does not.
7
+ *
8
+ * const { drag, startDrag } = usePointerSort(onReorder)
9
+ * <button onPointerDown={(e) => startDrag(e, i, rowEls)} className="touch-none">⠿</button>
10
+ *
11
+ * `startDrag(e, from, rowEls)`: `rowEls` is the list of row elements in order (a NodeList or an
12
+ * array); their rects are measured once at the start. While the pointer is down `drag` is
13
+ * `{ from, over, x, y }` — `over` is the row under the pointer, `x`/`y` the pointer, for a
14
+ * floating label — and null otherwise. On release `onReorder(from, over)` fires when they differ.
15
+ * Without `onReorder` a start is a no-op, so a handle can render conditionally on it.
16
+ */
17
+ export default function usePointerSort(onReorder) {
18
+ const dragRef = useRef(null)
19
+ const [drag, setDrag] = useState(null)
20
+
21
+ const startDrag = (e, from, rowEls) => {
22
+ if (!onReorder) return
23
+ e.preventDefault()
24
+ if (!rowEls?.length) return
25
+ const rects = [...rowEls].map((el) => el.getBoundingClientRect())
26
+ const move = (ev) => {
27
+ const hit = rects.findIndex((r) => ev.clientY < r.bottom)
28
+ const over = hit === -1 ? rects.length - 1 : hit
29
+ dragRef.current = { from, over, x: ev.clientX, y: ev.clientY }
30
+ setDrag(dragRef.current)
31
+ }
32
+ const up = () => {
33
+ window.removeEventListener('pointermove', move)
34
+ window.removeEventListener('pointerup', up)
35
+ document.body.style.cursor = ''
36
+ document.body.style.userSelect = ''
37
+ const d = dragRef.current
38
+ dragRef.current = null
39
+ setDrag(null)
40
+ if (d && d.over !== d.from) onReorder(d.from, d.over)
41
+ }
42
+ window.addEventListener('pointermove', move)
43
+ window.addEventListener('pointerup', up)
44
+ document.body.style.cursor = 'grabbing'
45
+ document.body.style.userSelect = 'none'
46
+ dragRef.current = { from, over: from, x: e.clientX, y: e.clientY }
47
+ setDrag(dragRef.current)
48
+ }
49
+
50
+ return { drag, startDrag }
51
+ }
package/src/index.js CHANGED
@@ -38,6 +38,9 @@ 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
+ /* the overlay layer stack — which open layer owns Escape; exported so a layer in another package
42
+ * (kol-shell's walkthrough) joins the same stack instead of a second one (2026-10-09) */
43
+ export { pushLayer, popLayer, isTopLayer } from './utilities/layerStack.js'
41
44
  /* BackgroundVideo — renamed from HlsVideo and moved to utilities 2026-10-01 (user ruling: no UI,
42
45
  * and the name said how it streams, not what it is for). `HlsVideo` is the alias, on the ledger. */
43
46
  export { default as BackgroundVideo, default as HlsVideo } from './utilities/BackgroundVideo.jsx'
@@ -79,6 +82,7 @@ export { default as InspectorRail } from './molecules/InspectorRail.jsx'
79
82
  export { default as PathNodeOverlay } from './utilities/PathNodeOverlay.jsx'
80
83
  export { default as CropOverlay } from './utilities/CropOverlay.jsx'
81
84
  export { default as LayerStack, AddLayerButton, BLEND_MODES } from './organisms/LayerStack.jsx'
85
+ export { default as StepList } from './molecules/StepList.jsx'
82
86
  export { TYPE_LABELS, BOOL_OP_LABELS, SHAPE_KIND_LABELS, labelForLayer, rowLabelForLayer, findLayerDeep } from './hooks/layerTree.js'
83
87
  export { default as TimelineDock, sampleTrack, TIMELINE_EASINGS } from './organisms/TimelineDock.jsx'
84
88
  export { default as CurveEditor, CURVE_KINDS, defaultCurveFor } from './organisms/CurveEditor.jsx'
@@ -107,7 +107,7 @@ export default function SectionText({
107
107
  ? <p className={cls(bodyClass, 'body')} style={slotStyle.body}>{body}</p>
108
108
  : <div className={cls(bodyClass, 'body')} style={slotStyle.body}>{body}</div>)
109
109
  const cluster = actions && (
110
- <div className={cls(`${actionsClass} shrink-0 h-0 self-center`, 'actions')} style={slotStyle.actions}>{actions}</div>
110
+ <div className={cls(`${actionsClass} shrink-0 md:h-0 md:self-center`, 'actions')} style={slotStyle.actions}>{actions}</div>
111
111
  )
112
112
  return (
113
113
  <div className={`kol-section-text flex flex-col ${gap} ${alignCls} ${className}`.replace(/\s+/g, ' ').trim()} style={style}>
@@ -133,7 +133,10 @@ export default function SectionText({
133
133
  * have been space between … on the right edge"). The column is `items-start`, so this
134
134
  * row shrank to its content and `justify-between` had no room to act: the cluster sat
135
135
  * against the subtitle on every PageHeader with actions. */
136
- <div className="flex self-stretch items-baseline justify-between gap-6">
136
+ /* BELOW `md` THE CLUSTER DROPS UNDER THE BODY (kol-fxr 2026-10-09, HubSettingsHeaderAt390:
137
+ * at 390 the cluster kept its natural width and left the lede ~110px, three lines). It
138
+ * takes its own row there, with its real height; the zero-height trick is a desk rule. */
139
+ <div className="flex flex-col md:flex-row self-stretch md:items-baseline md:justify-between gap-3 md:gap-6">
137
140
  {bodyNode}
138
141
  {cluster}
139
142
  </div>
@@ -0,0 +1,109 @@
1
+ import { useRef } from 'react'
2
+ import Button from '../atoms/Button.jsx'
3
+ import { Icon } from '@kolkrabbi/kol-icons'
4
+ import usePointerSort from '../hooks/usePointerSort.js'
5
+
6
+ /**
7
+ * StepList — A numbered list of slots ordered by hand. the Morph rail's steps in kol-fxr (each row a
8
+ * generator preset, the morph tweening from row 1 to row N), generic enough for any ordered slots —
9
+ * a playlist, a chain of stages (lobby: StepList, kol-fxr 2026-10-09). One row is active, × removes
10
+ * a row, the dashed last row adds one.
11
+ *
12
+ * <StepList items={steps} activeIndex={i} onSelect={setI} onRemove={remove} onMove={move} onAdd={add} />
13
+ *
14
+ * Reorder is `usePointerSort` — the pointer sort `RecordManager` runs — not the HTML drag the brief
15
+ * named from `LayerStack`: HTML drag does nothing under a finger, and fxr runs on a phone. The
16
+ * `arrows` variant is the keyboard path.
17
+ *
18
+ * @param {{ id: string|number, label: import('react').ReactNode }[]} items the rows, in order
19
+ * @param {number|null} activeIndex the highlighted (editing) row
20
+ * @param {'grab'|'arrows'} reorder `grab`: a ⠿ handle and a pointer drag (touch too) · `arrows`: ↑ ↓ quiet buttons, one slot a press
21
+ * @param {boolean} readOnly no handle or arrows, no ×, no add row; the body does not open
22
+ * @param {'xs'|'sm'|'md'|'lg'} size the control rung the × and the arrows ride
23
+ * @param {(index: number) => void} onSelect the body pressed
24
+ * @param {(index: number) => void} onRemove ×
25
+ * @param {(from: number, to: number) => void} onMove after a drop or an arrow press
26
+ * @param {() => void} onAdd the dashed row
27
+ * @param {import('react').ReactNode} addLabel the dashed row's words — the consumer's copy
28
+ * @param {string} className on the list
29
+ */
30
+ export default function StepList({
31
+ items = [],
32
+ activeIndex = null,
33
+ reorder = 'grab',
34
+ readOnly = false,
35
+ size = 'sm',
36
+ onSelect,
37
+ onRemove,
38
+ onMove,
39
+ onAdd,
40
+ addLabel = 'Add a step',
41
+ className = '',
42
+ }) {
43
+ const listRef = useRef(null)
44
+ const { drag, startDrag } = usePointerSort(readOnly ? null : onMove)
45
+ const grab = !readOnly && reorder === 'grab' && !!onMove
46
+ const arrows = !readOnly && reorder === 'arrows' && !!onMove
47
+ const rows = () => listRef.current?.querySelectorAll('.kol-step-list-item')
48
+
49
+ return (
50
+ <ul ref={listRef} className={`flex flex-col gap-1 ${className}`.trim()}>
51
+ {items.map((it, i) => {
52
+ const active = i === activeIndex
53
+ const dragging = drag && drag.from === i
54
+ const over = drag && drag.over === i && drag.over !== drag.from
55
+ const mark = over ? (drag.over > drag.from ? 'bottom-0' : 'top-0') : null
56
+ return (
57
+ <li
58
+ key={it.id}
59
+ className={`kol-step-list-item relative flex items-center gap-2 rounded px-2 py-1${active ? ' bg-fg-08' : ''}${dragging ? ' opacity-40' : ''}`}
60
+ >
61
+ {/* the drop mark: a 1px accent line above or below the row under the pointer */}
62
+ {mark && <span aria-hidden="true" className={`absolute left-2 right-2 h-px ${mark}`} style={{ background: 'var(--kol-accent-primary)' }} />}
63
+ {grab && (
64
+ <button
65
+ type="button"
66
+ aria-label={`Reorder step ${i + 1}`}
67
+ className="cursor-grab shrink-0 touch-none border-0 bg-transparent p-0"
68
+ onPointerDown={(e) => startDrag(e, i, rows())}
69
+ >
70
+ <Icon name="drag-handle" size={12} className="text-oq-48" />
71
+ </button>
72
+ )}
73
+ {arrows && (
74
+ <span className="flex shrink-0 items-center">
75
+ <Button tone="ghost" quiet iconOnly="chevron-up" size={size} aria-label={`Move step ${i + 1} up`} disabled={i === 0} onClick={() => onMove(i, i - 1)} />
76
+ <Button tone="ghost" quiet iconOnly="chevron-down" size={size} aria-label={`Move step ${i + 1} down`} disabled={i === items.length - 1} onClick={() => onMove(i, i + 1)} />
77
+ </span>
78
+ )}
79
+ <button
80
+ type="button"
81
+ className="flex min-w-0 flex-1 items-center gap-2 border-0 bg-transparent p-0 text-left"
82
+ onClick={readOnly ? undefined : () => onSelect?.(i)}
83
+ disabled={readOnly}
84
+ >
85
+ <span className="kol-helper-10 text-meta tabular-nums w-4 shrink-0">{i + 1}</span>
86
+ <span className="kol-mono-12 text-emphasis truncate">{it.label}</span>
87
+ </button>
88
+ {!readOnly && onRemove && (
89
+ <Button tone="ghost" quiet iconOnly="x" size={size} aria-label={`Remove step ${i + 1}`} onClick={() => onRemove(i)} />
90
+ )}
91
+ </li>
92
+ )
93
+ })}
94
+ {!readOnly && onAdd && (
95
+ <li>
96
+ <button
97
+ type="button"
98
+ className="flex w-full items-center gap-2 rounded border border-dashed border-oq-16 px-2 py-1 text-meta hover:text-body hover:border-oq-24"
99
+ onClick={onAdd}
100
+ >
101
+ <span className="kol-helper-10 tabular-nums w-4 shrink-0">{items.length + 1}</span>
102
+ <Icon name="plus" size={12} className="text-oq-48" />
103
+ <span className="kol-mono-12">{addLabel}</span>
104
+ </button>
105
+ </li>
106
+ )}
107
+ </ul>
108
+ )
109
+ }
@@ -523,19 +523,39 @@ const ContentFilters = ({
523
523
 
524
524
  {/* the view family's own line below md (`auto`) or always (`below`) —
525
525
  * full width, wrapping, so no view is ever off the page */}
526
- {viewModeOptions && viewPlacement !== 'header' && (
527
- <div className={`${viewPlacement === 'auto' ? 'md:hidden ' : ''}mb-3`}>
528
- {viewStrip('flex flex-wrap gap-y-2')}
529
- </div>
530
- )}
531
- {/* the consumer's trailing controls: their OWN line below md (`auto`) or
532
- * always (`below`) — full width, wrapping. In the below row's right
533
- * group they measured 223px beside LIST/GRID in a 342px track. */}
534
- {trailingActions && trailingPlacement !== 'header' && (
535
- <div className={`${trailingPlacement === 'auto' ? 'md:hidden ' : ''}mb-3 flex flex-wrap items-center gap-6 gap-y-2`}>
536
- {trailingActions}
537
- </div>
538
- )}
526
+ {/* BELOW `md` THE VIEW STRIP AND THE TRAILING CONTROLS SHARE ONE WRAPPING LINE (kol-fxr
527
+ * 2026-10-09, HubSettingsHeaderAt390: fxr's SETTINGS label and its options/shortcuts toggle
528
+ * were two ragged rows of one rung). Same rung, same line; it still wraps when they do not
529
+ * fit, so nothing is ever off the page. `below` placements keep their own line at every width. */}
530
+ {(() => {
531
+ const view = viewModeOptions && viewPlacement !== 'header'
532
+ const trail = trailingActions && trailingPlacement !== 'header'
533
+ const shared = view && trail && viewPlacement === 'auto' && trailingPlacement === 'auto'
534
+ if (shared) return (
535
+ <div className="md:hidden mb-3 flex flex-wrap items-center gap-6 gap-y-2">
536
+ {/* the desk's order — trailing, then the strip — so a consumer's divider sits between them */}
537
+ {trailingActions}
538
+ {viewStrip('flex flex-wrap gap-y-2')}
539
+ </div>
540
+ )
541
+ return (
542
+ <>
543
+ {view && (
544
+ <div className={`${viewPlacement === 'auto' ? 'md:hidden ' : ''}mb-3`}>
545
+ {viewStrip('flex flex-wrap gap-y-2')}
546
+ </div>
547
+ )}
548
+ {/* the consumer's trailing controls: their OWN line below md (`auto`) or
549
+ * always (`below`) — full width, wrapping. In the below row's right
550
+ * group they measured 223px beside LIST/GRID in a 342px track. */}
551
+ {trail && (
552
+ <div className={`${trailingPlacement === 'auto' ? 'md:hidden ' : ''}mb-3 flex flex-wrap items-center gap-6 gap-y-2`}>
553
+ {trailingActions}
554
+ </div>
555
+ )}
556
+ </>
557
+ )
558
+ })()}
539
559
 
540
560
  {/* BELOW the divider: the filter GROUPS only. Left-aligned columns —
541
561
  * label above values — visible only while the filter toggle is open.
@@ -10,6 +10,7 @@ import FieldRow, { StatusChip } from '../molecules/FieldRow'
10
10
  import { Tooltip } from '../utilities/Popover'
11
11
  import Table from './Table'
12
12
  import MediaLibrary from './MediaLibrary'
13
+ import usePointerSort from '../hooks/usePointerSort.js'
13
14
 
14
15
  /* TOOLBAR ICONS ARE BARE GLYPHS (user ruling 2026-08-09, verbatim: "did I
15
16
  * ask for a button?? … the only thing that should hover is full opacity and
@@ -93,8 +94,9 @@ export default function RecordManager({
93
94
  }) {
94
95
  const wrapRef = useRef(null)
95
96
  const [searchOpen, setSearchOpen] = useState(false)
96
- const dragRef = useRef(null)
97
- const [drag, setDrag] = useState(null) // { from, over, x, y } during a drag
97
+ /* { from, over, x, y } during a drag — the sort lives in hooks/usePointerSort (lifted 2026-10-09
98
+ * so StepList is not a second copy); this file keeps only which rows it measures */
99
+ const { drag, startDrag: beginSort } = usePointerSort(onReorder)
98
100
  const [active, setActive] = useState(null)
99
101
  const [picker, setPicker] = useState(null) // the field being picked for
100
102
  const [selected, setSelected] = useState(() => new Set())
@@ -118,36 +120,7 @@ export default function RecordManager({
118
120
  onSelectionChange?.([...next])
119
121
  }
120
122
 
121
- /* ── pointer sort ─────────────────────────────────────────────────────── */
122
- const startDrag = (e, from) => {
123
- if (!onReorder) return
124
- e.preventDefault()
125
- const rowEls = wrapRef.current?.querySelectorAll('.kol-table-row')
126
- if (!rowEls?.length) return
127
- const rects = [...rowEls].map((el) => el.getBoundingClientRect())
128
- const move = (ev) => {
129
- const hit = rects.findIndex((r) => ev.clientY < r.bottom)
130
- const over = hit === -1 ? rects.length - 1 : hit
131
- dragRef.current = { from, over, x: ev.clientX, y: ev.clientY }
132
- setDrag(dragRef.current)
133
- }
134
- const up = () => {
135
- window.removeEventListener('pointermove', move)
136
- window.removeEventListener('pointerup', up)
137
- document.body.style.cursor = ''
138
- document.body.style.userSelect = ''
139
- const d = dragRef.current
140
- dragRef.current = null
141
- setDrag(null)
142
- if (d && d.over !== d.from) onReorder(d.from, d.over)
143
- }
144
- window.addEventListener('pointermove', move)
145
- window.addEventListener('pointerup', up)
146
- document.body.style.cursor = 'grabbing'
147
- document.body.style.userSelect = 'none'
148
- dragRef.current = { from, over: from, x: e.clientX, y: e.clientY }
149
- setDrag(dragRef.current)
150
- }
123
+ const startDrag = (e, from) => beginSort(e, from, wrapRef.current?.querySelectorAll('.kol-table-row'))
151
124
 
152
125
  /* ── columns: resolve types, prepend handle + checkbox ────────────────── */
153
126
  const builtColumns = useMemo(() => {