@kolkrabbi/kol-component 0.224.0 → 0.226.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.
@@ -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)
@@ -333,7 +334,7 @@ function FolderRow({ name, icon = 'folder', onClick, onDoubleClick, depth = 0, e
333
334
  {onToggle ? (
334
335
  <button type="button" aria-label={expanded ? `Collapse ${name}` : `Expand ${name}`} aria-expanded={!!expanded}
335
336
  onClick={(e) => { e.stopPropagation(); onToggle() }}
336
- className="w-4 h-4 shrink-0 flex items-center justify-center text-fg-32 hover:text-fg-default transition-transform"
337
+ className="w-4 h-4 shrink-0 flex items-center justify-center text-oq-32 hover:text-fg-default transition-transform"
337
338
  style={{ transform: expanded ? 'rotate(90deg)' : 'none' }}>
338
339
  <Icon name="chevron-right" size={12} />
339
340
  </button>
@@ -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) } }}
@@ -27,7 +27,7 @@ const ToolbarIcon = ({ name, label, onClick, active = false, triggerClassName })
27
27
  * icon, and only when pressed". Rest is bare; hover is ink only. */
28
28
  /* 20px glyph, half-step pad — "TOO SMALL, TOO MUCH PADDING" (user
29
29
  * frames 2026-08-09): the reference icons nearly fill their box. */
30
- className={`inline-flex cursor-pointer rounded-sm border-0 p-0.5 transition-colors duration-150 ${active ? 'bg-fg-08 text-emphasis' : 'bg-transparent text-body hover:text-emphasis'}`}
30
+ className={`inline-flex cursor-pointer rounded-sm border-0 p-0.5 transition-colors duration-150 ${active ? 'bg-fg-08 text-emphasis' : 'bg-transparent text-oq-64 hover:text-emphasis'}`}
31
31
  >
32
32
  <Icon name={name} size={20} />
33
33
  </button>
@@ -255,7 +255,7 @@ export default function RecordManager({
255
255
  <button
256
256
  type="button"
257
257
  aria-label={reorderLabel(i + 1)}
258
- className="inline-flex w-4 cursor-grab touch-none justify-center border-0 bg-transparent p-0 text-meta hover:text-emphasis"
258
+ className="inline-flex w-4 cursor-grab touch-none justify-center border-0 bg-transparent p-0 text-oq-48 hover:text-emphasis"
259
259
  onPointerDown={(e) => startDrag(e, i)}
260
260
  >
261
261
  <Icon name="drag-handle" size={16} />
@@ -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>
@@ -247,7 +250,7 @@ export default function TimelineDock({ tracks = [], t = 0, onSeek, onChange, eas
247
250
  }
248
251
 
249
252
  return (
250
- <div className={`kol-timeline-dock border-t border-fg-08 px-4 py-2 flex flex-col gap-1 select-none ${className}`.trim()} style={{ background: 'var(--kol-surface-primary)' }}>
253
+ <div className={`kol-timeline-dock border-t border-oq-08 px-4 py-2 flex flex-col gap-1 select-none ${className}`.trim()} style={{ background: 'var(--kol-surface-primary)' }}>
251
254
  <ScrubRuler t={t} onSeek={onSeek} />
252
255
  {tracks.map((track) => (
253
256
  <TrackRow key={track.id} track={track} t={t} selected={selected} setSelected={setSelected} writeKeys={writeKeys} />
@@ -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>