@kolkrabbi/kol-component 0.231.0 → 0.232.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.231.0",
3
+ "version": "0.232.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",
package/src/index.js CHANGED
@@ -60,6 +60,7 @@ export { default as SectionLabel } from './atoms/SectionLabel.jsx'
60
60
  export { default as SegmentedToggle } from './atoms/SegmentedToggle.jsx'
61
61
  export { default as Stepper } from './molecules/Stepper.jsx'
62
62
  export { default as Kbd } from './atoms/Kbd.jsx'
63
+ export { default as OptionRow } from './molecules/OptionRow.jsx'
63
64
  export { default as Tag } from './atoms/Tag.jsx'
64
65
  export { default as Textarea } from './atoms/Textarea.jsx'
65
66
  export { default as ToggleBracket } from './atoms/ToggleBracket.jsx'
@@ -3,6 +3,7 @@ import { Icon } from '@kolkrabbi/kol-icons'
3
3
  import Input from '../atoms/Input'
4
4
  import { usePopover, PopoverPanel } from '../utilities/Popover'
5
5
  import Dropdown from './Dropdown'
6
+ import OptionRow from './OptionRow.jsx'
6
7
 
7
8
  /**
8
9
  * FieldRow — one labeled field row in a record surface (lobby: RecordManager).
@@ -93,23 +94,23 @@ export function StatusChip({ value, options = [], onChange, variant = 'neutral',
93
94
  {norm.map(({ value: val, label }) => {
94
95
  return (
95
96
  <li key={val}>
96
- <button
97
+ <OptionRow
98
+ as="button"
97
99
  type="button"
98
100
  role="option"
99
101
  aria-selected={val === value}
100
- className={`kol-helper-12 w-full text-left pr-3 py-2 bg-transparent border-0 cursor-pointer rounded-sm hover:bg-fg-08 inline-flex items-center ${val === value ? 'text-emphasis' : 'text-body'}`}
102
+ size="sm"
103
+ selected={val === value}
104
+ className="w-full"
101
105
  onClick={() => {
102
106
  onChange?.(val)
103
107
  setOpen(false)
104
108
  }}
105
- >
106
- {/* check column always reserved — labels align whether or
107
- * not a row carries the mark (reference grammar) */}
108
- <span className="inline-flex w-7 shrink-0 justify-center" aria-hidden="true">
109
- {val === value && <Icon name="check" size={12} />}
110
- </span>
111
- {label}
112
- </button>
109
+ /* check column always reserved — labels align whether or
110
+ * not a row carries the mark (reference grammar) */
111
+ leading={<span className="inline-flex w-4 justify-center">{val === value && <Icon name="check" size={12} />}</span>}
112
+ label={label}
113
+ />
113
114
  </li>
114
115
  )
115
116
  })}
