@kolkrabbi/kol-component 0.231.0 → 0.233.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.233.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",
@@ -25,8 +25,8 @@
25
25
  "embla-carousel-react": "^8.6.0",
26
26
  "pdfjs-dist": "^6.3.289",
27
27
  "react-syntax-highlighter": "^16.1.1",
28
- "@kolkrabbi/kol-search": "^0.2.0",
29
- "@kolkrabbi/kol-markdown": "^0.1.2"
28
+ "@kolkrabbi/kol-markdown": "^0.1.2",
29
+ "@kolkrabbi/kol-search": "^0.2.0"
30
30
  },
31
31
  "peerDependencies": {
32
32
  "@kolkrabbi/kol-icons": ">=0.22.0",
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,22 @@ 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
+ className="w-full"
101
104
  onClick={() => {
102
105
  onChange?.(val)
103
106
  setOpen(false)
104
107
  }}
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>
108
+ /* check column always reserved — labels align whether or
109
+ * not a row carries the mark (reference grammar) */
110
+ leading={<span className="inline-flex w-4 justify-center">{val === value && <Icon name="check" size={12} />}</span>}
111
+ label={label}
112
+ />
113
113
  </li>
114
114
  )
115
115
  })}
@@ -28,7 +28,7 @@ export default function MediaTile({ preview, name, selected = false, markKey, on
28
28
  onContextMenu={onContextMenu}
29
29
  onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); onDoubleClick?.(e) } }}>
30
30
  <div className="kol-media-tile-thumb">{preview}</div>
31
- <span className="kol-media-tile-name">{name}</span>
31
+ <span className="kol-media-tile-name kol-item-name">{name}</span>
32
32
  <RowMenuButton variant="grey" onOpen={onContextMenu} className="absolute top-1 right-1" />
33
33
  </div>
34
34
  )
