@kolkrabbi/kol-component 0.224.0 → 0.225.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.224.0",
3
+ "version": "0.225.0",
4
4
  "description": "KOL design-system components — atoms through organisms, emitting canonical kol-* classes. Pairs with @kolkrabbi/kol-theme for styling.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -40,6 +40,7 @@
40
40
  * title — passes through.
41
41
  */
42
42
  import TransparentX from '../utilities/TransparentX'
43
+ import { Tooltip } from '../utilities/Popover.jsx'
43
44
 
44
45
  const SIZE_CLASSES = {
45
46
  fill: 'w-full aspect-square',
@@ -122,11 +123,11 @@ export default function ColorSwatch({
122
123
 
123
124
  if (interactive) {
124
125
  return (
126
+ <Tooltip label={title} asChild>
125
127
  <button
126
128
  {...rest}
127
129
  type="button"
128
130
  onClick={onClick}
129
- title={title}
130
131
  aria-label={rest['aria-label'] ?? hex ?? 'transparent'}
131
132
  aria-pressed={selected}
132
133
  className={cls}
@@ -134,12 +135,15 @@ export default function ColorSwatch({
134
135
  >
135
136
  {inner}
136
137
  </button>
138
+ </Tooltip>
137
139
  )
138
140
  }
139
141
 
140
142
  return (
141
- <span {...rest} aria-hidden="true" title={title} className={cls} style={style}>
143
+ <Tooltip label={title} asChild>
144
+ <span {...rest} aria-hidden="true" className={cls} style={style}>
142
145
  {inner}
143
146
  </span>
147
+ </Tooltip>
144
148
  )
145
149
  }
@@ -1,5 +1,6 @@
1
1
  import { useContext } from 'react'
2
2
  import { CanvasZoomContext } from '../hooks/canvasZoom.js'
3
+ import { Tooltip } from '../utilities/Popover.jsx'
3
4
 
4
5
  /* taxonomy-ok: presentational transform-chrome overlay. It nests no KOL
5
6
  * component (pure inline-styled squares + label), so by the letter of the
@@ -110,9 +111,9 @@ export default function SelectionOverlay({
110
111
  * the box about its centre. Independent of the resize handles: a path
111
112
  * hides those (node-edit owns their geometry) and still rotates. */}
112
113
  {rotate && (
114
+ <Tooltip label={rotateTitle} asChild>
113
115
  <div
114
116
  data-handle="ROT"
115
- title={rotateTitle}
116
117
  style={{
117
118
  position: 'absolute',
118
119
  left: `calc(50% - ${size / 2}px)`,
@@ -126,6 +127,7 @@ export default function SelectionOverlay({
126
127
  pointerEvents: 'auto',
127
128
  }}
128
129
  />
130
+ </Tooltip>
129
131
  )}
130
132
  {showHandles && HANDLE_DIRS.map(({ dir, cursor, x: hx, y: hy }) => (
131
133
  <div
package/src/index.js CHANGED
@@ -166,7 +166,7 @@ export { default as MediaLibrary, MediaLibraryProvider, useMediaLibrary, MediaPi
166
166
  /* the full-screen file viewer — public 2026-09-04 (user ruling): it already
167
167
  * rendered both mobile viewer views and was module-private, so every consumer
168
168
  * needing a phone viewer hand-rolled one. */
169
- export { MediaInspector } from './organisms/MediaLibraryPages.jsx'
169
+ export { MediaInspector, mediaSettingsSections, SETTINGS_BASE as MEDIA_SETTINGS_BASE } from './organisms/MediaLibraryPages.jsx'
170
170
  export { default as MediaLibraryExplorer } from './organisms/MediaLibraryExplorer.jsx'
171
171
  export { default as MediaTileGallery } from './organisms/MediaTileGallery.jsx'
172
172
  export { default as MediaViewer } from './organisms/MediaViewer.jsx'
@@ -2,7 +2,7 @@ import { useState } from 'react'
2
2
  import { Icon } from '@kolkrabbi/kol-icons'
3
3
  import ColorSwatch from '../atoms/ColorSwatch'
4
4
  import Input from '../atoms/Input'
5
- import { usePopover, PopoverPanel } from '../utilities/Popover'
5
+ import { usePopover, PopoverPanel, Tooltip } from '../utilities/Popover'
6
6
 
7
7
  /**
8
8
  * ColorInputRow — swatch chip + `#` hex input row. The single merged form of
@@ -150,11 +150,11 @@ export default function ColorInputRow({
150
150
  {chip(24)}
151
151
  </button>
152
152
  ) : isLockToggle ? (
153
+ <Tooltip label={locked ? 'Unlock' : 'Lock'} asChild>
153
154
  <button
154
155
  type="button"
155
156
  onClick={onToggleLock}
156
157
  aria-pressed={locked}
157
- title={locked ? 'Unlock' : 'Lock'}
158
158
  className="group relative inline-flex h-6 w-6 shrink-0"
159
159
  >
160
160
  {chip('stretch')}
@@ -167,6 +167,7 @@ export default function ColorInputRow({
167
167
  <Icon name={locked ? 'lock' : 'unlock'} size={12} />
168
168
  </span>
169
169
  </button>
170
+ </Tooltip>
170
171
  ) : (
171
172
  chip(24)
172
173
  )
@@ -1,7 +1,7 @@
1
1
  import { useEffect, useLayoutEffect, useRef, useState } from 'react'
2
2
  import { toneClass, TONE_VARS } from '../utilities/tone.js'
3
3
  import { Icon } from '@kolkrabbi/kol-icons'
4
- import { MenuDropdownItem } from './MenuItem.jsx'
4
+ import { MenuDropdownItem, MenuDropdownDivider } from './MenuItem.jsx'
5
5
  import { PopoverPanel, usePopover } from '../utilities/Popover.jsx'
6
6
  import { glyphSize, indicatorSize } from '../hooks/glyphLadders.js'
7
7
 
@@ -160,7 +160,11 @@ const Dropdown = ({
160
160
  onOptionHover?.(null)
161
161
  }, [isOpen]) // eslint-disable-line react-hooks/exhaustive-deps
162
162
 
163
- const currentOption = options.find((opt) => opt.value === value) || options[0]
163
+ /* `{ divider: true }` in `options` draws a hairline between groups (FoundationsTones, 2026-09-26 —
164
+ * user: none on top, the filled tones in order, outline and ghost apart). It is never a choice:
165
+ * the lookup, the fallback and the trigger's width all read the choices only. */
166
+ const choices = options.filter((opt) => !opt?.divider)
167
+ const currentOption = choices.find((opt) => opt.value === value) || choices[0]
164
168
 
165
169
  /* A clamped list (Popover caps the panel to the viewport) can open with the
166
170
  * checked row past the fold — scroll it into reach. Keyboard focus after
@@ -168,7 +172,7 @@ const Dropdown = ({
168
172
  const listRef = useRef(null)
169
173
  useEffect(() => {
170
174
  if (!isOpen) return
171
- const idx = options.findIndex((opt) => opt.value === currentOption?.value)
175
+ const idx = options.findIndex((opt) => !opt?.divider && opt.value === currentOption?.value)
172
176
  listRef.current?.children[idx]?.scrollIntoView({ block: 'nearest' })
173
177
  }, [isOpen]) // eslint-disable-line react-hooks/exhaustive-deps
174
178
 
@@ -215,7 +219,7 @@ const Dropdown = ({
215
219
  * trigger and list stay one piece at every selection */}
216
220
  <span className="kol-dd-label">
217
221
  <span>{currentOption?.label}</span>
218
- {options.map((option) => (
222
+ {choices.map((option) => (
219
223
  <span key={option.value} className="kol-dd-ghost" aria-hidden="true">
220
224
  {option.label}
221
225
  </span>
@@ -247,7 +251,8 @@ const Dropdown = ({
247
251
  {(resolvedVariant === 'primary' || resolvedVariant === 'grey') && <div className="kol-dd-div" />}
248
252
 
249
253
  <div ref={listRef} className="kol-dd-list" role="listbox">
250
- {options.map((option) => {
254
+ {options.map((option, i) => {
255
+ if (option?.divider) return <MenuDropdownDivider key={`divider-${i}`} />
251
256
  const isActive = option.value === currentOption?.value
252
257
  return (
253
258
  <MenuDropdownItem
@@ -115,7 +115,11 @@ export default function SectionText({
115
115
  {headline && <Headline className={cls(`kol-section-text-headline ${headlineClass ?? (HEADLINE_ROLE[headlineSize] ?? HEADLINE_ROLE['heading-02'])}${headlineCase === 'upper' ? ' kol-section-text-caps' : ''}`, 'headline')} style={slotStyle.headline}>{headline}</Headline>}
116
116
  {bodyNode && (inlineActions
117
117
  ? (
118
- <div className="flex items-baseline justify-between gap-6">
118
+ /* SELF-STRETCH (the Hub review, 2026-09-26 — user, on fxr's /settings: "there should
119
+ * have been space between … on the right edge"). The column is `items-start`, so this
120
+ * row shrank to its content and `justify-between` had no room to act: the cluster sat
121
+ * against the subtitle on every PageHeader with actions. */
122
+ <div className="flex self-stretch items-baseline justify-between gap-6">
119
123
  {bodyNode}
120
124
  {cluster}
121
125
  </div>
@@ -1,7 +1,7 @@
1
1
  import { useState } from 'react'
2
2
  import { Icon } from '@kolkrabbi/kol-icons'
3
3
  import Button from '../atoms/Button.jsx'
4
- import { PopoverPanel, usePopover } from '../utilities/Popover.jsx'
4
+ import { PopoverPanel, usePopover, Tooltip } from '../utilities/Popover.jsx'
5
5
  import { MenuDropdownItem } from './MenuItem.jsx'
6
6
 
7
7
  /**
@@ -44,15 +44,16 @@ const ShapeDropdown = ({ options = [], value, onChange, onAction, className = ''
44
44
  return (
45
45
  <div className={`inline-flex items-center ${className}`.trim()}>
46
46
  {current?.icon ? (
47
+ <Tooltip label={current.label}>
47
48
  <Button
48
49
  variant="ghost"
49
50
  size="sm"
50
51
  quiet
51
52
  iconOnly={current.icon}
52
53
  aria-label={current.label}
53
- title={current.label}
54
54
  onClick={() => onAction?.(current.id)}
55
55
  />
56
+ </Tooltip>
56
57
  ) : (
57
58
  <Button variant="ghost" size="sm" quiet onClick={() => onAction?.(current?.id)}>
58
59
  {current?.label}
@@ -1,6 +1,6 @@
1
1
  import { useState } from 'react'
2
2
  import { Icon } from '@kolkrabbi/kol-icons'
3
- import { PopoverPanel, usePopover } from '../utilities/Popover.jsx'
3
+ import { PopoverPanel, usePopover, Tooltip } from '../utilities/Popover.jsx'
4
4
  import { glyphSize } from '../hooks/glyphLadders.js'
5
5
 
6
6
  /**
@@ -121,6 +121,7 @@ const SplitToolButton = ({
121
121
 
122
122
  return (
123
123
  <>
124
+ <Tooltip label={title} asChild>
124
125
  <button
125
126
  ref={popover.refs.setReference}
126
127
  {...popover.getReferenceProps({ onClick: handleTriggerClick })}
@@ -129,11 +130,11 @@ const SplitToolButton = ({
129
130
  disabled={disabled}
130
131
  aria-pressed={onTrigger ? undefined : active}
131
132
  aria-label={label}
132
- title={title}
133
133
  >
134
134
  {triggerVariant && <IconC name={triggerVariant.icon} size={glyphSize(size, true)} />}
135
135
  <FoldIndicator />
136
136
  </button>
137
+ </Tooltip>
137
138
  {/* w-max — floats size to content, the menu-family law (2026-08-09). */}
138
139
  <PopoverPanel
139
140
  popover={popover}
@@ -1,6 +1,7 @@
1
1
  import { useEffect, useState } from 'react'
2
2
  import { Icon } from '@kolkrabbi/kol-icons'
3
3
  import ColorSwatch from '../atoms/ColorSwatch.jsx'
4
+ import { Tooltip } from '../utilities/Popover.jsx'
4
5
 
5
6
  /*
6
7
  * SwatchControls — the Photoshop-style top row of the colour panel, in two
@@ -107,16 +108,17 @@ export function EyedropPick({ sampleColor, onPick, disabled = false }) {
107
108
  return (
108
109
  <div className="flex items-start gap-1 shrink-0">
109
110
  {supported && (
111
+ <Tooltip label="Pick a color from the canvas" asChild>
110
112
  <button
111
113
  type="button"
112
114
  onClick={onPick}
113
115
  disabled={disabled}
114
116
  aria-label="Eyedropper"
115
- title="Pick a color from the canvas"
116
117
  className="inline-flex cursor-pointer text-fg hover:opacity-80 disabled:opacity-40 disabled:cursor-not-allowed"
117
118
  >
118
119
  <Icon name="eyedrop" size={24} />
119
120
  </button>
121
+ </Tooltip>
120
122
  )}
121
123
  <ColorSwatch
122
124
  hex={sampleColor}
@@ -1,5 +1,6 @@
1
1
  import { useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react'
2
2
  import { CanvasZoomContext } from '../hooks/canvasZoom.js'
3
+ import { Tooltip } from '../utilities/Popover.jsx'
3
4
 
4
5
  /**
5
6
  * Canvas — the editor's aspect-ratio stage.
@@ -628,22 +629,24 @@ export function PanZoomViewport({
628
629
  {/* Zoom % + fps — matching chips. Zoom (click resets to 100% /
629
630
  centered) first, fps to its right, shown while `f` toggles it. */}
630
631
  <div className="absolute bottom-3 right-3 z-[3] flex items-center gap-2">
632
+ <Tooltip label="Reset zoom (⌘0)" asChild>
631
633
  <button
632
634
  type="button"
633
635
  onClick={() => setView({ zoom: 1, x: 0, y: 0 })}
634
636
  className="px-2 py-1 rounded border border-fg-08 bg-surface-secondary kol-mono-12 text-emphasis tabular-nums"
635
637
  style={{ opacity: atRest && !showFps ? 0.55 : 1 }}
636
- title="Reset zoom (⌘0)"
637
638
  >
638
639
  {Math.round(view.zoom * 100)}%
639
640
  </button>
641
+ </Tooltip>
640
642
  {showFps && (
643
+ <Tooltip label="Framerate — press F to hide" asChild>
641
644
  <span
642
645
  className="px-2 py-1 rounded border border-fg-08 bg-surface-secondary kol-mono-12 text-emphasis tabular-nums"
643
- title="Framerate — press F to hide"
644
646
  >
645
647
  {fps} fps
646
648
  </span>
649
+ </Tooltip>
647
650
  )}
648
651
  </div>
649
652
  </div>
@@ -41,6 +41,9 @@ import { readDraft, writeDraft, clearDraft } from '../utilities/localDrafts.js'
41
41
  * searchable list inside the editor inserts the pick at the caret
42
42
  * @param {Function} onPickAsset `() => Promise<{ name, url, contentType } | null>` — a consumer's own picker instead
43
43
  * @param {Function} onClose
44
+ * @param {boolean} inline render IN the page, filling its container, instead of over it (the notes
45
+ * page, media-shell 2026-09-26: the editor is the page's content there,
46
+ * not a window over a list). No scrim, no Escape-to-close; the X still closes
44
47
  */
45
48
  const TYPES = [
46
49
  { value: 'md', label: 'MD', kind: 'markdown' },
@@ -64,6 +67,7 @@ const DRAFT_PAUSE = 400 // browser memory is free — a short pause, not D1's wr
64
67
  export default function DocumentEditor({
65
68
  mode = 'edit', name: nameProp = '', kind: kindProp, text, savedAt = 0, draft,
66
69
  onSave, onCreate, folder = '', assets, onPickAsset, onClose,
70
+ inline = false,
67
71
  }) {
68
72
  const [picking, setPicking] = useState(false)
69
73
  const [pickQuery, setPickQuery] = useState('')
@@ -186,9 +190,8 @@ export default function DocumentEditor({
186
190
  ? <img src={`data:image/svg+xml;charset=utf-8,${encodeURIComponent(value)}`} alt="" className="max-w-full max-h-full object-contain m-auto" />
187
191
  : <KindPreview o={{ key: name || 'document' }} text={value} kind={kind} />
188
192
 
189
- return (
190
- <FullscreenOverlay open onClose={close} closeButton={false} scrim>
191
- <QuickLookFrame title={isNew ? `New document${folder ? ` in ${folder}` : ''}` : name} meta={status} onClose={close}
193
+ const frame = (
194
+ <QuickLookFrame className={inline ? 'kol-quicklook--inline' : ''} title={isNew ? `New document${folder ? ` in ${folder}` : ''}` : name} meta={status} onClose={close}
192
195
  actions={(
193
196
  <span className="flex items-center gap-2">
194
197
  <ViewToggle variant="text" viewMode={view} onViewChange={setView} options={views} />
@@ -198,7 +201,7 @@ export default function DocumentEditor({
198
201
  </span>
199
202
  )}>
200
203
  {/* the window's own caps, inline — Tailwind does not generate arbitrary values from package source */}
201
- <div className="kol-doc-editor flex flex-col" style={{ width: 'min(1200px, var(--kol-ql-max-w))', height: 'min(760px, var(--kol-ql-media-h))' }}>
204
+ <div className="kol-doc-editor flex flex-col" style={inline ? { width: '100%', height: '100%' } : { width: 'min(1200px, var(--kol-ql-max-w))', height: 'min(760px, var(--kol-ql-media-h))' }}>
202
205
  {isNew && (
203
206
  <div className="flex flex-wrap items-center gap-3 p-4 border-b" style={{ borderColor: 'var(--kol-oq-08)' }}>
204
207
  <Input variant="filled" size="sm" value={name} aria-label="File name" placeholder="File name"
@@ -240,8 +243,8 @@ export default function DocumentEditor({
240
243
  </div>
241
244
  </div>
242
245
  </QuickLookFrame>
243
- </FullscreenOverlay>
244
246
  )
247
+ return inline ? frame : <FullscreenOverlay open onClose={close} closeButton={false} scrim>{frame}</FullscreenOverlay>
245
248
  }
246
249
 
247
250
  /* THE FRONTMATTER AS FIELDS. One row per key; a list value (tags) is typed comma-separated. The
@@ -3,6 +3,7 @@ import Button from '../atoms/Button.jsx'
3
3
  import Dropdown from '../molecules/Dropdown.jsx'
4
4
  import LabeledControl from '../molecules/LabeledControl.jsx'
5
5
  import Slider from '../molecules/Slider.jsx'
6
+ import { Tooltip } from '../utilities/Popover.jsx'
6
7
 
7
8
  const deg = (r) => Math.round(((r || 0) * 180) / Math.PI)
8
9
  const rad = (d) => (d * Math.PI) / 180
@@ -117,7 +118,7 @@ export default function KeyframeEditor({
117
118
  </div>
118
119
  <div className="flex gap-2">
119
120
  <Button variant="primary" size="sm" className="flex-1" onClick={onAdd}>Add @ playhead</Button>
120
- <Button variant="ghost" size="sm" title="Delete keyframe" onClick={onDelete} disabled={kfs.length <= 1}>Delete</Button>
121
+ <Tooltip label="Delete keyframe"><Button variant="ghost" size="sm" onClick={onDelete} disabled={kfs.length <= 1}>Delete</Button></Tooltip>
121
122
  </div>
122
123
 
123
124
  <span className="kol-helper-10 text-meta">Pose</span>
@@ -3,7 +3,7 @@ import { Icon } from '@kolkrabbi/kol-icons'
3
3
  import Button from '../atoms/Button.jsx'
4
4
  import Input from '../atoms/Input.jsx'
5
5
  import { MenuDropdownItem, MenuDropdownNest } from '../molecules/MenuItem.jsx'
6
- import { usePopover, PopoverPanel } from '../utilities/Popover.jsx'
6
+ import { usePopover, PopoverPanel, Tooltip } from '../utilities/Popover.jsx'
7
7
  import { rowLabelForLayer, findLayerDeep } from '../hooks/layerTree.js'
8
8
 
9
9
  /* DS icon names for the engine's types — every one a shipped v1 glyph. A
@@ -51,11 +51,11 @@ function useShiftClickHandlers(onSelect, onShiftSelect) {
51
51
 
52
52
  function Chevron({ IconC, collapsed, onToggle, title }) {
53
53
  return (
54
+ <Tooltip label={title} asChild>
54
55
  <button
55
56
  type="button"
56
57
  onClick={onToggle}
57
58
  aria-expanded={!collapsed}
58
- title={title}
59
59
  className="kol-layer-stack-collapse"
60
60
  >
61
61
  <IconC
@@ -64,6 +64,7 @@ function Chevron({ IconC, collapsed, onToggle, title }) {
64
64
  style={{ transform: collapsed ? 'rotate(-90deg)' : 'rotate(0deg)', transition: 'transform 150ms' }}
65
65
  />
66
66
  </button>
67
+ </Tooltip>
67
68
  )
68
69
  }
69
70
 
@@ -160,24 +161,26 @@ function LayerRow({
160
161
  </span>
161
162
  </button>
162
163
  )}
164
+ <Tooltip label={layer.visible ? 'Hide' : 'Show'} asChild>
163
165
  <button
164
166
  type="button"
165
167
  onClick={onToggleVisibility}
166
- title={layer.visible ? 'Hide' : 'Show'}
167
168
  aria-pressed={!layer.visible}
168
169
  className={`kol-layer-stack-toggle kol-layer-stack-toggle--eye${active || !layer.visible ? ' is-pinned' : ''}`}
169
170
  >
170
171
  <IconC name={layer.visible ? 'eye-on' : 'eye-off'} size={12} />
171
172
  </button>
173
+ </Tooltip>
174
+ <Tooltip label={layer.locked ? 'Unlock' : 'Lock'} asChild>
172
175
  <button
173
176
  type="button"
174
177
  onClick={onToggleLock}
175
- title={layer.locked ? 'Unlock' : 'Lock'}
176
178
  aria-pressed={!!layer.locked}
177
179
  className={`kol-layer-stack-toggle kol-layer-stack-toggle--lock${active || layer.locked ? ' is-pinned' : ''}${layer.locked ? ' is-on' : ''}`}
178
180
  >
179
181
  <IconC name={layer.locked ? 'lock' : 'unlock'} size={12} />
180
182
  </button>
183
+ </Tooltip>
181
184
  </div>
182
185
  </div>
183
186
  )
@@ -419,16 +422,17 @@ export default function LayerStack({
419
422
  * consumer's keymap. */}
420
423
  {onGroup && layerSelectionCount >= 2 && (
421
424
  <div className="mt-auto flex items-center gap-2 px-3 h-10 border-t border-fg-08">
425
+ <Tooltip label={`Group ${layerSelectionCount} selected layers`}>
422
426
  <Button
423
427
  iconComponent={IconC}
424
428
  variant="primary"
425
429
  size="sm"
426
430
  iconLeft="layers"
427
431
  onClick={() => onGroup(layerSelectedIds)}
428
- title={`Group ${layerSelectionCount} selected layers`}
429
432
  >
430
433
  Group {layerSelectionCount}
431
434
  </Button>
435
+ </Tooltip>
432
436
  </div>
433
437
  )}
434
438
  </div>
@@ -469,6 +473,7 @@ export function AddLayerButton({
469
473
  return (
470
474
  <>
471
475
  <span ref={popover.refs.setReference} {...popover.getReferenceProps()} className="inline-flex">
476
+ <Tooltip label="Add layer">
472
477
  <Button
473
478
  iconComponent={IconC}
474
479
  variant="primary"
@@ -476,8 +481,8 @@ export function AddLayerButton({
476
481
  quiet
477
482
  iconOnly="plus"
478
483
  aria-label="Add layer"
479
- title="Add layer"
480
484
  />
485
+ </Tooltip>
481
486
  </span>
482
487
  <PopoverPanel popover={popover} className="py-1" style={{ width: menuWidth }}>
483
488
  {types.map((t) => {
@@ -708,7 +708,7 @@ function FilesBody({ files, viewMode, onOpen, onPick }) {
708
708
  )
709
709
  /* the title voice is the family's ruled default (heading-04 card / heading-05
710
710
  * row, truncated by ContentText); the full key rides as the tooltip, as before */
711
- const nameFor = (row) => <span title={row.key}>{row.displayKey}</span>
711
+ const nameFor = (row) => <Tooltip label={row.key} asChild><span>{row.displayKey}</span></Tooltip>
712
712
  const date = (row) => (row.uploaded ? String(row.uploaded).slice(0, 10) : undefined)
713
713
  const size = (row) => formatSize(row.size) || undefined
714
714
 
@@ -1,5 +1,7 @@
1
- import { useState } from 'react'
1
+ import { useEffect, useRef, useState } from 'react'
2
2
  import { MediaLibraryBrowse } from './MediaLibraryPages.jsx'
3
+ import { useModal } from '../molecules/Modal.jsx'
4
+ import useMediaQuery from '../hooks/useMediaQuery.js'
3
5
 
4
6
  /**
5
7
  * MediaLibraryExplorer — the media surface, and since 2026-09-22 a thin name for it.
@@ -17,15 +19,77 @@ import { MediaLibraryBrowse } from './MediaLibraryPages.jsx'
17
19
  * @param {'columns'|'rows'|'grid'|'list'} view controlled view; omit and the page reads its own setting
18
20
  * @param {Function} onViewChange (view) => void
19
21
  * @param {'columns'|'rows'|'grid'|'list'} defaultView first view when uncontrolled and nothing is stored
22
+ *
23
+ * THE TOOL'S OWN BEHAVIOUR, opt-in (media parity, 2026-09-26 — user: "media in shell is just media in
24
+ * shell.. so there shouldnt really be a reason to diff"). apps/media carried these in its App.jsx, so
25
+ * the same tool one shell over had none of them. They are the product's, so they live with it:
26
+ *
27
+ * @param {boolean} keys the view keys — B browse · F files · R rows · C columns · G grid — and N
28
+ * (new folder, when `fileActions.createFolder` is given), plus K → `onKinds`.
29
+ * Ignored while typing and with ⌘ / Ctrl / ⌥ held
30
+ * @param {boolean} phoneTabs below 768 the surface is three tabs — Browse · Files · Kinds (Kinds only
31
+ * with `onKinds`); a tab picks the view, Kinds opens the overview and leaves
32
+ * the surface where it was
33
+ * @param {Function} onKinds opens the app's file-formats overview (K, the Kinds tab)
20
34
  */
21
- export default function MediaLibraryExplorer({ view, onViewChange, defaultView, ...pageProps }) {
35
+ const TABS = [
36
+ { value: 'browse', label: 'Browse', icon: 'folder' },
37
+ { value: 'files', label: 'Files', icon: 'view-list' },
38
+ { value: 'kinds', label: 'Kinds', icon: 'grid' },
39
+ ]
40
+ /* a key → the view it shows and the phone tab that view belongs to (apps/media's map, verbatim) */
41
+ const VIEW_KEYS = { b: ['columns', 'browse'], f: ['list', 'files'], r: ['rows', 'browse'], c: ['columns', 'browse'], g: ['grid', 'files'] }
42
+
43
+ export default function MediaLibraryExplorer({ view, onViewChange, defaultView, keys = false, phoneTabs = false, onKinds, ...pageProps }) {
22
44
  const [ownView, setOwnView] = useState(view ?? defaultView)
23
45
  const current = view ?? ownView
46
+ const setView = (v) => { setOwnView(v); onViewChange?.(v) }
47
+ const [tab, setTab] = useState('browse')
48
+ const phone = useMediaQuery('(max-width: 767px)')
49
+ const modal = useModal()
50
+
51
+ /* the latest props for the key handler, without re-binding it every render */
52
+ const live = useRef()
53
+ live.current = { onKinds, prefix: pageProps.prefix ?? '', createFolder: pageProps.fileActions?.createFolder, modal }
54
+ useEffect(() => {
55
+ if (!keys) return undefined
56
+ const onKey = (e) => {
57
+ if (e.metaKey || e.ctrlKey || e.altKey) return
58
+ const el = e.target
59
+ if (el?.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el?.tagName ?? '')) return
60
+ const { onKinds: kinds, prefix, createFolder, modal: m } = live.current
61
+ if (VIEW_KEYS[e.key]) {
62
+ e.preventDefault()
63
+ const [v, t] = VIEW_KEYS[e.key]
64
+ setOwnView(v); onViewChange?.(v); setTab(t)
65
+ } else if (e.key === 'k' && kinds) {
66
+ e.preventDefault(); kinds()
67
+ } else if (e.key === 'n' && createFolder) {
68
+ e.preventDefault()
69
+ m.prompt(`New folder in ${prefix || 'the bucket root'}:`, '', { okLabel: 'Create' }).then((name) => {
70
+ if (name?.trim()) createFolder(`${prefix}${name.trim().replace(/^\/+|\/+$/g, '')}/`)
71
+ })
72
+ }
73
+ }
74
+ window.addEventListener('keydown', onKey)
75
+ return () => window.removeEventListener('keydown', onKey)
76
+ }, [keys]) // eslint-disable-line react-hooks/exhaustive-deps
77
+
78
+ const onTabChange = (v) => {
79
+ if (v === 'kinds') { onKinds?.(); return }
80
+ setTab(v)
81
+ setView(v === 'files' ? 'list' : 'columns')
82
+ }
83
+ const tabProps = phoneTabs && phone
84
+ ? { tabs: onKinds ? TABS : TABS.filter((t) => t.value !== 'kinds'), activeTab: tab, onTabChange }
85
+ : {}
86
+
24
87
  return (
25
88
  <MediaLibraryBrowse
26
89
  {...pageProps}
90
+ {...tabProps}
27
91
  view={current}
28
- onViewChange={(v) => { setOwnView(v); onViewChange?.(v) }}
92
+ onViewChange={setView}
29
93
  />
30
94
  )
31
95
  }
@@ -1,4 +1,4 @@
1
- import { Fragment, useEffect, useMemo, useRef, useState } from 'react'
1
+ import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
2
2
  import formatSize from '../utilities/formatSize.js'
3
3
  import { Icon } from '@kolkrabbi/kol-icons'
4
4
  import Button from '../atoms/Button.jsx'
@@ -37,7 +37,8 @@ import { listDrafts, moveDrafts, DRAFTS_EVENT } from '../utilities/localDrafts.j
37
37
  import { parseFrontmatter } from '../utilities/frontmatter.js'
38
38
  import ShellSearchOverlay from './ShellSearchOverlay.jsx'
39
39
  import ColumnBrowser, { isFileDrag, Preview as ColumnPreview, SelectionPreview } from './ColumnBrowser.jsx'
40
- import SettingsPanel, { LabeledControlSection, SettingsRow, SettingsSwitch, SettingsChoice, SettingsMulti, SettingsFooter } from './SettingsPanel.jsx'
40
+ import SettingsPanel, { SettingsSwitch, SettingsChoice, SettingsMulti, SettingsFooter } from './SettingsPanel.jsx'
41
+ import SettingsSections from './SettingsSections.jsx'
41
42
  import { kindOf, extOf, KIND_LABEL, KINDS, DEFAULT_KINDS, isSystemFile, isSegment, groupSegments, groupVariants, posterFor, partition } from '../utilities/mediaKinds.js'
42
43
  import { nearestRatio } from '../utilities/ratios.js'
43
44
 
@@ -108,7 +109,7 @@ export const SETTINGS_BASE = {
108
109
  folderView: 'columns', // 'rows' | 'columns'
109
110
  sortBy: 'name',
110
111
  sortDir: 'asc',
111
- columnHeight: 528, // px, or any CSS length — forwarded to ColumnBrowser `height` untouched
112
+ columnHeight: 528, // px, any CSS length (forwarded to ColumnBrowser `height` untouched), or 'fill' — what is left of the window
112
113
  columnWidths: {},
113
114
  rowPreview: true, // row view's preview pane beside the list (Finder's "Show preview")
114
115
  view: 'columns', // 'columns' | 'rows' | 'grid' | 'list' — the ONE view state (2026-09-22)
@@ -683,61 +684,62 @@ export function MediaInspector({ files, index, onClose, onPrev, onNext, mediaUrl
683
684
  )
684
685
  }
685
686
 
686
- /* Display settings for the ACTIVE bucket — kol-r2b2's drawer wiring on the DS
687
- * organism (the composition the user locked 2026-08-27). Every control sets a
688
- * default, never a gate. */
689
- function MediaSettings({ bucketMeta, settings, onChange, onReset, onClose, profile, settingsFooter }) {
687
+ /* THE DISPLAY SETTINGS AS DATA (media-shell, user 2026-09-26: "missing settings sidebar? … or are
688
+ * we saying the shell has settings different from media?"). The rows were hard-wired JSX inside this
689
+ * drawer, so an app page could not show them and a second settings system grew beside it. Now ONE
690
+ * builder: this drawer renders it, and an app renders the same rows on its settings page and its own
691
+ * drawer (kol-shell `HubSettings sections`). `settings` is the page's per-bucket settings object,
692
+ * `onChange(next)` sets it, `profile` (what the bucket holds) drives the "nothing to fold" states —
693
+ * omit it where the listing is not at hand and those two switches stay enabled with plain hints.
694
+ * Every control sets a default, never a gate. */
695
+ export function mediaSettingsSections({ settings, onChange, profile }) {
690
696
  const set = (patch) => onChange({ ...settings, ...patch })
691
- const toggleKind = (k) => set({ kinds: settings.kinds.includes(k) ? settings.kinds.filter((x) => x !== k) : [...settings.kinds, k] })
692
- const noVariants = profile.variantSets === 0
693
- const noSegments = profile.segments === 0
697
+ const kinds = settings.kinds ?? []
698
+ const toggleKind = (k) => set({ kinds: kinds.includes(k) ? kinds.filter((x) => x !== k) : [...kinds, k] })
699
+ const noVariants = profile?.variantSets === 0
700
+ const noSegments = profile?.segments === 0
701
+ return [
702
+ { label: 'Structure', rowGap: 1, rows: [
703
+ { label: 'Columns', hint: 'Finder-style columns instead of folder rows',
704
+ render: () => <SettingsSwitch label="Columns" on={(settings.folderView ?? 'rows') === 'columns'} onChange={(v) => set({ folderView: v ? 'columns' : 'rows' })} /> },
705
+ { label: 'Preview', hint: 'the picked file beside the rows',
706
+ render: () => <SettingsSwitch label="Preview" on={settings.rowPreview ?? true} onChange={(v) => set({ rowPreview: v })} /> },
707
+ /* NOT "Columns" — that switch above means Finder's column browser. These are the row view's
708
+ * date/size FIELDS, which is what the user calls them. */
709
+ { label: 'Fields', hint: 'date and size beside each row, with a sort header',
710
+ render: () => <SettingsSwitch label="Fields" on={!!settings.rowColumns} onChange={(v) => set({ rowColumns: v })} /> },
711
+ { label: 'Flat', hint: 'ignore folders, show the whole subtree',
712
+ render: () => <SettingsSwitch label="Flat" on={settings.flat} onChange={(v) => set({ flat: v })} /> },
713
+ { label: 'Group resolutions', hint: noVariants ? 'no resolution sets in this bucket' : profile ? `${profile.variantSets} sets — previews the smallest file` : 'previews the smallest file of a set',
714
+ render: () => <SettingsSwitch label="Group resolutions" on={settings.groupVariants} onChange={(v) => set({ groupVariants: v })} disabled={noVariants} disabledHint="nothing to group here" /> },
715
+ { label: 'Fold HLS segments', hint: noSegments ? 'no segments in this bucket' : profile ? `${profile.segments} segments into stream rows` : 'segments into stream rows',
716
+ render: () => <SettingsSwitch label="Fold HLS segments" on={settings.foldSegments} onChange={(v) => set({ foldSegments: v })} disabled={noSegments} disabledHint="nothing to fold here" /> },
717
+ ] },
718
+ { label: 'Loading', rows: [
719
+ { label: 'Kinds', align: 'fill',
720
+ render: () => <SettingsMulti options={ALL_KINDS.map((k) => ({ value: k, label: KIND_LABEL[k] || k }))} selected={kinds} onToggle={toggleKind} noun="kinds" /> },
721
+ { label: 'Page size', hint: 'entries mounted at once', align: 'fill',
722
+ render: () => <SettingsChoice options={[{ value: 100, label: '100' }, { value: 200, label: '200' }, { value: 500, label: '500' }, { value: 0, label: 'All' }]} value={settings.pageSize} onChange={(v) => set({ pageSize: v })} /> },
723
+ { label: 'Video preview', hint: 'poster uses the sibling image; autoload fetches the file', align: 'fill',
724
+ render: () => <SettingsChoice options={[{ value: 'poster', label: 'Poster' }, { value: 'none', label: 'None' }, { value: 'autoload', label: 'Autoload' }]} value={settings.videoPreview} onChange={(v) => set({ videoPreview: v })} /> },
725
+ ] },
726
+ { label: 'Layout', rows: [
727
+ { label: 'View', align: 'fill',
728
+ render: () => <SettingsChoice options={[{ value: 'off', label: 'Off' }, { value: 'grid', label: 'Grid' }, { value: 'list', label: 'List' }]} value={settings.layout} onChange={(v) => set({ layout: v })} /> },
729
+ { label: 'Sort', align: 'fill',
730
+ render: () => <SettingsChoice options={SORT_OPTIONS} value={settings.sortBy} onChange={(v) => set({ sortBy: v })} /> },
731
+ { label: 'Direction', align: 'fill',
732
+ render: () => <SettingsChoice options={[{ value: 'asc', label: '↓ Asc' }, { value: 'desc', label: '↑ Desc' }]} value={settings.sortDir} onChange={(v) => set({ sortDir: v })} /> },
733
+ ] },
734
+ ]
735
+ }
736
+
737
+ /* Display settings for the ACTIVE bucket — kol-r2b2's drawer wiring on the DS organism (the
738
+ * composition the user locked 2026-08-27), drawn from `mediaSettingsSections`. */
739
+ function MediaSettings({ settings, onChange, onReset, onClose, profile, settingsFooter }) {
694
740
  return (
695
741
  <SettingsPanel variant="drawer" title="Display settings" onClose={onClose} footer={<SettingsFooter onReset={onReset} resetLabel="Reset preferences">{settingsFooter}</SettingsFooter>}>
696
- <LabeledControlSection label="Structure" rowGap={1} divided>
697
- <SettingsRow label="Columns" hint="Finder-style columns instead of folder rows">
698
- <SettingsSwitch label="Columns" on={(settings.folderView ?? 'rows') === 'columns'} onChange={(v) => set({ folderView: v ? 'columns' : 'rows' })} />
699
- </SettingsRow>
700
- <SettingsRow label="Preview" hint="the picked file beside the rows">
701
- <SettingsSwitch label="Preview" on={settings.rowPreview ?? true} onChange={(v) => set({ rowPreview: v })} />
702
- </SettingsRow>
703
- {/* NOT "Columns" — that switch three rows up means Finder's column browser. These are the
704
- row view's date/size FIELDS, which is what the user calls them. */}
705
- <SettingsRow label="Fields" hint="date and size beside each row, with a sort header">
706
- <SettingsSwitch label="Fields" on={!!settings.rowColumns} onChange={(v) => set({ rowColumns: v })} />
707
- </SettingsRow>
708
- <SettingsRow label="Flat" hint="ignore folders, show the whole subtree">
709
- <SettingsSwitch label="Flat" on={settings.flat} onChange={(v) => set({ flat: v })} />
710
- </SettingsRow>
711
- <SettingsRow label="Group resolutions" hint={noVariants ? 'no resolution sets in this bucket' : `${profile.variantSets} sets — previews the smallest file`}>
712
- <SettingsSwitch label="Group resolutions" on={settings.groupVariants} onChange={(v) => set({ groupVariants: v })} disabled={noVariants} disabledHint="nothing to group here" />
713
- </SettingsRow>
714
- <SettingsRow label="Fold HLS segments" hint={noSegments ? 'no segments in this bucket' : `${profile.segments} segments into stream rows`}>
715
- <SettingsSwitch label="Fold HLS segments" on={settings.foldSegments} onChange={(v) => set({ foldSegments: v })} disabled={noSegments} disabledHint="nothing to fold here" />
716
- </SettingsRow>
717
- </LabeledControlSection>
718
- <LabeledControlSection label="Loading" divided>
719
- <SettingsRow label="Kinds" align="fill">
720
- <SettingsMulti options={ALL_KINDS.map((k) => ({ value: k, label: KIND_LABEL[k] || k }))} selected={settings.kinds} onToggle={toggleKind} noun="kinds" />
721
- </SettingsRow>
722
- <SettingsRow label="Page size" hint="entries mounted at once" align="fill">
723
- <SettingsChoice options={[{ value: 100, label: '100' }, { value: 200, label: '200' }, { value: 500, label: '500' }, { value: 0, label: 'All' }]} value={settings.pageSize} onChange={(v) => set({ pageSize: v })} />
724
- </SettingsRow>
725
- <SettingsRow label="Video preview" hint="poster uses the sibling image; autoload fetches the file" align="fill">
726
- <SettingsChoice options={[{ value: 'poster', label: 'Poster' }, { value: 'none', label: 'None' }, { value: 'autoload', label: 'Autoload' }]} value={settings.videoPreview} onChange={(v) => set({ videoPreview: v })} />
727
- </SettingsRow>
728
- </LabeledControlSection>
729
- <LabeledControlSection label="Layout" divided>
730
- <SettingsRow label="View" align="fill">
731
- <SettingsChoice options={[{ value: 'off', label: 'Off' }, { value: 'grid', label: 'Grid' }, { value: 'list', label: 'List' }]} value={settings.layout} onChange={(v) => set({ layout: v })} />
732
- </SettingsRow>
733
- <SettingsRow label="Sort" align="fill">
734
- <SettingsChoice options={SORT_OPTIONS} value={settings.sortBy} onChange={(v) => set({ sortBy: v })} />
735
- </SettingsRow>
736
- <SettingsRow label="Direction" align="fill">
737
- <SettingsChoice options={[{ value: 'asc', label: '↓ Asc' }, { value: 'desc', label: '↑ Desc' }]} value={settings.sortDir} onChange={(v) => set({ sortDir: v })} />
738
- </SettingsRow>
739
- </LabeledControlSection>
740
- {void bucketMeta}
742
+ <SettingsSections sections={mediaSettingsSections({ settings, onChange, profile })} divided />
741
743
  </SettingsPanel>
742
744
  )
743
745
  }
@@ -916,6 +918,44 @@ function LibraryHeader({ title, buckets, bucketId, appRoot, onBucket, bucketMeta
916
918
  )
917
919
  }
918
920
 
921
+ /* `columnHeight: 'fill'` — THE BROWSER TAKES WHAT IS LEFT OF THE WINDOW (media-shell, user 2026-09-26:
922
+ * "why isnt the browser in media shell using available height like media does?"). apps/media had it
923
+ * as a hand-measured `calc(100dvh - 212px)` — its header, gaps and padding, counted once — so the same
924
+ * tool one shell over got the 528 base. Measured instead: the window, less the view's own top, less
925
+ * what the page draws under it (the count line), less the bottom padding / border / margin of every
926
+ * box around it. Follows a resize and any change above it. Floor 240 so a short window still browses. */
927
+ /* `view` is the ELEMENT (state, via a callback ref), not a ref object: the view mounts after the data
928
+ * loads, and in a fixed-height page (kol-shell PageShell) nothing else resizes to wake the observer —
929
+ * a ref read once at mount stayed null and the browser sat at the 528 base. */
930
+ function useFillHeight(on, view, pageEl) {
931
+ const [h, setH] = useState(null)
932
+ useLayoutEffect(() => {
933
+ if (!on || !view) return undefined
934
+ const measure = () => {
935
+ const page = pageEl.current
936
+ if (!page) return
937
+ const r = view.getBoundingClientRect()
938
+ /* what the page DRAWS under the view — its content's bottom, not its box's: a page stretched to
939
+ * fill its container (flex-1) would count its own empty space as "below" and freeze the height */
940
+ const contentBottom = Math.max(r.bottom, ...[...page.children].map((c) => c.getBoundingClientRect().bottom))
941
+ let below = contentBottom - r.bottom
942
+ for (let el = page; el && el !== document.body; el = el.parentElement) {
943
+ const cs = getComputedStyle(el)
944
+ below += parseFloat(cs.paddingBottom) + parseFloat(cs.borderBottomWidth) + (el === page ? 0 : parseFloat(cs.marginBottom))
945
+ }
946
+ const next = Math.max(240, Math.floor(window.innerHeight - r.top - below))
947
+ setH((p) => (p === next ? p : next))
948
+ }
949
+ measure()
950
+ const ro = new ResizeObserver(measure)
951
+ ro.observe(document.body)
952
+ if (pageEl.current) ro.observe(pageEl.current)
953
+ window.addEventListener('resize', measure)
954
+ return () => { ro.disconnect(); window.removeEventListener('resize', measure) }
955
+ }, [on, view]) // eslint-disable-line react-hooks/exhaustive-deps
956
+ return h
957
+ }
958
+
919
959
  /* the profile — what the bucket holds; drives the chips and the settings' "nothing to fold" states */
920
960
  const profileOf = (objects, rawFiles, systemCount) => ({
921
961
  variantSets: groupVariants(objects.map((o) => ({ ...o, displayKey: o.key }))).filter((g) => g.variants).length,
@@ -1689,6 +1729,17 @@ export function MediaLibraryBrowse({
1689
1729
  setQuickLook({ files, index: at === -1 ? 0 : at })
1690
1730
  }, [rowSelection]) // eslint-disable-line react-hooks/exhaustive-deps
1691
1731
  const columnsRef = useRef(null)
1732
+ /* the view box (whichever view is mounted) and the page root — what `fill` measures */
1733
+ const [viewEl, viewRef] = useState(null)
1734
+ /* stable, so the columns box is not re-attached (null, then itself) on every render */
1735
+ const columnsBox = useCallback((el) => { columnsRef.current = el; viewRef(el) }, [])
1736
+ const pageRef = useRef(null)
1737
+ const fill = settings.columnHeight === 'fill'
1738
+ const fillH = useFillHeight(fill, viewEl, pageRef)
1739
+ /* a drag in fill mode is the SESSION's, never saved — one height on every reload (apps/media's ruling) */
1740
+ const [dragH, setDragH] = useState(null)
1741
+ const colH = fill ? (dragH ?? fillH ?? SETTINGS_BASE.columnHeight) : (settings.columnHeight ?? SETTINGS_BASE.columnHeight)
1742
+ const onColH = (px) => (fill ? setDragH(px) : setSettings({ ...settings, columnHeight: px }))
1692
1743
  /* `flat` IS THE WALL'S, NOT THIS PAGE'S (user 2026-09-22: "there is no option to switch to flat
1693
1744
  * mode, bc its not in files view. this is row mode.. this makes no sense"). It reached here from
1694
1745
  * kol-r2b2's single surface and did two things that cannot be defended in a TREE: it struck every
@@ -1904,12 +1955,12 @@ export function MediaLibraryBrowse({
1904
1955
  const treeObjects = keep ? sortedObjects.filter((o) => keep.has(o.key)) : sortedObjects
1905
1956
  return (
1906
1957
  folderView === 'columns' || phone ? (
1907
- <div ref={columnsRef} className="relative">
1958
+ <div ref={columnsBox} className="relative">
1908
1959
  <ColumnBrowser
1909
1960
  autoFocus={autoFocus}
1910
1961
  className={atTitleRoot ? 'is-root' : ''}
1911
- height={settings.columnHeight}
1912
- onHeightChange={(px) => setSettings({ ...settings, columnHeight: px })}
1962
+ height={colH}
1963
+ onHeightChange={onColH}
1913
1964
  columnWidths={settings.columnWidths}
1914
1965
  onColumnResize={(i, px) => setSettings({ ...settings, columnWidths: { ...(settings.columnWidths ?? {}), [i]: px } })}
1915
1966
  objects={treeObjects.filter((o) => !isSystemFile(o.key)).map((o) => ({ ...o, key: `${VROOT}${o.key}` }))}
@@ -1993,7 +2044,7 @@ export function MediaLibraryBrowse({
1993
2044
  * the same line — the count line under them jumps otherwise, and a consumer's fill height
1994
2045
  * is ONE value for both — so the header comes out of this view's budget rather than being
1995
2046
  * added to it. The pane takes what is left. */
1996
- <div className="flex flex-col gap-2" style={{ height: settings.columnHeight ?? SETTINGS_BASE.columnHeight }}>
2047
+ <div ref={viewRef} className="flex flex-col gap-2" style={{ height: colH }}>
1997
2048
  {/* the header labels the LIST, so it spans the list and stops where the preview starts */}
1998
2049
  {rowCols && (
1999
2050
  <RowHeader sortBy={settings.sortBy} sortDir={settings.sortDir} width={showRowPreview ? `calc(100% - ${previewWidth}px)` : '100%'}
@@ -2103,7 +2154,7 @@ export function MediaLibraryBrowse({
2103
2154
  onChange={(e) => setSettings({ ...settings, tileSize: Number(e.target.value) })} aria-label="Tile size" />
2104
2155
  )
2105
2156
  const wallPane = (files) => (
2106
- <div className="flex flex-col gap-2" style={{ height: settings.columnHeight ?? SETTINGS_BASE.columnHeight }}>
2157
+ <div ref={viewRef} className="flex flex-col gap-2" style={{ height: colH }}>
2107
2158
  <div className="relative border rounded flex overflow-hidden flex-1 min-h-0" style={{ borderColor: 'var(--kol-oq-08)' }}>
2108
2159
  {/* THE MARQUEE IS THE PANE'S, not the tile grid's (user 2026-09-23: a drag only started where
2109
2160
  * there were tiles — the grid is as tall as its tiles, the pane is the whole frame). The rows'
@@ -2189,7 +2240,7 @@ export function MediaLibraryBrowse({
2189
2240
  : treeBody(filtered ? new Set(filtered.map((f) => f.key)) : null))
2190
2241
 
2191
2242
  return (
2192
- <div {...press} className={`flex flex-col gap-6 ${className}`.trim()}>
2243
+ <div ref={pageRef} {...press} className={`flex flex-col gap-6 ${className}`.trim()}>
2193
2244
  <LibraryHeader title={title} buckets={buckets} bucketId={bucketMeta.id} appRoot={atTitleRoot} bucketMeta={bucketMeta} writable={writable} headerActions={headerActions} headerTrailing={headerTrailing} onTrash={trash ? () => setTrashOpen(true) : undefined}
2194
2245
  onHome={goTitleRoot}
2195
2246
  onBucket={(v) => { if (v === 'all') goTitleRoot(); else { setAppRoot(false); switchBucket(v) } }}
@@ -171,7 +171,11 @@ export function LabeledControlSection({ label, divided = false, rowGap = 2, chil
171
171
  * rebuilt this same Dropdown in a local `ChoiceRow`, which is the exact
172
172
  * duplication this component exists to prevent. A component that wraps another
173
173
  * has to forward the wrapped one's seams or it becomes a wall. */
174
- export function SettingsChoice({ options = [], value, onChange, ariaLabel, tone = 'default', size = 'sm', variant = 'primary', className = '' }) {
174
+ /* `variant` UNSET by default (the Hub review, 2026-09-26): a forced `primary` blocked the
175
+ * tone of a `kol-tone-*` wrapper, so a sunken settings page drew a light well in every row
176
+ * unless the row said `tone="sunken"` itself. Unset, Dropdown takes the wrapper's tone and
177
+ * falls back to primary — the same render wherever there is no wrapper. */
178
+ export function SettingsChoice({ options = [], value, onChange, ariaLabel, tone = 'default', size = 'sm', variant, className = '' }) {
175
179
  const opts = options.map((o) => (o != null && typeof o === 'object' ? o : { value: o, label: String(o) }))
176
180
  return <Dropdown size={size} variant={variant} tone={tone} value={value} onChange={onChange} options={opts} className={`w-full ${className}`.trim()} aria-label={ariaLabel} />
177
181
  }
@@ -35,7 +35,7 @@ import { LabeledControlSection, SettingsRow } from './SettingsPanel.jsx'
35
35
  * first included: the rule is `.kol-section--divided + .kol-section--divided`,
36
36
  * so the first draws nothing and skipping it just costs the next its line.
37
37
  *
38
- * @param {Array<{label?: string, rows: Array<{label: ReactNode, render?: Function, value?: ReactNode, align?: string, labelWidth?: number|'auto', id?: string}>, rowGap?: number}>} sections - The settings body, in order
38
+ * @param {Array<{label?: string, rows: Array<{label: ReactNode, render?: Function, value?: ReactNode, hint?: string, align?: string, labelWidth?: number|'auto', id?: string}>, rowGap?: number}>} sections - The settings body, in order (`hint` → SettingsRow's hint)
39
39
  * @param {boolean} [divided=false] - Hairline above each section
40
40
  * @param {number|'auto'} [labelWidth] - Default label column for every row — `'auto'` makes the label yield and the control hug, which is what a narrow column or a nowrap value needs
41
41
  * @param {string} [align] - Default row alignment passed to `SettingsRow`
@@ -50,6 +50,7 @@ export default function SettingsSections({ sections = [], divided = false, label
50
50
  <SettingsRow
51
51
  key={row.id ?? row.label ?? j}
52
52
  label={row.label}
53
+ hint={row.hint}
53
54
  align={row.align ?? align}
54
55
  labelWidth={row.labelWidth ?? labelWidth}
55
56
  >
@@ -1,6 +1,7 @@
1
1
  import { useEffect, useState } from 'react'
2
2
  import ColorSwatch from '../atoms/ColorSwatch.jsx'
3
3
  import { resolveCssVar, resolveCssColor, isLight } from '../hooks/cssVar.js'
4
+ import { Tooltip } from '../utilities/Popover.jsx'
4
5
 
5
6
  /**
6
7
  * SpectrumGrid — matrix view of the whole ramp system: rows = ramps, columns =
@@ -63,10 +64,9 @@ export default function SpectrumGrid({ ramps, stops = DEFAULT_STOPS, brandAnchor
63
64
  const textColor = isLight(color) ? '#000' : '#fff'
64
65
  const brand = isBrandAnchor(ramp, stop)
65
66
  return (
67
+ <Tooltip key={stop} label={`${ramp}-${stop} ${hex}${brand ? ' · brand anchor' : ''}`} asChild>
66
68
  <div
67
- key={stop}
68
69
  className="relative aspect-[5/3] rounded overflow-hidden transition-transform hover:scale-[1.04]"
69
- title={`${ramp}-${stop} ${hex}${brand ? ' · brand anchor' : ''}`}
70
70
  >
71
71
  <ColorSwatch hex={`var(--${ramp}-${stop})`} size="stretch" radius="none" frame={false} className="absolute inset-0" />
72
72
  <div className="pointer-events-none absolute inset-0 px-2 py-1.5" style={{ color: textColor }}>
@@ -81,6 +81,7 @@ export default function SpectrumGrid({ ramps, stops = DEFAULT_STOPS, brandAnchor
81
81
  )}
82
82
  </div>
83
83
  </div>
84
+ </Tooltip>
84
85
  )
85
86
  })}
86
87
  </div>
@@ -1,6 +1,7 @@
1
1
  import { useRef, useState } from 'react'
2
2
  import Input from '../atoms/Input.jsx'
3
3
  import Dropdown from '../molecules/Dropdown.jsx'
4
+ import { Tooltip } from '../utilities/Popover.jsx'
4
5
 
5
6
  /* The six easings the key editor offers. The CURVES stay the consumer's
6
7
  * (its interpolator resolves the name); this is the menu, not the math. */
@@ -123,9 +124,11 @@ function TrackRow({ track, t, selected, setSelected, writeKeys }) {
123
124
 
124
125
  return (
125
126
  <div className="flex items-center gap-3">
126
- <span className="kol-helper-10 text-meta truncate shrink-0 text-right" style={{ width: 120 }} title={track.label}>
127
+ <Tooltip label={track.label} asChild>
128
+ <span className="kol-helper-10 text-meta truncate shrink-0 text-right" style={{ width: 120 }}>
127
129
  {track.label}
128
130
  </span>
131
+ </Tooltip>
129
132
  <div
130
133
  ref={laneRef}
131
134
  className="relative flex-1 h-5 rounded cursor-copy"
@@ -136,13 +139,12 @@ function TrackRow({ track, t, selected, setSelected, writeKeys }) {
136
139
  {track.keys.map((k, i) => {
137
140
  const kt = drag.current?.index === i ? drag.current.t : k.t
138
141
  return (
142
+ <Tooltip key={i} label={`t=${kt.toFixed(2)} v=${typeof k.v === 'number' ? Math.round(k.v * 100) / 100 : k.v} (alt-click deletes)`} asChild>
139
143
  <span
140
- key={i}
141
144
  data-diamond=""
142
145
  onPointerDown={onDiamondPointerDown(i)}
143
146
  onPointerMove={onDiamondPointerMove(i)}
144
147
  onPointerUp={onDiamondPointerUp(i)}
145
- title={`t=${kt.toFixed(2)} v=${typeof k.v === 'number' ? Math.round(k.v * 100) / 100 : k.v} (alt-click deletes)`}
146
148
  className="absolute top-1/2 cursor-grab"
147
149
  style={{
148
150
  left: `${kt * 100}%`,
@@ -152,6 +154,7 @@ function TrackRow({ track, t, selected, setSelected, writeKeys }) {
152
154
  borderRadius: 1.5,
153
155
  }}
154
156
  />
157
+ </Tooltip>
155
158
  )
156
159
  })}
157
160
  </div>
@@ -2,6 +2,7 @@ import { Icon } from '@kolkrabbi/kol-icons'
2
2
  import Button from '../atoms/Button.jsx'
3
3
  import Divider from '../atoms/Divider.jsx'
4
4
  import SplitToolButton from '../molecules/SplitToolButton.jsx'
5
+ import { Tooltip } from '../utilities/Popover.jsx'
5
6
 
6
7
  /**
7
8
  * ToolPalette — the editor's tool bar as one row (kol-fxr `shell/panels/ToolPalette.jsx`,
@@ -45,7 +46,6 @@ const ToolPalette = ({
45
46
  <div className={`flex items-center gap-1 min-w-0 overflow-x-auto ${className}`.trim()}>
46
47
  {items.map((it, i) => {
47
48
  if (it.kind === 'divider') return <Divider key={`d${i}`} variant="vertical" height={20} className="mx-1.5 shrink-0" />
48
- const tip = it.shortcut ? `${it.label} (${it.shortcut})` : it.label
49
49
  if (it.kind === 'split') {
50
50
  const variants = (it.variants ?? []).map((v) => (v.action && !it.action ? { ...v, onSelect: () => onAction?.(v.id) } : v))
51
51
  const run = (id) => (it.action ? onAction : onSelect)?.(id)
@@ -58,10 +58,13 @@ const ToolPalette = ({
58
58
  }
59
59
  const isTool = it.kind === 'tool'
60
60
  return (
61
- <Button key={it.id} variant="ghost" size={size} quiet iconOnly={it.icon} iconComponent={iconComponent}
61
+ /* the DS tooltip carries the key as its own chip (the native-title sweep, 2026-09-26) */
62
+ <Tooltip key={it.id} label={it.label} shortcut={it.shortcut}>
63
+ <Button variant="ghost" size={size} quiet iconOnly={it.icon} iconComponent={iconComponent}
62
64
  pressed={isTool ? activeId === it.id : undefined} disabled={it.disabled}
63
- aria-label={it.label} title={tip}
65
+ aria-label={it.label}
64
66
  onClick={(e) => { (isTool ? onSelect : onAction)?.(it.id); blur(e) }} />
67
+ </Tooltip>
65
68
  )
66
69
  })}
67
70
  </div>
@@ -1,5 +1,5 @@
1
- import { createContext, useContext, useState } from 'react'
2
- import { toneClass } from './tone.js'
1
+ import { cloneElement, createContext, isValidElement, useContext, useLayoutEffect, useState, version as reactVersion } from 'react'
2
+ import { toneClass, TONE_VARS } from './tone.js'
3
3
  import {
4
4
  useFloating,
5
5
  autoUpdate,
@@ -136,13 +136,29 @@ export function usePopover({
136
136
  * — the innermost wins, as the native `title` it replaced behaved. */
137
137
  const TooltipNest = createContext(null)
138
138
 
139
- export function Tooltip({
139
+ /* `asChild` (the native-title sweep, 2026-09-26): the tooltip attaches to its one child element
140
+ * instead of wrapping it in a span — for a trigger whose box IS the layout (a grid cell, a rail row,
141
+ * an absolutely placed handle), where an inline-flex wrapper would move it. The child must be an
142
+ * element that takes a ref: an intrinsic tag always does; a component only if it forwards one
143
+ * (React 18 needs forwardRef), so Button / IconFrame keep the wrapper. Its own ref and handlers are
144
+ * kept — the reference props merge over the child's. */
145
+ const childRefOf = (el) => ('ref' in (el.props ?? {}) ? el.props.ref : reactVersion.startsWith('18') ? el.ref : undefined)
146
+
147
+ export function Tooltip(props) {
148
+ /* no label → no tooltip: several call sites pass an optional hint, and an empty panel is worse
149
+ * than none. A separate component so the hooks below never run conditionally. */
150
+ if (props.label == null || props.label === '') return props.children
151
+ return <TooltipOn {...props} />
152
+ }
153
+
154
+ function TooltipOn({
140
155
  label,
141
156
  shortcut,
142
157
  placement = 'bottom',
143
158
  offset = 6,
144
159
  children,
145
160
  triggerClassName = 'inline-flex',
161
+ asChild = false,
146
162
  }) {
147
163
  const [open, setOpen] = useState(false)
148
164
  const [covered, setCovered] = useState(false)
@@ -158,22 +174,56 @@ export function Tooltip({
158
174
  focus: true,
159
175
  })
160
176
 
177
+ /* THE TONE OF WHERE IT IS (user, 2026-09-26: "tooltip should probably use same tone as dropdowns
178
+ * and buttons"). The panel is portalled to body, outside the `kol-tone-*` wrapper that tones the
179
+ * controls around the trigger — Dropdown's problem, solved the same way: on open, copy the
180
+ * trigger's RESOLVED `--kol-tone-*` onto the panel. No tone in effect = nothing copied, and the
181
+ * tooltip keeps its own surface. */
182
+ const [ambient, setAmbient] = useState(null)
183
+ const shown = open && !covered
184
+ useLayoutEffect(() => {
185
+ const ref = popover.refs.reference.current
186
+ /* the SURROUNDINGS' tone, not the trigger's own: an `asChild` trigger can be a variant button
187
+ * (`kol-btn-ghost` carries its own `--kol-tone-*`), so read its parent; the wrapper span has no
188
+ * variant and inherits the ambient values as it is */
189
+ const el = asChild ? ref?.parentElement : ref
190
+ if (!shown || !(el instanceof Element)) return
191
+ const cs = getComputedStyle(el)
192
+ const vars = {}
193
+ for (const v of TONE_VARS) { const val = cs.getPropertyValue(v).trim(); if (val) vars[v] = val }
194
+ setAmbient(vars)
195
+ }, [shown]) // eslint-disable-line react-hooks/exhaustive-deps
196
+
197
+ const child = asChild && isValidElement(children) ? children : null
198
+ const childRef = child ? childRefOf(child) : undefined
199
+ const setRef = (node) => {
200
+ popover.refs.setReference(node)
201
+ if (typeof childRef === 'function') childRef(node)
202
+ else if (childRef) childRef.current = node
203
+ }
161
204
  return (
162
205
  <>
163
- <span
164
- ref={popover.refs.setReference}
165
- {...popover.getReferenceProps()}
166
- className={triggerClassName}
167
- >
168
- <TooltipNest.Provider value={setCovered}>{children}</TooltipNest.Provider>
169
- </span>
206
+ {child ? (
207
+ <TooltipNest.Provider value={setCovered}>
208
+ {cloneElement(child, { ...popover.getReferenceProps(child.props), ref: setRef })}
209
+ </TooltipNest.Provider>
210
+ ) : (
211
+ <span
212
+ ref={popover.refs.setReference}
213
+ {...popover.getReferenceProps()}
214
+ className={triggerClassName}
215
+ >
216
+ <TooltipNest.Provider value={setCovered}>{children}</TooltipNest.Provider>
217
+ </span>
218
+ )}
170
219
  <PopoverPanel
171
220
  popover={popover}
172
221
  focus={false}
173
222
  panel={false}
174
223
  className="kol-tooltip"
224
+ style={ambient ?? undefined}
175
225
  >
176
- <span className="text-emphasis">{label}</span>
226
+ <span>{label}</span>
177
227
  {shortcut && <span className="kol-tooltip-key">{shortcut}</span>}
178
228
  </PopoverPanel>
179
229
  </>
@@ -38,6 +38,7 @@
38
38
  */
39
39
 
40
40
  import { useCallback, useEffect, useRef, useState } from 'react'
41
+ import { Tooltip } from './Popover.jsx'
41
42
 
42
43
  /* ── coordinates ─────────────────────────────────────────────────────────
43
44
  * Pure. No DOM writes. The invariant everything rests on: a cell is a
@@ -442,14 +443,15 @@ export default function QuadrantSync ({
442
443
  className="kol-mono-12 text-fg-96 bg-fg-04 border border-fg-16"
443
444
  style={{ width: 52, padding: '5px 6px', textAlign: 'center', borderRadius: 4 }}
444
445
  />
446
+ <Tooltip label="Set divisions so cells come out square at the current size" asChild>
445
447
  <button
446
448
  type="button" onClick={applySquare}
447
- title="Set divisions so cells come out square at the current size"
448
449
  className="kol-helper-10 text-fg-96 border border-fg-16"
449
450
  style={{ marginLeft: 'auto', padding: '5px 9px', borderRadius: 4, background: 'transparent', cursor: 'pointer' }}
450
451
  >
451
452
  square
452
453
  </button>
454
+ </Tooltip>
453
455
  </div>
454
456
  {aspect && (
455
457
  <div className="kol-mono-10 text-fg-48" style={{ marginTop: 6 }}>