@@ -0,0 +1,82 @@
1
+ /**
2
+ * OptionRow — one row in a list you move through: the palette's results, a browser's rows, a
3
+ * dropdown's options. Lifted out of ShellSearchOverlay (2026-09-30), where it was hand-rolled next to
4
+ * DropdownTagFilter's, FieldRow's and ColumnBrowser's own — four rows for one job.
5
+ *
6
+ * The box is the control's: `min-h` on the --kol-ctl-* ramp (22 · 26 · 32 · 40) with a 1px
7
+ * transparent ring, so a row lines up with the field or button beside it at the same size.
8
+ *
9
+ * States, strongest first:
10
+ * active — the keyboard cursor / hover: the tone fill, full ink
11
+ * selected — the picked item: oq-08
12
+ * trail — on the path to the picked item (a browser's parent folders): oq-04
13
+ *
14
+ * Renders no role — the caller owns the list semantics (`role="option"`, `aria-selected`, ids)
15
+ * and passes them through `...rest`.
16
+ *
17
+ * @param {'xs'|'sm'|'md'|'lg'} size the control ramp step
18
+ * @param {string} icon kol-icons name, drawn in oq (icons-use-oq law)
19
+ * @param {ReactNode} leading anything else in the icon column (a check, a thumb) — wins over icon
20
+ * @param {ReactNode} label
21
+ * @param {ReactNode} hint a second line under the label
22
+ * @param {ReactNode} trailing right-aligned (a shortcut, a count, a menu button)
23
+ * @param {string} as element to render (`div` · `button` · `li`)
24
+ * @param {boolean} fill false = the caller paints the state fills (ColumnBrowser's trail vs
25
+ * deepest-selection is decided in CSS by column); the ink still follows
26
+ * @param {boolean} muted a row that is there but not the point (fg-48)
27
+ *
28
+ * `ROW_HEIGHT` / `ROW_TYPE` are exported for a row that cannot take this anatomy (the media rows
29
+ * view's table row: twisty, drag hit, columns) — it still sits on the same ramp.
30
+ */
31
+ import { Icon } from '@kolkrabbi/kol-icons'
32
+ import { glyphSize } from '../hooks/glyphLadders.js'
33
+
34
+ /* the box: min-height on the ramp, and a pad that lands one line of the step's type exactly on it —
35
+ * xs 2+1+16+1+2 = 22 · sm 4+1+16+1+4 = 26 · md 6+1+18+1+6 = 32 · lg 10+1+18+1+10 = 40 */
36
+ export const ROW_HEIGHT = {
37
+ xs: 'min-h-[var(--kol-ctl-xs)] py-0.5',
38
+ sm: 'min-h-[var(--kol-ctl-sm)] py-1',
39
+ md: 'min-h-[var(--kol-ctl-md)] py-1.5',
40
+ lg: 'min-h-[var(--kol-ctl-lg)] py-2.5',
41
+ }
42
+ export const ROW_TYPE = { xs: 'kol-mono-12', sm: 'kol-mono-12', md: 'kol-mono-14', lg: 'kol-mono-14' }
43
+
44
+ export default function OptionRow({
45
+ as: Tag = 'div',
46
+ size = 'md',
47
+ active = false,
48
+ selected = false,
49
+ trail = false,
50
+ fill: paints = true,
51
+ muted = false,
52
+ icon,
53
+ leading,
54
+ label,
55
+ hint,
56
+ trailing,
57
+ className = '',
58
+ children,
59
+ ...rest
60
+ }) {
61
+ const lit = active || selected || trail
62
+ /* hover takes the active fill — the palette already moves `active` on hover; a list without a
63
+ * keyboard cursor gets the same look for free. `fill={false}` rows paint nothing (the columns) */
64
+ const bg = !paints ? '' : active
65
+ ? 'bg-[var(--kol-tone-bg,var(--kol-surface-secondary))]'
66
+ : `${selected ? 'bg-oq-08' : trail ? 'bg-oq-04' : ''} hover:bg-[var(--kol-tone-bg,var(--kol-surface-secondary))]`
67
+ const fill = `${bg} ${lit ? 'text-fg' : muted ? 'text-fg-48' : 'text-fg-80'}`
68
+ const lead = leading ?? (icon && <Icon name={icon} size={glyphSize(size)} />)
69
+ return (
70
+ <Tag
71
+ className={`flex items-center gap-2 px-4 border border-transparent ${ROW_HEIGHT[size] ?? ROW_HEIGHT.md} rounded-[var(--kol-radius-sm)] cursor-pointer text-left ${ROW_TYPE[size] ?? ROW_TYPE.md} transition-colors ${fill} ${className}`.trim()}
72
+ {...rest}
73
+ >
74
+ {lead && <span aria-hidden="true" className="flex shrink-0 text-oq-48">{lead}</span>}
75
+ <span className="flex flex-1 flex-col min-w-0">
76
+ <span className="truncate">{label ?? children}</span>
77
+ {hint && <span className="kol-mono-12 text-fg-48 truncate">{hint}</span>}
78
+ </span>
79
+ {trailing && <span className="flex shrink-0 items-center">{trailing}</span>}
80
+ </Tag>
81
+ )
82
+ }
@@ -3,6 +3,7 @@ import { Icon } from '@kolkrabbi/kol-icons'
3
3
  import KindPreview, { KIND_GLYPH } from '../molecules/KindPreview.jsx'
4
4
  import FileIcon from '../atoms/FileIcon.jsx'
5
5
  import RowMenuButton from '../molecules/RowMenuButton.jsx'
6
+ import OptionRow from '../molecules/OptionRow.jsx'
6
7
  import { formatLength } from '../molecules/AudioPreview.jsx'
7
8
  import { kindOf as dsKindOf, KIND_LABEL as DS_KIND_LABEL } from '../utilities/mediaKinds.js'
8
9
  import useGrabEdge from '../hooks/useGrabEdge.js'
@@ -248,7 +249,7 @@ const ZONE_BOX = 44
248
249
  * without the stack: the columns pass none, so a desktop row is byte-identical
249
250
  * to what it was. */