@@ -0,0 +1,91 @@
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
+ * selected — the picked item: the tone's PRESSED fill (white in dark, as the grid's picked name)
11
+ * active — the keyboard cursor / hover: the tone fill, full ink
12
+ * trail — on the path to the picked item (a browser's parent folders): oq-08
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} type a type class instead of the ramp's (file lists: `kol-item-name`)
19
+ * @param {string} icon kol-icons name, drawn in oq (icons-use-oq law)
20
+ * @param {ReactNode} leading anything else in the icon column (a check, a thumb) — wins over icon
21
+ * @param {ReactNode} label
22
+ * @param {ReactNode} hint secondary text — beside the label, right-aligned on its baseline
23
+ * @param {ReactNode} trailing right-aligned (a shortcut, a count, a menu button)
24
+ * @param {string} as element to render (`div` · `button` · `li`)
25
+ * @param {boolean} hover false = hover paints nothing (the media browser's ruling, 2026-09-02)
26
+ * @param {boolean} muted a row that is there but not the point (fg-48)
27
+ *
28
+ * `ROW_HEIGHT` / `ROW_TYPE` / `ROW_FILL` are exported for a row that cannot take this anatomy (the
29
+ * media rows view's table row: twisty, drag hit, columns) — it still sits on the same ramp and fills.
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
+ const TONE = 'bg-[var(--kol-tone-bg,var(--kol-surface-secondary))]'
45
+ /* SELECTED IS THE TONE'S PRESSED STATE (user 2026-09-30: *"use white as focused item like grid
46
+ * already does"*) — `--kol-tone-pressed-*`, the fill a pressed button takes; outside a tone, the
47
+ * same surface-on-primary / surface-primary pair the grid's picked name paints. */
48
+ export const ROW_FILL = {
49
+ selected: 'bg-[var(--kol-tone-pressed-bg,var(--kol-surface-on-primary))] text-[var(--kol-tone-pressed-fg,var(--kol-surface-primary))]',
50
+ active: `${TONE} text-fg`,
51
+ trail: 'bg-oq-08 text-fg',
52
+ }
53
+
54
+ export default function OptionRow({
55
+ as: Tag = 'div',
56
+ size = 'md',
57
+ type,
58
+ active = false,
59
+ selected = false,
60
+ trail = false,
61
+ hover = true,
62
+ muted = false,
63
+ icon,
64
+ leading,
65
+ label,
66
+ hint,
67
+ trailing,
68
+ className = '',
69
+ children,
70
+ ...rest
71
+ }) {
72
+ const state = selected ? ROW_FILL.selected : active ? ROW_FILL.active : trail ? ROW_FILL.trail
73
+ : `${muted ? 'text-fg-48' : 'text-fg-80'}${hover ? ` hover:${TONE}` : ''}`
74
+ const lead = leading ?? (icon && <Icon name={icon} size={glyphSize(size)} />)
75
+ return (
76
+ <Tag
77
+ 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 ${type ?? ROW_TYPE[size] ?? ROW_TYPE.md} transition-colors ${state} ${className}`.trim()}
78
+ {...rest}
79
+ >
80
+ {/* the glyph takes the row's ink when selected — a grey mark on a white fill reads as a hole */}
81
+ {lead && <span aria-hidden="true" className={`flex shrink-0${selected ? '' : ' text-oq-48'}`}>{lead}</span>}
82
+ {/* ONE LINE (user 2026-09-30): the hint sits beside the label on its baseline, right-aligned,
83
+ * so the names read as one column and a long name never pushes the path around */}
84
+ <span className="flex flex-1 items-baseline gap-3 min-w-0">
85
+ <span className="truncate">{label ?? children}</span>
86
+ {hint && <span className={`kol-mono-12 ml-auto truncate${selected ? ' opacity-60' : ' text-fg-48'}`}>{hint}</span>}
87
+ </span>
88
+ {trailing && <span className="flex shrink-0 items-center">{trailing}</span>}
89
+ </Tag>
90
+ )
91
+ }
@@ -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, trail = false, 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,33 @@ 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 and glyph; the name keeps ONE type in every view (`kol-item-name`, the
279
+ * grid's), as Finder does. The deepest selection takes the pressed (white) fill, the folders on the
280
+ * way to it the trail; hover and the bare cursor paint nothing (the 2026-09-02 ruling). The `zones`
281
+ * row below is the phone's and keeps its own. */
282
+ if (!zones) return (
283
+ <OptionRow
284
+ as="li"
285
+ size={size}
286
+ type="kol-item-name"
287
+ hover={false}
288
+ selected={active && !trail}
289
+ trail={active && trail}
290
+ muted={muted && !cursor}
291
+ icon={icon}
292
+ label={label}
293
+ hint={meta}
294
+ trailing={<>{trailing}<RowMenuButton onOpen={onContextMenu} className="-my-2 -mr-2" /></>}
295
+ onContextMenu={onContextMenu}
296
+ {...dropProps}
297
+ data-marquee-key={markKey}
298
+ data-drop-over={over || undefined}
299
+ className={`kol-column-browser-row${active ? ' is-selected' : ''}${cursor ? ' is-cursor' : ''}`}
300
+ onClick={onClick}
301
+ style={indent ? { paddingLeft: `calc(var(--kol-spacing-4) + ${indent} * var(--kol-spacing-5))` } : undefined}
302
+ />
303
+ )
276
304
  return (
277
305
  <li
278
306
  onContextMenu={onContextMenu}
@@ -521,6 +549,9 @@ export default function ColumnBrowser({
521
549
  /* `folderIcon(path)` — the glyph a folder row wears (default `folder`). The media pages mark a
522
550
  * bucket — a SOURCE, not a prefix — with `database` (user 2026-09-23). */
523
551
  folderIcon,
552
+ /* `rowSize` — the desktop rows' step on the control ramp (xs · sm · md · lg = 22 · 26 · 32 · 40),
553
+ * height, glyph and type together (user 2026-09-30). md matches the search field. */
554
+ rowSize = 'md',
524
555
  /* THE TWO SEAMS THE MOBILE TICKET'S RULINGS POINT AT (ColumnBrowserMobileViews,
525
556
  * kol-r2b2 2026-09-03). Both are questions the DS must not answer for a
526
557
  * consumer, so neither is computed here:
@@ -1006,12 +1037,16 @@ export default function ColumnBrowser({
1006
1037
  {folders.map((f, i) => (
1007
1038
  <Row
1008
1039
  key={f}
1040
+ size={rowSize}
1009
1041
  icon={folderIcon?.(level + f) ?? 'folder'}
1010
1042
  label={f.replace(/\/$/, '')}
1011
1043
  markKey={level + f}
1012
1044
  active={f === activeFolder || isPicked(level + f)}
1045
+ /* a folder on the path is the TRAIL unless it is the deepest pick — the column after
1046
+ * it is its own contents with nothing chosen, and no file is showing */
1047
+ trail={f === activeFolder && !isPicked(level + f) && (!!shown || k < levels.length - 2)}
1013
1048
  cursor={cursorActive && cursor.col === k && cursor.idx === i}
1014
- trailing={<Icon name="chevron-right" size={12} className="text-oq-32" />}
1049
+ trailing={<Icon name="chevron-right" size={12} className={f === activeFolder && !shown && k === levels.length - 2 ? '' : 'text-oq-32'} />}
1015
1050
  onContextMenu={onRowContextMenu && ((e) => onRowContextMenu(e, { type: 'folder', path: level + f }))}
1016
1051
  drop={dragFor?.(level + f)}
1017
1052
  dropFiles={onDropFiles ? (list) => onDropFiles(list, level + f) : undefined}
@@ -1021,6 +1056,7 @@ export default function ColumnBrowser({
1021
1056
  {files.map((o, i) => (
1022
1057
  <Row
1023
1058
  key={o.key}
1059
+ size={rowSize}
1024
1060
  icon={COL_ICON[kindOf(o)] || 'file'}
1025
1061
  label={o.displayKey ?? o.key}
1026
1062
  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_FILL } 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,19 @@ 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 the ramp's box and glyph, OptionRow's
307
+ * fills (picked = the pressed white, the folder you are in = the trail) and the one item type,
308
+ * `kol-item-name`, that the columns and the grid use. */
309
+ const RowSize = createContext('md')
310
+
302
311
  /* `depth` indents a row under the folder it was expanded from; `expanded`/`onToggle` draw the
303
312
  * disclosure twisty. A folder row without `onToggle` keeps the old behaviour exactly — one chevron
304
313
  * that navigates — so nothing that already renders these moves. */
305
314
  function FolderRow({ name, icon = 'folder', onClick, onDoubleClick, depth = 0, expanded, onToggle, meta, cols, onContextMenu, drag, dropFiles, selected, current, markKey }) {
306
315
  const [over, setOver] = useState(false)
316
+ const size = useContext(RowSize)
307
317
  /* A FOLDER IS A DROP TARGET. `drag` carries the page's move verb and the path this row is; the
308
318
  * row highlights only while something is actually over it, so an accidental hover reads as
309
319
  * nothing. Without `drag` every handler below is undefined and the row is exactly what it was. */
@@ -333,7 +343,7 @@ function FolderRow({ name, icon = 'folder', onClick, onDoubleClick, depth = 0, e
333
343
  return (
334
344
  /* THE COLUMN BROWSER'S ROW (user 2026-09-22): `kol-column-browser-row` carries the 4px pill
335
345
  * 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}>
346
+ <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 ${ROW_FILL.selected}` : ''}${current ? ' is-current' : ''}${current && !selected ? ` ${ROW_FILL.trail}` : ''}`} style={{ paddingLeft: depth * 20 + 12 }} onClick={onClick} onDoubleClick={onDoubleClick}>
337
347
  {onToggle ? (
338
348
  <button type="button" aria-label={expanded ? `Collapse ${name}` : `Expand ${name}`} aria-expanded={!!expanded}
339
349
  onClick={(e) => { e.stopPropagation(); onToggle() }}
@@ -345,11 +355,11 @@ function FolderRow({ name, icon = 'folder', onClick, onDoubleClick, depth = 0, e
345
355
  {/* THE FOLDER GLYPH IS THE ONE ICON LEFT, so it fills its box (user 2026-09-23: *"only folder
346
356
  should use icon, and it should be bigger"*). Every other kind now renders its own bytes. */}
347
357
  <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>
358
+ <span className={`shrink-0 flex items-center justify-center${selected ? '' : ' text-oq-48'}`}><Icon name={icon} size={glyphSize(size)} /></span>
349
359
  {/* NO TRAILING SLASH (user 2026-09-22). `partition` hands folders back as `audio/` because a
350
360
  slash is what marks one in a flat key space; that is storage's spelling, not a label, and
351
361
  `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>
362
+ <span className={`kol-item-name min-w-0 truncate${selected ? '' : ' text-fg-default'}`}>{name.replace(/\/$/, '')}</span>
353
363
  </span>
354
364
  <span className="flex-1" />
355
365
  {/* THE FOLDER'S COUNT SITS IN THE DATE COLUMN and the size column takes a dash — the columns
@@ -357,8 +367,8 @@ function FolderRow({ name, icon = 'folder', onClick, onDoubleClick, depth = 0, e
357
367
  promised a click that opens, and a click now SELECTS (opening is a double-click). */}
358
368
  {cols && <>
359
369
  {cols.tags != null && <span className="shrink-0" style={{ width: cols.tags }} />}
360
- <span className="kol-mono-12 text-fg-32 shrink-0 truncate" style={{ width: cols.date }}>{meta}</span>
361
- <span className="kol-mono-12 text-fg-24 shrink-0 truncate" style={{ width: cols.size }}>—</span>
370
+ <span className={`kol-item-name ${selected ? "opacity-60" : "text-fg-32"} shrink-0 truncate`} style={{ width: cols.date }}>{meta}</span>
371
+ <span className={`kol-item-name ${selected ? "opacity-60" : "text-fg-24"} shrink-0 truncate`} style={{ width: cols.size }}>—</span>
362
372
  </>}
363
373
  <RowMenuButton onOpen={onContextMenu} className="-my-1 -mr-1" />
364
374
  </li>
@@ -419,23 +429,24 @@ function RowSubtree({ level, depth, rowLevel, expanded, onToggle, onOpenFolder,
419
429
  * `#img/` drew two rows where the column browser drew four — the two folders, the empty folder the
420
430
  * columns knew about, and a file. A list view that cannot show a file is not a list view. */
421
431
  function FileRow({ o, onClick, onDoubleClick, depth = 0, formatDate, thumb, cols, onContextMenu, drag, selected, markKey }) {
432
+ const size = useContext(RowSize)
422
433
  const dragProps = drag ? { draggable: true, onDragStart: (e) => { e.stopPropagation(); drag.onDragStart(e, o.key) } } : {}
423
434
  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}>
435
+ <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 ${ROW_FILL.selected}` : ''}`} style={{ paddingLeft: depth * 20 + 12 }} onClick={onClick} onDoubleClick={onDoubleClick}>
425
436
  <span className="w-4 shrink-0" />
426
437
  {/* the hit — icon and name, as in the folder row above */}
427
438
  <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">
439
+ <span className={`shrink-0 flex items-center justify-center overflow-hidden rounded${selected ? '' : ' text-oq-48'}`} style={{ width: glyphSize(size), height: glyphSize(size) }}>
429
440
  {/* 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} />}
441
+ {thumb ?? <Icon name={kindOf(o) === 'image' ? 'image' : 'file'} size={glyphSize(size)} />}
431
442
  </span>
432
- <span className="kol-mono-12 min-w-0 truncate text-fg-default">{o.displayKey ?? o.key.split('/').pop()}</span>
443
+ <span className={`kol-item-name min-w-0 truncate${selected ? '' : ' text-fg-default'}`}>{o.displayKey ?? o.key.split('/').pop()}</span>
433
444
  </span>
434
445
  <span className="flex-1" />
435
446
  {cols && <>
436
- {cols.tags != null && <span className="kol-mono-12 text-fg-48 shrink-0 truncate" style={{ width: cols.tags }}>{(o.tags ?? []).map((t) => `#${t}`).join(' ')}</span>}
437
- <span className="kol-mono-12 text-fg-32 shrink-0 truncate" style={{ width: cols.date }}>{formatDate?.(o.uploaded)}</span>
438
- <span className="kol-mono-12 text-fg-32 shrink-0 truncate" style={{ width: cols.size }}>{formatSize(o.size)}</span>
447
+ {cols.tags != null && <span className={`kol-item-name ${selected ? "opacity-60" : "text-fg-48"} shrink-0 truncate`} style={{ width: cols.tags }}>{(o.tags ?? []).map((t) => `#${t}`).join(' ')}</span>}
448
+ <span className={`kol-item-name ${selected ? "opacity-60" : "text-fg-32"} shrink-0 truncate`} style={{ width: cols.date }}>{formatDate?.(o.uploaded)}</span>
449
+ <span className={`kol-item-name ${selected ? "opacity-60" : "text-fg-32"} shrink-0 truncate`} style={{ width: cols.size }}>{formatSize(o.size)}</span>
439
450
  </>}
440
451
  <RowMenuButton onOpen={onContextMenu} className="-my-1 -mr-1" />
441
452
  </li>
@@ -1058,6 +1069,11 @@ export function MediaLibraryBrowse({
1058
1069
  * media-hub, "two too many"). Given, the gear calls it and this page opens no drawer of its own —
1059
1070
  * inside a Hub the Hub's Settings is the one place. Absent, the gear opens the drawer as always. */
1060
1071
  onOpenSettings,
1072
+ /* `searchSuggestions` — what the palette lists before you type. Absent: favourites, smart
1073
+ * folders, then the bucket's top-level folders. An array replaces them; a function receives them
1074
+ * and returns the list. Rows are the palette's shape; a row with `run` calls it instead of
1075
+ * jumping to `id` as a file key. */
1076
+ searchSuggestions,
1061
1077
  /* `bucketLevel` — keep title → bucket → folders with ONE bucket (kol-client-olina 2026-09-23).
1062
1078
  * Absent, a one-bucket consumer collapses the level as ruled 2026-09-03. */
1063
1079
  bucketLevel = false,
@@ -1593,6 +1609,9 @@ export function MediaLibraryBrowse({
1593
1609
  * you, which is what the filter bar is for. */
1594
1610
  const [searchOpen, setSearchOpen] = useState(false)
1595
1611
  const [searchQuery, setSearchQuery] = useState('')
1612
+ /* the filter bar's own query — lifted so a typed one swaps the tree for a flat list, and so the
1613
+ * palette's ⌘Enter can hand its query over */
1614
+ const [barQuery, setBarQuery] = useState('')
1596
1615
  /* THE ARROWS IN THE GRID AND THE ROWS (user 2026-09-23: *"arrow navigation doesnt work in grid
1597
1616
  * mode? nor in row mode"*). The columns have always had theirs (`ColumnBrowser`); these are the
1598
1617
  * same keys for the other two views, and every one is listed in the shortcuts sheet.
@@ -1929,8 +1948,12 @@ export function MediaLibraryBrowse({
1929
1948
  .map((o) => ({ ...o, kind: kindOf(o), poster: posterFor(o.key, keySet) }))
1930
1949
  const filtersOn = !!settings.filters
1931
1950
  /* 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 }))
1951
+ * serves both: the wall renders it, the tree is scoped to the keys in it.
1952
+ * THE TREE'S LIST IS THE WHOLE BUCKET, not the folder you stand in (user 2026-09-30: open the
1953
+ * bar at `brand/og/` and `logos`, `original`, `projects` vanished with no filter set). The
1954
+ * columns draw every level up to the root, so a list scoped to `prefix` cut every sibling path
1955
+ * the moment the bar mounted. */
1956
+ 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
1957
  const filterKinds = [...new Set(filterItems.map((o) => o.kind))].sort()
1935
1958
  const filterTags = [...new Set(filterItems.flatMap((o) => o.tags ?? []))].sort()
1936
1959
  /* favourites as a one-chip group: ContentFilters matches a value, so a starred file carries one */
@@ -1980,6 +2003,7 @@ export function MediaLibraryBrowse({
1980
2003
  className={atTitleRoot ? 'is-root' : ''}
1981
2004
  height={colH}
1982
2005
  onHeightChange={onColH}
2006
+ rowSize={rowSize}
1983
2007
  columnWidths={settings.columnWidths}
1984
2008
  onColumnResize={(i, px) => setSettings({ ...settings, columnWidths: { ...(settings.columnWidths ?? {}), [i]: px } })}
1985
2009
  objects={treeObjects.filter((o) => !isSystemFile(o.key)).map((o) => ({ ...o, key: `${VROOT}${o.key}` }))}
@@ -2166,13 +2190,23 @@ export function MediaLibraryBrowse({
2166
2190
  * inside it, the same preview pane beside them, the same count line under it — with the size
2167
2191
  * slider at its right end, Finder's. */
2168
2192
  const tileSize = settings.tileSize ?? 180
2193
+ const rowSize = settings.rowSize ?? 'md'
2169
2194
  /* the tile size is the GRID's, not a level's (user 2026-09-24: the slider vanished at the root
2170
2195
  * levels) — one control, drawn wherever the grid is */
2196
+ /* THE TREE VIEWS SCALE TOO (user 2026-09-30: *"we might want to scale everything on slider like we
2197
+ * do in grid view"*) — in steps of the control ramp, not pixels, so a row is always a control height */
2198
+ const RAMP = ['xs', 'sm', 'md', 'lg']
2199
+ const rowSlider = (
2200
+ <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))}
2201
+ onChange={(e) => setSettings({ ...settings, rowSize: RAMP[Number(e.target.value)] })} aria-label="Row size" />
2202
+ )
2171
2203
  const sizeSlider = (
2172
2204
  <input type="range" className="slider-black w-32 cursor-pointer" min={100} max={360} step={20} value={tileSize}
2173
2205
  onChange={(e) => setSettings({ ...settings, tileSize: Number(e.target.value) })} aria-label="Tile size" />
2174
2206
  )
2175
- const wallPane = (files) => (
2207
+ /* `hits` = a query's results: no folders, no root level, each file named by its whole path —
2208
+ * in the tree views as a list, the rows' shape; the wall keeps its tiles */
2209
+ const wallPane = (files, hits = false) => (
2176
2210
  <div ref={viewRef} className="flex flex-col gap-2" style={{ height: colH }}>
2177
2211
  <div className="relative border rounded flex overflow-hidden flex-1 min-h-0" style={{ borderColor: 'var(--kol-oq-08)' }}>
2178
2212
  {/* THE MARQUEE IS THE PANE'S, not the tile grid's (user 2026-09-23: a drag only started where
@@ -2185,7 +2219,7 @@ export function MediaLibraryBrowse({
2185
2219
  onContextMenu={titleLevel ? undefined : (e) => menu.openAt(e, { type: 'level', path: prefix })}
2186
2220
  onClick={(e) => { if (!e.target.closest('[data-marquee-key]')) { setRowSelection(new Set()); setPickedFile(null); setPickedFolder(null); setPickedBucket(null) } }}>
2187
2221
  {gridMarquee.rect && <div className="kol-marquee" style={gridMarquee.rect} />}
2188
- {titleLevel ? (
2222
+ {titleLevel && !hits ? (
2189
2223
  <div className="grid gap-x-3 gap-y-5" style={{ gridTemplateColumns: `repeat(auto-fill, minmax(${tileSize}px, 1fr))` }}>
2190
2224
  {rootItems.map((it) => (
2191
2225
  <MediaTile key={it.key} preview={<Icon name={it.icon} size="100%" className="text-oq-48" />} name={it.name}
@@ -2193,11 +2227,12 @@ export function MediaLibraryBrowse({
2193
2227
  onClick={(e) => { e.stopPropagation(); setPickedBucket(it.key) }} onDoubleClick={it.open} />
2194
2228
  ))}
2195
2229
  </div>
2196
- ) : <WallBody files={files} layout="grid" tiles="media" cardMin={tileSize} onSorted={(list) => { gridOrderRef.current = list }}
2230
+ ) : <WallBody files={files} layout={hits && !isWall ? 'list' : 'grid'} tiles="media" cardMin={tileSize} onSorted={(list) => { gridOrderRef.current = list }}
2197
2231
  /* THE GRID DRAWS THE LEVEL'S SUBFOLDERS, ahead of the files (same source: kol-client-olina
2198
2232
  * 2026-09-23 — `projects/` read `0 files` over a blank pane). `flat` is the subtree's files
2199
2233
  * and no folders, as it was. */
2200
- folders={settings.flat || smartMatches ? [] : folders.map((f) => prefix + f)}
2234
+ folders={settings.flat || smartMatches || hits ? [] : folders.map((f) => prefix + f)}
2235
+ {...(hits && { renderName: (o) => o.key })}
2201
2236
  onOpenFolder={goFolder}
2202
2237
  onPickFolder={(path, e) => { if (selectRow(path, e, [...folders.map((f) => prefix + f), ...files.map((f) => f.key)])) return; rowPickFolder(path) }}
2203
2238
  onFolderContextMenu={(e, path) => menu.openAt(e, { type: 'folder', path, targets: targetsFor(path) })}
@@ -2230,6 +2265,20 @@ export function MediaLibraryBrowse({
2230
2265
  hint: o.key.slice(0, o.key.lastIndexOf('/') + 1) || 'the bucket root',
2231
2266
  group: KIND_LABEL[kindOf(o)] || 'File',
2232
2267
  }))
2268
+ /* THE PALETTE OPENS ON SOMEWHERE TO GO (user 2026-09-30: it opened blank in olina). */
2269
+ const openFolder = (path) => { setAppRoot(false); goFolder(path) }
2270
+ const leafOf = (path) => path.replace(/\/$/, '').split('/').pop()
2271
+ const hintOf = (path) => dirOf(path) || 'the bucket root'
2272
+ const defaultSuggestions = [
2273
+ ...Object.keys(folderRows ?? {}).filter((p) => folderRows[p]?.favourite).sort()
2274
+ .map((p) => ({ id: p, label: leafOf(p), hint: hintOf(p), group: 'Favourites', icon: 'folder', run: () => openFolder(p) })),
2275
+ ...objects.filter((o) => o.favourite && !isSystemFile(o.key))
2276
+ .map((o) => ({ id: o.key, label: leafOf(o.key), hint: hintOf(o.key), group: 'Favourites', icon: 'file' })),
2277
+ ...smartFolders.map((sf) => ({ id: `smart:${sf.id}`, label: sf.name, group: 'Smart folders', icon: 'folder', run: () => setActiveSmart(sf.id) })),
2278
+ ...[...new Set([...treeFolders(''), ...partition(objects.filter((o) => !isSystemFile(o.key)), '').folders])].sort()
2279
+ .map((f) => ({ id: f, label: leafOf(f), group: 'Folders', icon: 'folder', run: () => openFolder(f) })),
2280
+ ]
2281
+ const suggestions = typeof searchSuggestions === 'function' ? searchSuggestions(defaultSuggestions) : (searchSuggestions ?? defaultSuggestions)
2233
2282
  const jumpTo = (key) => {
2234
2283
  const o = objects.find((x) => x.key === key)
2235
2284
  setSearchOpen(false); setSearchQuery('')
@@ -2249,14 +2298,20 @@ export function MediaLibraryBrowse({
2249
2298
  if (q.text && !filterMedia([o], q.text).length) return false
2250
2299
  return true
2251
2300
  }).map((o) => ({ ...o, kind: kindOf(o), poster: posterFor(o.key, keySet), displayKey: o.key })) : null
2301
+ /* A TYPED QUERY IS A RESULTS LIST (user 2026-09-30, reversing "a flat results list nobody asked
2302
+ * for"): pruning the tree to matching paths hid the files one level down and read as "search only
2303
+ * finds folders". While the bar holds a query, the tree views show every hit flat, each with its
2304
+ * path; clearing it brings the tree back. The wall already was a flat list. */
2252
2305
  const body = (filtered) => (smartMatches
2253
2306
  ? wallPane(filtered ?? smartMatches)
2307
+ : filtered && barQuery.trim() ? wallPane(filtered, true)
2254
2308
  : isWall
2255
2309
  ? wallPane(filtered ?? wallFiles)
2256
2310
  : treeBody(filtered ? new Set(filtered.map((f) => f.key)) : null))
2257
2311
 
2258
2312
  return (
2259
2313
  <div ref={pageRef} {...press} className={`flex flex-col gap-6 ${className}`.trim()}>
2314
+ <RowSize.Provider value={rowSize}>
2260
2315
  <LibraryHeader title={title} buckets={buckets} bucketId={bucketMeta.id} appRoot={atTitleRoot} bucketMeta={bucketMeta} writable={writable} headerActions={headerActions} headerTrailing={headerTrailing} onTrash={trash ? () => setTrashOpen(true) : undefined}
2261
2316
  onHome={goTitleRoot}
2262
2317
  onBucket={(v) => { if (v === 'all') goTitleRoot(); else { setAppRoot(false); switchBucket(v) } }}
@@ -2440,7 +2495,10 @@ export function MediaLibraryBrowse({
2440
2495
  placeholder="Search this bucket"
2441
2496
  results={searchResults}
2442
2497
  onExpand={() => searchResults[0] && jumpTo(searchResults[0].id)}
2443
- onSelect={(item) => jumpTo(item.id)}
2498
+ onSelect={(item) => (item.run ? item.run() : jumpTo(item.id))}
2499
+ suggestions={suggestions}
2500
+ enterLabel="Go to file"
2501
+ onOpenResults={(q) => { setSettings({ ...settings, filters: true }); setBarQuery(q) }}
2444
2502
  />
2445
2503
  )}
2446
2504
  {quickLookFolder && (
@@ -2490,6 +2548,8 @@ export function MediaLibraryBrowse({
2490
2548
  title="Files"
2491
2549
  totalCount={filterItems.length}
2492
2550
  searchKeys={['displayKey']}
2551
+ searchValue={barQuery}
2552
+ onSearchChange={setBarQuery}
2493
2553
  filterGroups={[{ label: 'Kind', key: 'kind', values: filterKinds }, ...(filterTags.length ? [{ label: 'Tags', key: 'tags', values: filterTags }] : []), ...(anyStarred ? [{ label: 'Favourites', key: 'starred', values: ['favourite'] }] : [])]}
2494
2554
  mutuallyExclusiveFilters={['kind']}
2495
2555
  /* ONE FUNNEL, NOT TWO (2026-09-22): the crumb row's funnel mounts this bar AND opens
@@ -2513,7 +2573,7 @@ export function MediaLibraryBrowse({
2513
2573
  {atTop ? '1 folder' : `${bucketList.length} ${bucketList.length === 1 ? 'bucket' : 'buckets'}`}
2514
2574
  {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
2575
  </p>
2516
- {isWall && sizeSlider}
2576
+ {isWall ? sizeSlider : rowSlider}
2517
2577
  </div>
2518
2578
  ) : isWall ? (
2519
2579
  /* ONE LINE'S HEIGHT, like the other views' count line — the slider is 24px tall and made
@@ -2665,6 +2725,7 @@ export function MediaLibraryBrowse({
2665
2725
  </>
2666
2726
  )}
2667
2727
  </div>
2728
+ </RowSize.Provider>
2668
2729
  </div>
2669
2730
  )
2670
2731
  }
@@ -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>