250
251
  function Row({
251
- icon, label, active, cursor = false, trailing, onClick, muted = false,
252
+ icon, label, active, cursor = false, trailing, onClick, muted = false, size = 'md',
252
253
  indent = 0, meta, zones = false, thumb, onDisclose, disclosed, onContextMenu, drop, dropFiles, markKey,
253
254
  }) {
254
255
  const [over, setOver] = useState(false)
@@ -273,6 +274,30 @@ function Row({
273
274
  drop.onDrop(drop.path)
274
275
  },
275
276
  } : {}
277
+ /* THE DESKTOP ROW IS AN OptionRow (2026-09-30) — the palette's row, on the control ramp, so a size
278
+ * setting scales height, glyph and type together. The fills stay this file's CSS (trail vs the
279
+ * deepest column's selection is decided by column there, and hover/cursor paint nothing — the
280
+ * 2026-09-02 ruling), so `fill={false}`. The `zones` row below is the phone's and keeps its own. */
281
+ if (!zones) return (
282
+ <OptionRow
283
+ as="li"
284
+ size={size}
285
+ fill={false}
286
+ selected={active}
287
+ muted={muted && !cursor}
288
+ icon={icon}
289
+ label={label}
290
+ hint={meta}
291
+ trailing={<>{trailing}<RowMenuButton onOpen={onContextMenu} className="-my-2 -mr-2" /></>}
292
+ onContextMenu={onContextMenu}
293
+ {...dropProps}
294
+ data-marquee-key={markKey}
295
+ data-drop-over={over || undefined}
296
+ className={`kol-column-browser-row${active ? ' is-selected' : ''}${cursor ? ' is-cursor' : ''}`}
297
+ onClick={onClick}
298
+ style={indent ? { paddingLeft: `calc(var(--kol-spacing-4) + ${indent} * var(--kol-spacing-5))` } : undefined}
299
+ />
300
+ )
276
301
  return (
277
302
  <li
278
303
  onContextMenu={onContextMenu}
@@ -521,6 +546,9 @@ export default function ColumnBrowser({
521
546
  /* `folderIcon(path)` — the glyph a folder row wears (default `folder`). The media pages mark a
522
547
  * bucket — a SOURCE, not a prefix — with `database` (user 2026-09-23). */
523
548
  folderIcon,
549
+ /* `rowSize` — the desktop rows' step on the control ramp (xs · sm · md · lg = 22 · 26 · 32 · 40),
550
+ * height, glyph and type together (user 2026-09-30). md matches the search field. */
551
+ rowSize = 'md',
524
552
  /* THE TWO SEAMS THE MOBILE TICKET'S RULINGS POINT AT (ColumnBrowserMobileViews,
525
553
  * kol-r2b2 2026-09-03). Both are questions the DS must not answer for a
526
554
  * consumer, so neither is computed here:
@@ -1006,6 +1034,7 @@ export default function ColumnBrowser({
1006
1034
  {folders.map((f, i) => (
1007
1035
  <Row
1008
1036
  key={f}
1037
+ size={rowSize}
1009
1038
  icon={folderIcon?.(level + f) ?? 'folder'}
1010
1039
  label={f.replace(/\/$/, '')}
1011
1040
  markKey={level + f}
@@ -1021,6 +1050,7 @@ export default function ColumnBrowser({
1021
1050
  {files.map((o, i) => (
1022
1051
  <Row
1023
1052
  key={o.key}
1053
+ size={rowSize}
1024
1054
  icon={COL_ICON[kindOf(o)] || 'file'}
1025
1055
  label={o.displayKey ?? o.key}
1026
1056
  markKey={o.key}
@@ -1,4 +1,4 @@
1
- import { useState, useMemo } from 'react'
1
+ import { useEffect, useState, useMemo } from 'react'
2
2
  import Tag from '../atoms/Tag.jsx'
3
3
  import Divider from '../atoms/Divider.jsx'
4
4
  import Button from '../atoms/Button.jsx'
@@ -115,6 +115,11 @@ const ContentFilters = ({
115
115
  mutuallyExclusiveFilters = [],
116
116
  customFilterKeys = [],
117
117
  searchKeys = ['label', 'name', 'title', 'type'],
118
+ /* CONTROLLED SEARCH (2026-09-30) — same seam as `filtersOpen`: a consumer that must know the
119
+ * query (media swaps the tree for a flat list while one is typed) or hand one in (⌘Enter from its
120
+ * palette) owns it. Absent, the text stays private, exactly as before. */
121
+ searchValue,
122
+ onSearchChange,
118
123
  headerActions,
119
124
  trailingActions,
120
125
  trailingPlacement = 'auto',
@@ -182,8 +187,15 @@ const ContentFilters = ({
182
187
  setInternalLayout(next)
183
188
  onLayoutChange?.(next)
184
189
  }
185
- const [searchOpen, setSearchOpen] = useState(false)
186
- const [searchText, setSearchText] = useState('')
190
+ const [searchOpen, setSearchOpen] = useState(!!searchValue)
191
+ const [internalSearch, setInternalSearch] = useState('')
192
+ const searchText = searchValue !== undefined ? searchValue : internalSearch
193
+ const setSearchText = (next) => {
194
+ setInternalSearch(next)
195
+ onSearchChange?.(next)
196
+ }
197
+ /* a query handed in from outside opens the field it lives in */
198
+ useEffect(() => { if (searchValue) setSearchOpen(true) }, [searchValue])
187
199
 
188
200
 
189
201
  const toggleFilter = (filterType, value) => {
@@ -1,5 +1,7 @@
1
- import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
1
+ import { Fragment, createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
2
2
  import formatSize from '../utilities/formatSize.js'
3
+ import { ROW_HEIGHT, ROW_TYPE } from '../molecules/OptionRow.jsx'
4
+ import { glyphSize } from '../hooks/glyphLadders.js'
3
5
  import { Icon } from '@kolkrabbi/kol-icons'
4
6
  import Button from '../atoms/Button.jsx'
5
7
  import Divider from '../atoms/Divider.jsx'
@@ -299,11 +301,17 @@ function TagEditor({ tags = [], onChange, suggestions = [] }) {
299
301
  )
300
302
  }
301
303
 
304
+ /* THE ROWS VIEW'S STEP ON THE CONTROL RAMP (user 2026-09-30) — the same `rowSize` the columns take,
305
+ * handed down by context rather than threaded through every subtree level. These rows keep their
306
+ * own anatomy (twisty, drag hit, table columns) and take only the ramp: box, type, glyph. */
307
+ const RowSize = createContext('md')
308
+
302
309
  /* `depth` indents a row under the folder it was expanded from; `expanded`/`onToggle` draw the
303
310
  * disclosure twisty. A folder row without `onToggle` keeps the old behaviour exactly — one chevron
304
311
  * that navigates — so nothing that already renders these moves. */
305
312
  function FolderRow({ name, icon = 'folder', onClick, onDoubleClick, depth = 0, expanded, onToggle, meta, cols, onContextMenu, drag, dropFiles, selected, current, markKey }) {
306
313
  const [over, setOver] = useState(false)
314
+ const size = useContext(RowSize)
307
315
  /* A FOLDER IS A DROP TARGET. `drag` carries the page's move verb and the path this row is; the
308
316
  * row highlights only while something is actually over it, so an accidental hover reads as
309
317
  * nothing. Without `drag` every handler below is undefined and the row is exactly what it was. */
@@ -333,7 +341,7 @@ function FolderRow({ name, icon = 'folder', onClick, onDoubleClick, depth = 0, e
333
341
  return (
334
342
  /* THE COLUMN BROWSER'S ROW (user 2026-09-22): `kol-column-browser-row` carries the 4px pill
335
343
  * inset, the selected fill and the drop fill; no divider, as the columns have none. */
336
- <li onContextMenu={onContextMenu} {...dropProps} data-marquee-key={markKey} data-hit-zone data-drop-over={over || undefined} className={`kol-column-browser-row flex items-center gap-3 py-2 px-3 transition-colors${selected ? ' is-selected' : ''}${current ? ' is-current' : ''}`} style={{ paddingLeft: depth * 20 + 12 }} onClick={onClick} onDoubleClick={onDoubleClick}>
344
+ <li onContextMenu={onContextMenu} {...dropProps} data-marquee-key={markKey} data-hit-zone data-drop-over={over || undefined} className={`kol-column-browser-row flex items-center gap-3 border border-transparent px-3 ${ROW_HEIGHT[size]} transition-colors${selected ? ' is-selected' : ''}${current ? ' is-current' : ''}`} style={{ paddingLeft: depth * 20 + 12 }} onClick={onClick} onDoubleClick={onDoubleClick}>
337
345
  {onToggle ? (
338
346
  <button type="button" aria-label={expanded ? `Collapse ${name}` : `Expand ${name}`} aria-expanded={!!expanded}
339
347
  onClick={(e) => { e.stopPropagation(); onToggle() }}
@@ -345,11 +353,11 @@ function FolderRow({ name, icon = 'folder', onClick, onDoubleClick, depth = 0, e
345
353
  {/* THE FOLDER GLYPH IS THE ONE ICON LEFT, so it fills its box (user 2026-09-23: *"only folder
346
354
  should use icon, and it should be bigger"*). Every other kind now renders its own bytes. */}
347
355
  <span data-hit {...hitDrag} className="kol-row-hit flex items-center gap-3 min-w-0">
348
- <span className="w-8 h-8 shrink-0 flex items-center justify-center text-oq-48"><Icon name={icon} size={26} /></span>
356
+ <span className="shrink-0 flex items-center justify-center text-oq-48"><Icon name={icon} size={glyphSize(size)} /></span>
349
357
  {/* NO TRAILING SLASH (user 2026-09-22). `partition` hands folders back as `audio/` because a
350
358
  slash is what marks one in a flat key space; that is storage's spelling, not a label, and
351
359
  `ColumnBrowser` has always stripped it. The glyph says "folder". */}
352
- <span className="kol-mono-12 min-w-0 truncate text-fg-default">{name.replace(/\/$/, '')}</span>
360
+ <span className={`${ROW_TYPE[size]} min-w-0 truncate text-fg-default`}>{name.replace(/\/$/, '')}</span>
353
361
  </span>
354
362
  <span className="flex-1" />
355
363
  {/* THE FOLDER'S COUNT SITS IN THE DATE COLUMN and the size column takes a dash — the columns
@@ -419,17 +427,18 @@ function RowSubtree({ level, depth, rowLevel, expanded, onToggle, onOpenFolder,
419
427
  * `#img/` drew two rows where the column browser drew four — the two folders, the empty folder the
420
428
  * columns knew about, and a file. A list view that cannot show a file is not a list view. */
421
429
  function FileRow({ o, onClick, onDoubleClick, depth = 0, formatDate, thumb, cols, onContextMenu, drag, selected, markKey }) {
430
+ const size = useContext(RowSize)
422
431
  const dragProps = drag ? { draggable: true, onDragStart: (e) => { e.stopPropagation(); drag.onDragStart(e, o.key) } } : {}
423
432
  return (
424
- <li onContextMenu={onContextMenu} data-marquee-key={markKey ?? o.key} data-hit-zone className={`kol-column-browser-row flex items-center gap-3 py-2 px-3 transition-colors${selected ? ' is-selected' : ''}`} style={{ paddingLeft: depth * 20 + 12 }} onClick={onClick} onDoubleClick={onDoubleClick}>
433
+ <li onContextMenu={onContextMenu} data-marquee-key={markKey ?? o.key} data-hit-zone className={`kol-column-browser-row flex items-center gap-3 border border-transparent px-3 ${ROW_HEIGHT[size]} transition-colors${selected ? ' is-selected' : ''}`} style={{ paddingLeft: depth * 20 + 12 }} onClick={onClick} onDoubleClick={onDoubleClick}>
425
434
  <span className="w-4 shrink-0" />
426
435
  {/* the hit — icon and name, as in the folder row above */}
427
436
  <span data-hit {...dragProps} className="kol-row-hit flex items-center gap-3 min-w-0">
428
- <span className="w-8 h-8 shrink-0 flex items-center justify-center text-oq-48 overflow-hidden rounded">
437
+ <span className="shrink-0 flex items-center justify-center text-oq-48 overflow-hidden rounded" style={{ width: glyphSize(size), height: glyphSize(size) }}>
429
438
  {/* a file with no thumbnail draws its glyph at the FOLDER's size — one icon column (user 2026-09-25) */}
430
- {thumb ?? <Icon name={kindOf(o) === 'image' ? 'image' : 'file'} size={26} />}
439
+ {thumb ?? <Icon name={kindOf(o) === 'image' ? 'image' : 'file'} size={glyphSize(size)} />}
431
440
  </span>
432
- <span className="kol-mono-12 min-w-0 truncate text-fg-default">{o.displayKey ?? o.key.split('/').pop()}</span>
441
+ <span className={`${ROW_TYPE[size]} min-w-0 truncate text-fg-default`}>{o.displayKey ?? o.key.split('/').pop()}</span>
433
442
  </span>
434
443
  <span className="flex-1" />
435
444
  {cols && <>
@@ -1058,6 +1067,11 @@ export function MediaLibraryBrowse({
1058
1067
  * media-hub, "two too many"). Given, the gear calls it and this page opens no drawer of its own —
1059
1068
  * inside a Hub the Hub's Settings is the one place. Absent, the gear opens the drawer as always. */
1060
1069
  onOpenSettings,
1070
+ /* `searchSuggestions` — what the palette lists before you type. Absent: favourites, smart
1071
+ * folders, then the bucket's top-level folders. An array replaces them; a function receives them
1072
+ * and returns the list. Rows are the palette's shape; a row with `run` calls it instead of
1073
+ * jumping to `id` as a file key. */
1074
+ searchSuggestions,
1061
1075
  /* `bucketLevel` — keep title → bucket → folders with ONE bucket (kol-client-olina 2026-09-23).
1062
1076
  * Absent, a one-bucket consumer collapses the level as ruled 2026-09-03. */
1063
1077
  bucketLevel = false,
@@ -1593,6 +1607,9 @@ export function MediaLibraryBrowse({
1593
1607
  * you, which is what the filter bar is for. */
1594
1608
  const [searchOpen, setSearchOpen] = useState(false)
1595
1609
  const [searchQuery, setSearchQuery] = useState('')
1610
+ /* the filter bar's own query — lifted so a typed one swaps the tree for a flat list, and so the
1611
+ * palette's ⌘Enter can hand its query over */
1612
+ const [barQuery, setBarQuery] = useState('')
1596
1613
  /* THE ARROWS IN THE GRID AND THE ROWS (user 2026-09-23: *"arrow navigation doesnt work in grid
1597
1614
  * mode? nor in row mode"*). The columns have always had theirs (`ColumnBrowser`); these are the
1598
1615
  * same keys for the other two views, and every one is listed in the shortcuts sheet.
@@ -1929,8 +1946,12 @@ export function MediaLibraryBrowse({
1929
1946
  .map((o) => ({ ...o, kind: kindOf(o), poster: posterFor(o.key, keySet) }))
1930
1947
  const filtersOn = !!settings.filters
1931
1948
  /* the bar filters FILES, and a tree is drawn from the files under it — so one filtered list
1932
- * serves both: the wall renders it, the tree is scoped to the keys in it */
1933
- const filterItems = isWall ? wallFiles : scoped.filter((o) => !isSystemFile(o.key)).map((o) => ({ ...o, kind: kindOf(o), displayKey: prefix ? o.key.slice(prefix.length) : o.key }))
1949
+ * serves both: the wall renders it, the tree is scoped to the keys in it.
1950
+ * THE TREE'S LIST IS THE WHOLE BUCKET, not the folder you stand in (user 2026-09-30: open the
1951
+ * bar at `brand/og/` and `logos`, `original`, `projects` vanished with no filter set). The
1952
+ * columns draw every level up to the root, so a list scoped to `prefix` cut every sibling path
1953
+ * the moment the bar mounted. */
1954
+ const filterItems = isWall ? wallFiles : sortedObjects.filter((o) => !isSystemFile(o.key)).map((o) => ({ ...o, kind: kindOf(o), poster: posterFor(o.key, keySet), displayKey: o.key }))
1934
1955
  const filterKinds = [...new Set(filterItems.map((o) => o.kind))].sort()
1935
1956
  const filterTags = [...new Set(filterItems.flatMap((o) => o.tags ?? []))].sort()
1936
1957
  /* favourites as a one-chip group: ContentFilters matches a value, so a starred file carries one */
@@ -1980,6 +2001,7 @@ export function MediaLibraryBrowse({
1980
2001
  className={atTitleRoot ? 'is-root' : ''}
1981
2002
  height={colH}
1982
2003
  onHeightChange={onColH}
2004
+ rowSize={rowSize}
1983
2005
  columnWidths={settings.columnWidths}
1984
2006
  onColumnResize={(i, px) => setSettings({ ...settings, columnWidths: { ...(settings.columnWidths ?? {}), [i]: px } })}
1985
2007
  objects={treeObjects.filter((o) => !isSystemFile(o.key)).map((o) => ({ ...o, key: `${VROOT}${o.key}` }))}
@@ -2166,13 +2188,23 @@ export function MediaLibraryBrowse({
2166
2188
  * inside it, the same preview pane beside them, the same count line under it — with the size
2167
2189
  * slider at its right end, Finder's. */
2168
2190
  const tileSize = settings.tileSize ?? 180
2191
+ const rowSize = settings.rowSize ?? 'md'
2169
2192
  /* the tile size is the GRID's, not a level's (user 2026-09-24: the slider vanished at the root
2170
2193
  * levels) — one control, drawn wherever the grid is */
2194
+ /* THE TREE VIEWS SCALE TOO (user 2026-09-30: *"we might want to scale everything on slider like we
2195
+ * do in grid view"*) — in steps of the control ramp, not pixels, so a row is always a control height */
2196
+ const RAMP = ['xs', 'sm', 'md', 'lg']
2197
+ const rowSlider = (
2198
+ <input type="range" className="slider-black w-32 cursor-pointer" min={0} max={RAMP.length - 1} step={1} value={Math.max(0, RAMP.indexOf(rowSize))}
2199
+ onChange={(e) => setSettings({ ...settings, rowSize: RAMP[Number(e.target.value)] })} aria-label="Row size" />
2200
+ )
2171
2201
  const sizeSlider = (
2172
2202
  <input type="range" className="slider-black w-32 cursor-pointer" min={100} max={360} step={20} value={tileSize}
2173
2203
  onChange={(e) => setSettings({ ...settings, tileSize: Number(e.target.value) })} aria-label="Tile size" />
2174
2204
  )
2175
- const wallPane = (files) => (
2205
+ /* `hits` = a query's results: no folders, no root level, each file named by its whole path —
2206
+ * in the tree views as a list, the rows' shape; the wall keeps its tiles */
2207
+ const wallPane = (files, hits = false) => (
2176
2208
  <div ref={viewRef} className="flex flex-col gap-2" style={{ height: colH }}>
2177
2209
  <div className="relative border rounded flex overflow-hidden flex-1 min-h-0" style={{ borderColor: 'var(--kol-oq-08)' }}>
2178
2210
  {/* THE MARQUEE IS THE PANE'S, not the tile grid's (user 2026-09-23: a drag only started where
@@ -2185,7 +2217,7 @@ export function MediaLibraryBrowse({
2185
2217
  onContextMenu={titleLevel ? undefined : (e) => menu.openAt(e, { type: 'level', path: prefix })}
2186
2218
  onClick={(e) => { if (!e.target.closest('[data-marquee-key]')) { setRowSelection(new Set()); setPickedFile(null); setPickedFolder(null); setPickedBucket(null) } }}>
2187
2219
  {gridMarquee.rect && <div className="kol-marquee" style={gridMarquee.rect} />}
2188
- {titleLevel ? (
2220
+ {titleLevel && !hits ? (
2189
2221
  <div className="grid gap-x-3 gap-y-5" style={{ gridTemplateColumns: `repeat(auto-fill, minmax(${tileSize}px, 1fr))` }}>
2190
2222
  {rootItems.map((it) => (
2191
2223
  <MediaTile key={it.key} preview={<Icon name={it.icon} size="100%" className="text-oq-48" />} name={it.name}
@@ -2193,11 +2225,12 @@ export function MediaLibraryBrowse({
2193
2225
  onClick={(e) => { e.stopPropagation(); setPickedBucket(it.key) }} onDoubleClick={it.open} />
2194
2226
  ))}
2195
2227
  </div>
2196
- ) : <WallBody files={files} layout="grid" tiles="media" cardMin={tileSize} onSorted={(list) => { gridOrderRef.current = list }}
2228
+ ) : <WallBody files={files} layout={hits && !isWall ? 'list' : 'grid'} tiles="media" cardMin={tileSize} onSorted={(list) => { gridOrderRef.current = list }}
2197
2229
  /* THE GRID DRAWS THE LEVEL'S SUBFOLDERS, ahead of the files (same source: kol-client-olina
2198
2230
  * 2026-09-23 — `projects/` read `0 files` over a blank pane). `flat` is the subtree's files
2199
2231
  * and no folders, as it was. */
2200
- folders={settings.flat || smartMatches ? [] : folders.map((f) => prefix + f)}
2232
+ folders={settings.flat || smartMatches || hits ? [] : folders.map((f) => prefix + f)}
2233
+ {...(hits && { renderName: (o) => o.key })}
2201
2234
  onOpenFolder={goFolder}
2202
2235
  onPickFolder={(path, e) => { if (selectRow(path, e, [...folders.map((f) => prefix + f), ...files.map((f) => f.key)])) return; rowPickFolder(path) }}
2203
2236
  onFolderContextMenu={(e, path) => menu.openAt(e, { type: 'folder', path, targets: targetsFor(path) })}
@@ -2230,6 +2263,20 @@ export function MediaLibraryBrowse({
2230
2263
  hint: o.key.slice(0, o.key.lastIndexOf('/') + 1) || 'the bucket root',
2231
2264
  group: KIND_LABEL[kindOf(o)] || 'File',
2232
2265
  }))
2266
+ /* THE PALETTE OPENS ON SOMEWHERE TO GO (user 2026-09-30: it opened blank in olina). */
2267
+ const openFolder = (path) => { setAppRoot(false); goFolder(path) }
2268
+ const leafOf = (path) => path.replace(/\/$/, '').split('/').pop()
2269
+ const hintOf = (path) => dirOf(path) || 'the bucket root'
2270
+ const defaultSuggestions = [
2271
+ ...Object.keys(folderRows ?? {}).filter((p) => folderRows[p]?.favourite).sort()
2272
+ .map((p) => ({ id: p, label: leafOf(p), hint: hintOf(p), group: 'Favourites', icon: 'folder', run: () => openFolder(p) })),
2273
+ ...objects.filter((o) => o.favourite && !isSystemFile(o.key))
2274
+ .map((o) => ({ id: o.key, label: leafOf(o.key), hint: hintOf(o.key), group: 'Favourites', icon: 'file' })),
2275
+ ...smartFolders.map((sf) => ({ id: `smart:${sf.id}`, label: sf.name, group: 'Smart folders', icon: 'folder', run: () => setActiveSmart(sf.id) })),
2276
+ ...[...new Set([...treeFolders(''), ...partition(objects.filter((o) => !isSystemFile(o.key)), '').folders])].sort()
2277
+ .map((f) => ({ id: f, label: leafOf(f), group: 'Folders', icon: 'folder', run: () => openFolder(f) })),
2278
+ ]
2279
+ const suggestions = typeof searchSuggestions === 'function' ? searchSuggestions(defaultSuggestions) : (searchSuggestions ?? defaultSuggestions)
2233
2280
  const jumpTo = (key) => {
2234
2281
  const o = objects.find((x) => x.key === key)
2235
2282
  setSearchOpen(false); setSearchQuery('')
@@ -2249,14 +2296,20 @@ export function MediaLibraryBrowse({
2249
2296
  if (q.text && !filterMedia([o], q.text).length) return false
2250
2297
  return true
2251
2298
  }).map((o) => ({ ...o, kind: kindOf(o), poster: posterFor(o.key, keySet), displayKey: o.key })) : null
2299
+ /* A TYPED QUERY IS A RESULTS LIST (user 2026-09-30, reversing "a flat results list nobody asked
2300
+ * for"): pruning the tree to matching paths hid the files one level down and read as "search only
2301
+ * finds folders". While the bar holds a query, the tree views show every hit flat, each with its
2302
+ * path; clearing it brings the tree back. The wall already was a flat list. */
2252
2303
  const body = (filtered) => (smartMatches
2253
2304
  ? wallPane(filtered ?? smartMatches)
2305
+ : filtered && barQuery.trim() ? wallPane(filtered, true)
2254
2306
  : isWall
2255
2307
  ? wallPane(filtered ?? wallFiles)
2256
2308
  : treeBody(filtered ? new Set(filtered.map((f) => f.key)) : null))
2257
2309
 
2258
2310
  return (
2259
2311
  <div ref={pageRef} {...press} className={`flex flex-col gap-6 ${className}`.trim()}>
2312
+ <RowSize.Provider value={rowSize}>
2260
2313
  <LibraryHeader title={title} buckets={buckets} bucketId={bucketMeta.id} appRoot={atTitleRoot} bucketMeta={bucketMeta} writable={writable} headerActions={headerActions} headerTrailing={headerTrailing} onTrash={trash ? () => setTrashOpen(true) : undefined}
2261
2314
  onHome={goTitleRoot}
2262
2315
  onBucket={(v) => { if (v === 'all') goTitleRoot(); else { setAppRoot(false); switchBucket(v) } }}
@@ -2440,7 +2493,10 @@ export function MediaLibraryBrowse({
2440
2493
  placeholder="Search this bucket"
2441
2494
  results={searchResults}
2442
2495
  onExpand={() => searchResults[0] && jumpTo(searchResults[0].id)}
2443
- onSelect={(item) => jumpTo(item.id)}
2496
+ onSelect={(item) => (item.run ? item.run() : jumpTo(item.id))}
2497
+ suggestions={suggestions}
2498
+ enterLabel="Go to file"
2499
+ onOpenResults={(q) => { setSettings({ ...settings, filters: true }); setBarQuery(q) }}
2444
2500
  />
2445
2501
  )}
2446
2502
  {quickLookFolder && (
@@ -2490,6 +2546,8 @@ export function MediaLibraryBrowse({
2490
2546
  title="Files"
2491
2547
  totalCount={filterItems.length}
2492
2548
  searchKeys={['displayKey']}
2549
+ searchValue={barQuery}
2550
+ onSearchChange={setBarQuery}
2493
2551
  filterGroups={[{ label: 'Kind', key: 'kind', values: filterKinds }, ...(filterTags.length ? [{ label: 'Tags', key: 'tags', values: filterTags }] : []), ...(anyStarred ? [{ label: 'Favourites', key: 'starred', values: ['favourite'] }] : [])]}
2494
2552
  mutuallyExclusiveFilters={['kind']}
2495
2553
  /* ONE FUNNEL, NOT TWO (2026-09-22): the crumb row's funnel mounts this bar AND opens
@@ -2513,7 +2571,7 @@ export function MediaLibraryBrowse({
2513
2571
  {atTop ? '1 folder' : `${bucketList.length} ${bucketList.length === 1 ? 'bucket' : 'buckets'}`}
2514
2572
  {folderTree && ` · ${Object.values(folderTree).reduce((n, t) => n + (t.files ?? 0), 0)} files · ${formatSize(Object.values(folderTree).reduce((n, t) => n + (t.bytes ?? 0), 0))}`}
2515
2573
  </p>
2516
- {isWall && sizeSlider}
2574
+ {isWall ? sizeSlider : rowSlider}
2517
2575
  </div>
2518
2576
  ) : isWall ? (
2519
2577
  /* ONE LINE'S HEIGHT, like the other views' count line — the slider is 24px tall and made
@@ -2665,6 +2723,7 @@ export function MediaLibraryBrowse({
2665
2723
  </>
2666
2724
  )}
2667
2725
  </div>
2726
+ </RowSize.Provider>
2668
2727
  </div>
2669
2728
  )
2670
2729
  }
@@ -3,11 +3,11 @@ import { Icon } from '@kolkrabbi/kol-icons'
3
3
  import SearchInput from '../molecules/SearchInput.jsx'
4
4
  import Tag from '../atoms/Tag.jsx'
5
5
  import Kbd from '../atoms/Kbd.jsx'
6
+ import OptionRow from '../molecules/OptionRow.jsx'
6
7
 
7
8
  /* ONE COLUMN (2026-09-30): rows, headings and footer sit on the field's own
8
9
  * geometry — the p-2 inset, then the md control's 1px ring + 16px pad — so
9
10
  * every glyph and word lines up under the field's icon and caret. */
10
- import { glyphSize } from '../hooks/glyphLadders.js'
11
11
 
12
12
  /* Rows bucketed by `group` in first-seen order, rank kept inside a group —
13
13
  * one heading per group; arrows rove this order, not the engine's. */
@@ -87,6 +87,9 @@ export function HighlightMatch({ label, query, ranges }) {
87
87
  * the panel's last line, so Enter is never a surprise
88
88
  * (e.g. `All results for “q”`). Rows may carry
89
89
  * `highlights` ([start, end] ranges) from the engine.
90
+ * @param {Function} [onOpenResults] (query) => void — ⌘/Ctrl+Enter: every hit, on the consumer's
91
+ * own results surface. Plain Enter keeps its meaning.
92
+ * @param {string} [resultsLabel] the footer's second line for ⌘Enter (e.g. `All results`)
90
93
  */
91
94
  export default function ShellSearchOverlay({
92
95
  open,
@@ -107,6 +110,8 @@ export default function ShellSearchOverlay({
107
110
  onSelect,
108
111
  placeholder = 'Search…',
109
112
  enterLabel,
113
+ onOpenResults,
114
+ resultsLabel = 'All results',
110
115
  }) {
111
116
  const panelRef = useRef(null)
112
117
  const listRef = useRef(null)
@@ -160,6 +165,9 @@ export default function ShellSearchOverlay({
160
165
  setActiveIndex((i) => Math.max(i - 1, 0))
161
166
  } else if (e.key === 'Enter') {
162
167
  e.preventDefault()
168
+ /* ⌘ENTER OPENS EVERY HIT (user 2026-09-30: *"enter would focus searched item, and maybe
169
+ * command enter would open the results page"*) — only where the consumer has a place for them. */
170
+ if ((e.metaKey || e.ctrlKey) && onOpenResults && query) { onOpenResults(query); onClose?.(); return }
163
171
  /* Enter COMMITS the query and expands. It only selects when the user has
164
172
  * actually arrowed to a row — `activeIndex` starts at 0, so "a row is
165
173
  * highlighted" is true from the first keystroke and testing `active >= 0`
@@ -256,33 +264,19 @@ export default function ShellSearchOverlay({
256
264
  {heading && item.group && (
257
265
  <p className="kol-helper-12 text-fg-48 px-4 border-x border-transparent pt-3 pb-2">{item.group}</p>
258
266
  )}
259
- <div
267
+ <OptionRow
260
268
  id={optionId(item)}
261
269
  role="option"
262
270
  aria-selected={i === active}
271
+ active={i === active}
272
+ icon={item.icon}
273
+ label={<HighlightMatch label={item.label} query={query} ranges={item.highlights} />}
274
+ hint={item.hint}
263
275
  /* preventDefault keeps focus in the input through the click */
264
276
  onMouseDown={(e) => e.preventDefault()}
265
277
  onClick={() => select(item)}
266
278
  onMouseEnter={() => { setActiveIndex(i); setNavigated(true) }}
267
- /* the md control's box: 1px ring + 6/16 pad + gap-2 → 32px, same as the field */
268
- className={`flex items-center gap-2 px-4 py-1.5 border border-transparent min-h-[var(--kol-ctl-md)] rounded-[var(--kol-radius-sm)] cursor-pointer kol-mono-14 transition-colors ${
269
- i === active ? 'bg-[var(--kol-tone-bg,var(--kol-surface-secondary))] text-fg' : 'text-fg-80'
270
- }`}
271
- >
272
- {item.icon && (
273
- <span aria-hidden="true" className="flex shrink-0 text-oq-48">
274
- <Icon name={item.icon} size={glyphSize('md')} />
275
- </span>
276
- )}
277
- <span className="flex flex-col min-w-0">
278
- <span className="truncate">
279
- <HighlightMatch label={item.label} query={query} ranges={item.highlights} />
280
- </span>
281
- {item.hint && (
282
- <span className="kol-mono-12 text-fg-48 truncate">{item.hint}</span>
283
- )}
284
- </span>
285
- </div>
279
+ />
286
280
  </li>
287
281
  )
288
282
  })}
@@ -295,6 +289,12 @@ export default function ShellSearchOverlay({
295
289
  <Kbd icon="corner-down-left" />
296
290
  {query ? enterLabel : 'Go to page'}
297
291
  </p>
292
+ {query && onOpenResults && (
293
+ <p className="flex items-center gap-2 kol-helper-12 text-fg-48 mx-2 px-4 border-x border-transparent pt-1">
294
+ <Kbd icon="command"><Icon name="corner-down-left" size={12} /></Kbd>
295
+ {resultsLabel}
296
+ </p>
297
+ )}
298
298
  </div>
299
299
  )}
300
300
  </div>