@kolkrabbi/kol-component 0.218.0 → 0.220.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.218.0",
3
+ "version": "0.220.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",
@@ -0,0 +1,41 @@
1
+ import { useRef } from 'react'
2
+
3
+ /**
4
+ * useLongPress — the touch half of right-click. iOS never fires `contextmenu` from a held press,
5
+ * so a container spreads these props and a held touch dispatches that event itself, at the finger,
6
+ * on whatever was pressed. Every `onContextMenu` inside — and the payload it builds — works
7
+ * unchanged, with no per-row wiring.
8
+ *
9
+ * const press = useLongPress()
10
+ * <div {...press}> …rows and tiles with onContextMenu… </div>
11
+ *
12
+ * Touch only (a mouse and a pen have a right button). It acts only when a handler took the event
13
+ * (`preventDefault`, which is what `useContextMenu().openAt` does): a held press on anything else
14
+ * stays a press, and its click is not swallowed. When one did, the click that follows the lift is
15
+ * swallowed so the row underneath does not also select or open. Moving past `slop` px is a scroll
16
+ * or a drag and cancels it. A trusted `contextmenu` after ours (Android fires one) is dropped.
17
+ */
18
+ export default function useLongPress({ ms = 450, slop = 10 } = {}) {
19
+ const s = useRef({ timer: 0, x: 0, y: 0, target: null, fired: false })
20
+ const cancel = () => { clearTimeout(s.current.timer); s.current.timer = 0 }
21
+ return {
22
+ /* iOS draws its own callout over a held image — the thumbnails are images */
23
+ style: { WebkitTouchCallout: 'none' },
24
+ onPointerDown: (e) => {
25
+ const t = s.current
26
+ cancel()
27
+ t.fired = false
28
+ if (e.pointerType !== 'touch' || !e.isPrimary || e.target.closest('input,textarea,select')) return
29
+ t.x = e.clientX; t.y = e.clientY; t.target = e.target
30
+ t.timer = setTimeout(() => {
31
+ t.timer = 0
32
+ t.fired = !t.target.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: t.x, clientY: t.y, button: 2 }))
33
+ }, ms)
34
+ },
35
+ onPointerMove: (e) => { const t = s.current; if (t.timer && Math.hypot(e.clientX - t.x, e.clientY - t.y) > slop) cancel() },
36
+ onPointerUp: cancel,
37
+ onPointerCancel: cancel,
38
+ onClickCapture: (e) => { if (s.current.fired) { s.current.fired = false; e.preventDefault(); e.stopPropagation() } },
39
+ onContextMenuCapture: (e) => { if (s.current.fired && e.nativeEvent.isTrusted) { e.preventDefault(); e.stopPropagation() } },
40
+ }
41
+ }
package/src/index.js CHANGED
@@ -183,6 +183,7 @@ export { default as PlaybackBar } from './molecules/PlaybackBar.jsx'
183
183
  export { default as AudioSheet } from './molecules/AudioSheet.jsx'
184
184
  export { default as QuickLookFrame } from './molecules/QuickLookFrame.jsx'
185
185
  export { default as MediaTile } from './molecules/MediaTile.jsx'
186
+ export { default as RowMenuButton } from './molecules/RowMenuButton.jsx'
186
187
  export { default as DocPage } from './molecules/DocPage.jsx'
187
188
  export { default as DocFrontmatter } from './molecules/DocFrontmatter.jsx'
188
189
  export { default as ProfileCard } from './molecules/ProfileCard.jsx'
@@ -221,6 +222,7 @@ export { default as useScrollSpy } from './hooks/useScrollSpy.js'
221
222
  export { default as useTilt } from './hooks/useTilt.js'
222
223
  export { default as usePlayback } from './hooks/usePlayback.js'
223
224
  export { default as useCoarsePointer } from './hooks/useCoarsePointer.js'
225
+ export { default as useLongPress } from './hooks/useLongPress.js'
224
226
  export { default as useInViewAttention } from './hooks/useInViewAttention.js'
225
227
  export { default as useAxisAnimation } from './hooks/useAxisAnimation.js'
226
228
  export { useEyedropper, pickFromCanvasElement } from './hooks/useEyedropper.js'
@@ -182,11 +182,16 @@ export default function KindPreview({
182
182
  if (kind === 'video') return <VideoTile src={url} poster={poster} />
183
183
  if (kind === 'audio') return <AudioTile src={url} fallback={icon} />
184
184
  if (isText) {
185
+ /* A THUMBNAIL IS A PICTURE of the document, not the document: a README's links stayed live
186
+ * inside a grid tile, and tapping the tile navigated the app away. `inert` takes the whole page
187
+ * out of hit-testing and the tab order, so the tap lands on the tile. The pane and the sheet
188
+ * are for reading, and keep their links. */
189
+ const picture = (node) => (fit === 'tile' || fit === 'thumb' ? <div inert className="contents">{node}</div> : node)
185
190
  if (loading) return <span className="kol-mono-12 text-meta">Loading…</span>
186
191
  if (error) return <span className="kol-mono-12 text-ui-error">Couldn’t load: {error}</span>
187
192
  if (kind === 'markdown') {
188
193
  const meta = parseFrontmatter(text)
189
- return (
194
+ return picture(
190
195
  <DocPage frontmatter={Object.keys(meta).length ? meta : null}>
191
196
  <div className="kol-prose" dangerouslySetInnerHTML={{ __html: markdownToHtml(text) }} />
192
197
  {truncated && <p className="kol-mono-12 text-meta">truncated at {textLimit / 1024} KB</p>}
@@ -194,7 +199,7 @@ export default function KindPreview({
194
199
  )
195
200
  }
196
201
  const language = kind === 'json' ? 'json' : kind === 'yaml' ? 'yaml' : LANG[ext] || 'text'
197
- return (
202
+ return picture(
198
203
  <DocPage>
199
204
  <CodeBlock code={text} language={language} filename={name} copy={false} />
200
205
  {truncated && <p className="kol-mono-12 text-meta">truncated at {textLimit / 1024} KB</p>}
@@ -1,4 +1,5 @@
1
- /* taxonomy-ok: molecule — lays out a caller's preview node and a name; no DS parts of its own. */
1
+ /* taxonomy-ok: molecule — lays out a caller's preview node and a name; its one DS part is the touch `···`. */
2
+ import RowMenuButton from './RowMenuButton.jsx'
2
3
 
3
4
  /**
4
5
  * MediaTile — one file in the grid view, Finder's icon view (user 2026-09-23: *"skip the container
@@ -18,12 +19,17 @@
18
19
  */
19
20
  export default function MediaTile({ preview, name, selected = false, markKey, onClick, onDoubleClick, onContextMenu, className = '' }) {
20
21
  return (
21
- <div className={`kol-media-tile-item ${className}`.trim()} data-selected={selected || undefined} data-marquee-key={markKey}
22
+ <div className={`kol-media-tile-item relative ${className}`.trim()} data-selected={selected || undefined} data-marquee-key={markKey}
22
23
  role="button" tabIndex={0} aria-pressed={selected}
23
- onClick={onClick} onDoubleClick={onDoubleClick} onContextMenu={onContextMenu}
24
+ /* THE SECOND CLICK OPENS (kol-client-olina 2026-09-23: double-click on a folder tile did nothing).
25
+ * The browser counted the double click (`detail: 2`) and never fired `dblclick` on a folder tile,
26
+ * so the open hung off an event that did not arrive. The click's own count is the signal. */
27
+ onClick={(e) => { if (e.detail === 2 && onDoubleClick) { onDoubleClick(e); return } onClick?.(e) }}
28
+ onContextMenu={onContextMenu}
24
29
  onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); onDoubleClick?.(e) } }}>
25
30
  <div className="kol-media-tile-thumb">{preview}</div>
26
31
  <span className="kol-media-tile-name">{name}</span>
32
+ <RowMenuButton variant="grey" onOpen={onContextMenu} className="absolute top-1 right-1" />
27
33
  </div>
28
34
  )
29
35
  }
@@ -0,0 +1,20 @@
1
+ /* taxonomy-ok: molecule — one Button behind the coarse-pointer gate; the menu it opens is the caller's. */
2
+ import Button from '../atoms/Button.jsx'
3
+ import useCoarsePointer from '../hooks/useCoarsePointer.js'
4
+
5
+ /**
6
+ * RowMenuButton — the `···` that stands in for right-click on a touch device (media D-touch,
7
+ * 2026-09-23). A finger has no right button, and a held press (`useLongPress`) is invisible until
8
+ * you know it, so on `pointer: coarse` a row or tile that has a context menu wears this. It calls
9
+ * the SAME handler right-click calls, with the tap as the event, so the payload, the selection
10
+ * rules and the menu are the row's own; `openAt` stops the tap, so the row underneath does not
11
+ * also select or open. Renders nothing on a fine pointer, or without a handler.
12
+ *
13
+ * @param {(e: Event) => void} onOpen the row's `onContextMenu`
14
+ * @param {string} variant `ghost` on a row; `grey` — an opaque plate — over a picture
15
+ */
16
+ export default function RowMenuButton({ onOpen, variant = 'ghost', className = '' }) {
17
+ const coarse = useCoarsePointer()
18
+ if (!coarse || !onOpen) return null
19
+ return <Button variant={variant} size="md" iconOnly="more" aria-label="More actions" className={`shrink-0 ${className}`.trim()} onClick={onOpen} />
20
+ }
@@ -2,6 +2,7 @@ import { Fragment, useEffect, useRef, useState } from 'react'
2
2
  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
+ import RowMenuButton from '../molecules/RowMenuButton.jsx'
5
6
  import { formatLength } from '../molecules/AudioPreview.jsx'
6
7
  import { kindOf as dsKindOf, KIND_LABEL as DS_KIND_LABEL } from '../utilities/mediaKinds.js'
7
8
  import useGrabEdge from '../hooks/useGrabEdge.js'
@@ -324,9 +325,9 @@ function Row({
324
325
  </span>
325
326
  ) : (
326
327
  <span className="w-5 shrink-0 flex items-center justify-center text-oq-48">
327
- {/* a folder's mark is the only glyph a row carries now, so it is drawn at the box's
328
- size rather than floating in it (user 2026-09-23) */}
329
- <Icon name={icon} size={icon === 'folder' ? 18 : 14} />
328
+ {/* one glyph size for every row — a folder and a file side by side draw at the same size
329
+ (user 2026-09-25; the file glyph was 14 against the folder's 18) */}
330
+ <Icon name={icon} size={18} />
330
331
  </span>
331
332
  )}
332
333
 
@@ -342,6 +343,9 @@ function Row({
342
343
 
343
344
  {/* ZONE 4 */}
344
345
  {zones ? <span className="shrink-0 inline-flex justify-end" style={{ width: 20 }}>{trailing}</span> : trailing}
346
+ {/* on touch the row's menu has a face — see RowMenuButton; the negative margins keep the row's
347
+ height and inset what they were without it */}
348
+ <RowMenuButton onOpen={onContextMenu} className="-my-2 -mr-2" />
345
349
  </li>
346
350
  )
347
351
  }
@@ -807,11 +811,14 @@ export default function ColumnBrowser({
807
811
  {rows.filter((r) => r.kind !== 'empty').map((r) => {
808
812
  const isFolder = r.kind === 'folder'
809
813
  const o = r.o
814
+ const menu = onRowContextMenu && ((e) => onRowContextMenu(e, isFolder ? { type: 'folder', path: r.level + r.name } : { type: 'file', path: o.key, o }))
810
815
  return (
816
+ /* the wrapper exists so the `···` is a SIBLING of the tile, not a button inside one */
817
+ <div key={r.key} className="relative min-w-0">
811
818
  <button
812
- key={r.key}
813
819
  type="button"
814
- className={`kol-column-browser-tile${!isFolder && shown?.key === o.key ? ' is-selected' : ''}`}
820
+ className={`kol-column-browser-tile w-full${!isFolder && shown?.key === o.key ? ' is-selected' : ''}`}
821
+ onContextMenu={menu}
815
822
  onClick={() => {
816
823
  if (isFolder) { onPrefix(r.level + r.name); return }
817
824
  const files = itemsAt(r.level ?? '').filter((it) => it.type === 'file').map((it) => it.o)
@@ -830,6 +837,8 @@ export default function ColumnBrowser({
830
837
  {isFolder ? folderMeta?.(r.level + r.name, 'grid') : (o.size != null ? formatSize(o.size) : '')}
831
838
  </span>
832
839
  </button>
840
+ <RowMenuButton variant="grey" onOpen={menu} className="absolute top-1 right-1" />
841
+ </div>
833
842
  )
834
843
  })}
835
844
  </div>
@@ -858,6 +867,9 @@ export default function ColumnBrowser({
858
867
  * level; past the indent cap it has nothing left to offer, so
859
868
  * the row is the only way down */
860
869
  onDisclose={r.depth < INDENT_CAP ? () => toggleExpanded(r.key) : undefined}
870
+ /* THE ROW'S OWN MENU. Only the <ul> carried one, so a menu opened on a phone row
871
+ * targeted the LEVEL — the rename and delete the row was asking for were not there. */
872
+ onContextMenu={onRowContextMenu && ((e) => onRowContextMenu(e, { type: 'folder', path: r.level + r.name }))}
861
873
  onClick={() => onPrefix(r.level + r.name)}
862
874
  />
863
875
  ) : (
@@ -870,6 +882,7 @@ export default function ColumnBrowser({
870
882
  indent={r.depth}
871
883
  meta={metaOf(r.o)}
872
884
  active={shown?.key === r.o.key}
885
+ onContextMenu={onRowContextMenu && ((e) => onRowContextMenu(e, { type: 'file', path: r.o.key, o: r.o }))}
873
886
  /* A FILE IS A PUSH, NOT A PREVIEW PANE (item 6): there is no
874
887
  * column for the preview to sit beside at this width, so the
875
888
  * tap opens the full-screen inspector the consumer already
@@ -957,7 +970,7 @@ export default function ColumnBrowser({
957
970
  active={f === activeFolder || isPicked(level + f)}
958
971
  cursor={cursorActive && cursor.col === k && cursor.idx === i}
959
972
  trailing={<Icon name="chevron-right" size={12} className="text-oq-32" />}
960
- onContextMenu={(e) => onRowContextMenu?.(e, { type: 'folder', path: level + f })}
973
+ onContextMenu={onRowContextMenu && ((e) => onRowContextMenu(e, { type: 'folder', path: level + f }))}
961
974
  drop={dragFor?.(level + f)}
962
975
  dropFiles={onDropFiles ? (list) => onDropFiles(list, level + f) : undefined}
963
976
  onClick={(e) => { if (onSelectClick?.(level + f, e, colKeys)) return; setCursor({ col: k, idx: i }); setCursorActive(true); rootRef.current?.focus(); pick(null); onPrefix(level + f) }}
@@ -971,7 +984,7 @@ export default function ColumnBrowser({
971
984
  markKey={o.key}
972
985
  active={shown?.key === o.key || isPicked(o.key)}
973
986
  cursor={cursorActive && cursor.col === k && cursor.idx === folders.length + i}
974
- onContextMenu={(e) => onRowContextMenu?.(e, { type: 'file', path: o.key, o })}
987
+ onContextMenu={onRowContextMenu && ((e) => onRowContextMenu(e, { type: 'file', path: o.key, o }))}
975
988
  drop={dragFor?.(o.key)}
976
989
  onClick={(e) => { if (onSelectClick?.(o.key, e, colKeys)) return; setCursor({ col: k, idx: folders.length + i }); setCursorActive(true); rootRef.current?.focus(); pickFile(level, o) }}
977
990
  />
@@ -12,6 +12,7 @@ import ViewToggle from '../atoms/ViewToggle.jsx'
12
12
  import Dropdown from '../molecules/Dropdown.jsx'
13
13
  import ContentCard from '../molecules/ContentCard.jsx'
14
14
  import MediaTile from '../molecules/MediaTile.jsx'
15
+ import RowMenuButton from '../molecules/RowMenuButton.jsx'
15
16
  import ContentRow from '../molecules/ContentRow.jsx'
16
17
  import SortControls from '../molecules/SortControls.jsx'
17
18
  import SearchInput from '../molecules/SearchInput.jsx'
@@ -20,6 +21,8 @@ import { MenuItem, MenuDropdownItem, MenuDropdownDivider } from '../molecules/Me
20
21
  import { Tooltip } from '../utilities/Popover.jsx'
21
22
  import ContextMenu, { useContextMenu } from '../utilities/ContextMenu.jsx'
22
23
  import useMarquee from '../hooks/useMarquee.js'
24
+ import useLongPress from '../hooks/useLongPress.js'
25
+ import useMediaQuery from '../hooks/useMediaQuery.js'
23
26
  import KindPreview from '../molecules/KindPreview.jsx'
24
27
  import AudioSheet from '../molecules/AudioSheet.jsx'
25
28
  import VideoSheet from '../molecules/VideoSheet.jsx'
@@ -206,7 +209,7 @@ function ImageFrame({ src }) {
206
209
  /* `depth` indents a row under the folder it was expanded from; `expanded`/`onToggle` draw the
207
210
  * disclosure twisty. A folder row without `onToggle` keeps the old behaviour exactly — one chevron
208
211
  * that navigates — so nothing that already renders these moves. */
209
- function FolderRow({ name, onClick, onDoubleClick, depth = 0, expanded, onToggle, meta, cols, onContextMenu, drag, dropFiles, selected, current, markKey }) {
212
+ function FolderRow({ name, icon = 'folder', onClick, onDoubleClick, depth = 0, expanded, onToggle, meta, cols, onContextMenu, drag, dropFiles, selected, current, markKey }) {
210
213
  const [over, setOver] = useState(false)
211
214
  /* A FOLDER IS A DROP TARGET. `drag` carries the page's move verb and the path this row is; the
212
215
  * row highlights only while something is actually over it, so an accidental hover reads as
@@ -243,7 +246,7 @@ function FolderRow({ name, onClick, onDoubleClick, depth = 0, expanded, onToggle
243
246
  {/* THE FOLDER GLYPH IS THE ONE ICON LEFT, so it fills its box (user 2026-09-23: *"only folder
244
247
  should use icon, and it should be bigger"*). Every other kind now renders its own bytes. */}
245
248
  <span data-hit {...hitDrag} className="kol-row-hit flex items-center gap-3 min-w-0">
246
- <span className="w-8 h-8 shrink-0 flex items-center justify-center text-oq-48"><Icon name="folder" size={26} /></span>
249
+ <span className="w-8 h-8 shrink-0 flex items-center justify-center text-oq-48"><Icon name={icon} size={26} /></span>
247
250
  {/* NO TRAILING SLASH (user 2026-09-22). `partition` hands folders back as `audio/` because a
248
251
  slash is what marks one in a flat key space; that is storage's spelling, not a label, and
249
252
  `ColumnBrowser` has always stripped it. The glyph says "folder". */}
@@ -257,6 +260,7 @@ function FolderRow({ name, onClick, onDoubleClick, depth = 0, expanded, onToggle
257
260
  <span className="kol-mono-12 text-fg-32 shrink-0 truncate" style={{ width: cols.date }}>{meta}</span>
258
261
  <span className="kol-mono-12 text-fg-24 shrink-0 truncate" style={{ width: cols.size }}>—</span>
259
262
  </>}
263
+ <RowMenuButton onOpen={onContextMenu} className="-my-1 -mr-1" />
260
264
  </li>
261
265
  )
262
266
  }
@@ -322,7 +326,8 @@ function FileRow({ o, onClick, onDoubleClick, depth = 0, formatDate, thumb, cols
322
326
  {/* the hit — icon and name, as in the folder row above */}
323
327
  <span data-hit {...dragProps} className="kol-row-hit flex items-center gap-3 min-w-0">
324
328
  <span className="w-8 h-8 shrink-0 flex items-center justify-center text-oq-48 overflow-hidden rounded">
325
- {thumb ?? <Icon name={kindOf(o) === 'image' ? 'image' : 'file'} size={18} />}
329
+ {/* a file with no thumbnail draws its glyph at the FOLDER's size — one icon column (user 2026-09-25) */}
330
+ {thumb ?? <Icon name={kindOf(o) === 'image' ? 'image' : 'file'} size={26} />}
326
331
  </span>
327
332
  <span className="kol-mono-12 min-w-0 truncate text-fg-default">{o.displayKey ?? o.key.split('/').pop()}</span>
328
333
  </span>
@@ -331,6 +336,7 @@ function FileRow({ o, onClick, onDoubleClick, depth = 0, formatDate, thumb, cols
331
336
  <span className="kol-mono-12 text-fg-32 shrink-0 truncate" style={{ width: cols.date }}>{formatDate?.(o.uploaded)}</span>
332
337
  <span className="kol-mono-12 text-fg-32 shrink-0 truncate" style={{ width: cols.size }}>{formatSize(o.size)}</span>
333
338
  </>}
339
+ <RowMenuButton onOpen={onContextMenu} className="-my-1 -mr-1" />
334
340
  </li>
335
341
  )
336
342
  }
@@ -349,12 +355,21 @@ function FolderPreview({ path, objects, width, formatDate }) {
349
355
  ['Size', formatSize(bytes)],
350
356
  ...(last ? [['Newest', formatDate(last) || '—']] : []),
351
357
  ]
358
+ return <ContainerPreview name={path.replace(/\/$/, '').split('/').pop()} icon="folder" facts={facts} width={width} />
359
+ }
360
+
361
+ /* ONE PREVIEW FOR EVERYTHING THAT HOLDS THINGS — a folder, a bucket, the title (user 2026-09-25:
362
+ * selecting a bucket or MEDIA left the pane empty). The glyph, the name, the facts; only the
363
+ * glyph and the facts differ. */
364
+ function ContainerPreview({ name, icon, facts, width }) {
352
365
  return (
353
366
  <div className="kol-column-browser-preview shrink-0 overflow-y-auto p-4 flex flex-col gap-4" style={{ width }}>
354
- <div className="w-full aspect-square bg-oq-04 rounded flex items-center justify-center">
355
- <Icon name="folder" size={64} className="text-oq-48" />
367
+ {/* the folder fills its box with the tile's inset (user 2026-09-24: a 64px glyph floated in
368
+ a 300px square while the grid's folder tile fills its own) */}
369
+ <div className="w-full aspect-square bg-oq-04 rounded flex items-center justify-center p-[5%]">
370
+ <Icon name={icon} size="100%" className="text-oq-48" />
356
371
  </div>
357
- <p className="kol-mono-12 text-fg-default break-all">{path.replace(/\/$/, '').split('/').pop()}</p>
372
+ <p className="kol-mono-12 text-fg-default break-all">{name}</p>
358
373
  <dl className="flex flex-col gap-1">
359
374
  {facts.map(([k, v]) => (
360
375
  <div key={k} className="flex justify-between gap-4 kol-mono-12">
@@ -469,7 +484,7 @@ export function MediaInspector({ files, index, onClose, onPrev, onNext, mediaUrl
469
484
  const [size, setSize] = useState(null)
470
485
  const shownDims = dims && dims.key === o?.key ? dims : null
471
486
  useEffect(() => {
472
- const onKey = (e) => { if (e.key === 'ArrowLeft') onPrev(); if (e.key === 'ArrowRight') onNext() }
487
+ const onKey = (e) => { if (e.shiftKey) return; if (e.key === 'ArrowLeft') onPrev(); if (e.key === 'ArrowRight') onNext() }
473
488
  window.addEventListener('keydown', onKey)
474
489
  return () => window.removeEventListener('keydown', onKey)
475
490
  }, [onPrev, onNext])
@@ -512,7 +527,12 @@ export function MediaInspector({ files, index, onClose, onPrev, onNext, mediaUrl
512
527
  <FullscreenOverlay open onClose={onClose} closeButton={false} scrim>
513
528
  {isImage(o.contentType) ? (
514
529
  <QuickLookFrame {...frame}>
515
- <img src={mediaUrl(o.key)} alt={o.displayKey} className="kol-quicklook-media" onLoad={(e) => setDims({ key: o.key, w: e.target.naturalWidth, h: e.target.naturalHeight })} />
530
+ {/* A VECTOR HAS NO PIXEL SIZE (user 2026-09-24: a 32px logo.svg opened as a 32px window). It
531
+ * is drawn in a 640 box — the window's max if that is smaller — and contained in it. */}
532
+ <img src={mediaUrl(o.key)} alt={o.displayKey}
533
+ className="kol-quicklook-media"
534
+ style={extOf(o.key) === 'svg' || o.contentType === 'image/svg+xml' ? { width: 'min(var(--kol-ql-max-w), 640px)', height: 'min(var(--kol-ql-media-h), 640px)' } : undefined}
535
+ onLoad={(e) => setDims({ key: o.key, w: e.target.naturalWidth, h: e.target.naturalHeight })} />
516
536
  </QuickLookFrame>
517
537
  ) : isVideo(o.contentType) ? (
518
538
  <VideoSheet key={o.key} src={mediaUrl(o.key)} poster={poster ? mediaUrl(poster) : undefined} onMeta={(m) => setDims({ key: o.key, ...m })} frame={frame} />
@@ -850,7 +870,7 @@ export function MediaLibraryBrowse({
850
870
  }) {
851
871
  const [ownPrefix, setOwnPrefix] = useState('')
852
872
  const prefix = prefixProp ?? ownPrefix
853
- const setPrefix = (v) => { if (prefixProp == null) setOwnPrefix(v); onPrefix?.(v) }
873
+ const setPrefix = (v) => { if (v) setAppRoot(false); if (prefixProp == null) setOwnPrefix(v); onPrefix?.(v) }
854
874
  const [appRoot, setAppRoot] = useState(false)
855
875
  const [ownBucket, setOwnBucket] = useState(bucket)
856
876
  const bucketId = bucket ?? ownBucket
@@ -864,8 +884,22 @@ export function MediaLibraryBrowse({
864
884
  * opens (user 2026-09-22, Finder's behaviour — "clicking shouldnt automatically open it"). The
865
885
  * chevron still expands in place, which is the third thing and always was. */
866
886
  const [pickedFolder, setPickedFolder] = useState(null)
867
- const pickFile = (o) => { setPickedFolder(null); setPickedFile(o) }
868
- const pickFolder = (path) => { setPickedFile(null); setPickedFolder(path) }
887
+ const pickFile = (o) => { setAppRoot(false); setPickedFolder(null); setPickedFile(o) }
888
+ const pickFolder = (path) => { setAppRoot(false); setPickedFile(null); setPickedFolder(path) }
889
+ /* AT THE TITLE ROOT IS DERIVED, NOT STORED (kol-client-olina 2026-09-23: rows and grid picked a
890
+ * file under `brand/logos/` and the crumb still read the title alone). `appRoot` was a second
891
+ * source of truth for the location, and only the columns ever cleared it. A path or a pick lives
892
+ * inside a bucket, so either one means you are not at the title — a stale flag can no longer
893
+ * hide where you are. Everything below reads this, never `appRoot`. */
894
+ const atTitleRoot = appRoot && !prefix && !pickedFile && !pickedFolder
895
+ /* ABOVE THE TITLE (user 2026-09-24: *"this is not the top root folder … in column view I can see
896
+ * the root MEDIA"*). Column 0 is a level whose one item is the title; rows and grid had no such
897
+ * level, so the bucket list was their top and the title never appeared as a thing. `appRoot` is
898
+ * `'top'` there — the same state, one level higher, derived the same way. */
899
+ const atTop = atTitleRoot && appRoot === 'top'
900
+ /* a BUCKET is a folder with another icon: a click highlights it, a double-click opens it */
901
+ const [pickedBucket, setPickedBucket] = useState(null)
902
+ const ROOT_KEY = '\u0000title'
869
903
  /* IN THE ROWS, WHAT YOU PICK SETS WHERE YOU ARE (user 2026-09-23: *"that files folder becomes the
870
904
  * default NOT the previous folder"*). The list is rooted at the top, so a pick can sit in any open
871
905
  * folder; the current folder follows it to the pick's parent. Whatever the old path had open
@@ -931,6 +965,8 @@ export function MediaLibraryBrowse({
931
965
  * that does not gets the native ones, exactly as before (kol-client-olina 2026-09-22: the
932
966
  * browser's own grey prompt box was the last non-DS surface on media.olina-productions.com). */
933
967
  const menu = useContextMenu()
968
+ /* touch has no right button: a held press on a row or tile opens the same menu (useLongPress) */
969
+ const press = useLongPress()
934
970
  const modal = useModal()
935
971
  const canWrite = !!fileActions && writable
936
972
  const [busyAction, setBusyAction] = useState(false)
@@ -1119,7 +1155,33 @@ export function MediaLibraryBrowse({
1119
1155
  }
1120
1156
  /* stepping into a folder drops the pick — the preview would otherwise describe something no
1121
1157
  * longer in the list */
1122
- const goFolder = (path) => { setPickedFile(null); setPickedFolder(null); setPrefix(path) }
1158
+ /* GOING UP SELECTS THE FOLDER YOU CAME FROM (user 2026-09-24: back from `01-shoots` landed on
1159
+ * `img/` with nothing selected and an empty preview — every file manager lights the child you
1160
+ * left). One rule for every way up — ⌘↑ and the crumbs both come through here. */
1161
+ const goFolder = (path) => {
1162
+ const child = path !== prefix && prefix.startsWith(path) ? `${path}${prefix.slice(path.length).split('/')[0]}/` : null
1163
+ setPickedFile(null); setPickedFolder(null); setPrefix(path)
1164
+ if (child) { setRowSelection(new Set([child])); setPickedFolder(child); selectAnchorRef.current = child; selectCursorRef.current = null }
1165
+ }
1166
+ /* ONE "GO UP" FOR ALL THREE VIEWS (kol-client-olina 2026-09-23: *"3 different stops for 3
1167
+ * modes … they should all go all the way to the shared root folder"*): parent folder → … → the
1168
+ * bucket's top → the title root, which is what `onHome` does. With one bucket and no
1169
+ * `bucketLevel` there is no level above the bucket's top, so that is where it ends. Columns walk
1170
+ * the same path with ArrowLeft. */
1171
+ const goUp = () => {
1172
+ if (prefix) { goFolder(dirOf(prefix)); return }
1173
+ if (!single && !atTitleRoot) goTitleRoot()
1174
+ else if (!single && !atTop) goTop()
1175
+ }
1176
+ /* THE TITLE ROOT CLEARS EVERYTHING that would say you are somewhere else — a pick, a selection —
1177
+ * or the derivation above reads you as still inside the bucket */
1178
+ const goTop = () => { setAppRoot('top'); setPickedFile(null); setPickedFolder(null); setRowSelection(new Set()); setPickedBucket(ROOT_KEY); setPrefix('') }
1179
+ const goTitleRoot = () => { setAppRoot(true); setPickedFile(null); setPickedFolder(null); setPickedBucket(atTitleRoot ? null : (bucketMeta.id ?? bucketMeta.label)); setRowSelection(new Set()); setPrefix('') }
1180
+ /* opening a bucket from the title root, the step the columns' column 1 is */
1181
+ const openBucket = (b) => {
1182
+ setAppRoot(false); setPickedFile(null); setPickedFolder(null); setPickedBucket(null); setRowSelection(new Set())
1183
+ if (b.id != null && b.id !== bucketMeta.id) switchBucket(b.id, ''); else setPrefix('')
1184
+ }
1123
1185
  /* SPACE IS QUICK LOOK IN EVERY VIEW (user 2026-09-23: *"spacebar preview only works in column,
1124
1186
  * fix that. CONSISTENCY"*). `ColumnBrowser` has owned that key since it shipped, so the columns
1125
1187
  * had it and the two views built later did not. The target is whatever is picked — the previewed
@@ -1169,6 +1231,8 @@ export function MediaLibraryBrowse({
1169
1231
  }
1170
1232
  const spaceQuickLook = () => {
1171
1233
  const key = pickedFile?.key ?? pickedFolder ?? (rowSelection.size === 1 ? [...rowSelection][0] : null)
1234
+ /* a picked bucket or the title Quick-Looks like any folder (user 2026-09-25) */
1235
+ if (!key && previewRoot) { setQuickLookFolder(ROOT_KEY); return }
1172
1236
  if (!key) return
1173
1237
  /* A FOLDER QUICK-LOOKS TOO (user 2026-09-23: *"anything can be quick viewed, also folders"*) —
1174
1238
  * the same pane the preview column draws, at overlay size. */
@@ -1196,7 +1260,14 @@ export function MediaLibraryBrowse({
1196
1260
  const gridOrderRef = useRef([])
1197
1261
  const arrowNav = (e) => {
1198
1262
  if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) return false
1199
- if (view === 'columns' || quickLook || quickLookFolder || searchOpen) return false
1263
+ /* ⇧+ARROWS KEEP SELECTING UNDER QUICK LOOK (user 2026-09-24), Finder's: the selection grows
1264
+ * underneath and the window pages through it */
1265
+ if (view === 'columns' || (quickLook && !e.shiftKey) || quickLookFolder || searchOpen) return false
1266
+ if (atTitleRoot && !single) {
1267
+ if (e.metaKey && e.key === 'ArrowUp') goUp()
1268
+ else if (e.metaKey && e.key === 'ArrowDown') { if (atTop) goTitleRoot(); else openBucket(bucketList.find((b) => (b.id ?? b.label) === pickedBucket) ?? bucketMeta) }
1269
+ return true
1270
+ }
1200
1271
  const cur = selectCursorRef.current ?? selectAnchorRef.current ?? [...rowSelection].pop() ?? null
1201
1272
  /* ⇧ + ARROW EXTENDS (user 2026-09-23: *"shift up down or left right … should also make
1202
1273
  * selection"*): the anchor stays where the run began, the cursor moves, and the selection is
@@ -1220,19 +1291,32 @@ export function MediaLibraryBrowse({
1220
1291
  if (el?.tabIndex >= 0) el.focus({ preventScroll: true })
1221
1292
  }
1222
1293
  if (view === 'grid') {
1223
- const list = gridOrderRef.current
1224
- if (!list.length) return true
1225
- if (e.metaKey && e.key === 'ArrowUp') { if (prefix) goFolder(dirOf(prefix)); return true }
1226
- const i = list.findIndex((f) => f.key === cur)
1227
- if (e.metaKey && e.key === 'ArrowDown') { if (i !== -1) openQuickLook({ files: list, index: i }); return true }
1294
+ /* BEFORE the empty-list bail-out: a folder that holds only subfolders has an empty wall, and
1295
+ * ⌘↑ was swallowed there */
1296
+ if (e.metaKey && e.key === 'ArrowUp') { goUp(); return true }
1297
+ /* THE ARROWS WALK WHAT IS DRAWN (user 2026-09-24: they skipped every folder tile) — the
1298
+ * folders, then the files, in the order on screen. The list was the files alone, from before
1299
+ * the grid drew folders. */
1300
+ const files = gridOrderRef.current
1301
+ const items = [...(settings.flat ? [] : folders.map((f) => ({ key: prefix + f, folder: true }))), ...files.map((o) => ({ key: o.key, o }))]
1302
+ if (!items.length) return true
1303
+ const i = items.findIndex((it) => it.key === cur)
1304
+ if (e.metaKey && e.key === 'ArrowDown') {
1305
+ const it = items[i]
1306
+ if (it?.folder) goFolder(it.key)
1307
+ else if (it) openQuickLook({ files, index: Math.max(0, files.findIndex((f) => f.key === it.key)) })
1308
+ return true
1309
+ }
1228
1310
  const box = gridMarquee.ref.current
1229
- const cols = box ? getComputedStyle(box).gridTemplateColumns.split(' ').filter(Boolean).length : 1
1311
+ /* the band lives on the pane now; the column count is the tile grid's, inside it */
1312
+ const tiles = box?.querySelector('.grid')
1313
+ const cols = tiles ? getComputedStyle(tiles).gridTemplateColumns.split(' ').filter(Boolean).length : 1
1230
1314
  const step = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -cols, ArrowDown: cols }[e.key]
1231
- const o = list[i === -1 ? 0 : Math.max(0, Math.min(list.length - 1, i + step))]
1232
- if (e.shiftKey && extend(list.map((f) => f.key), o.key)) { reveal(box, o.key); return true }
1233
- setRowSelection(new Set([o.key])); selectAnchorRef.current = o.key; selectCursorRef.current = null
1234
- setPickedFolder(null); setPickedFile(o)
1235
- reveal(box, o.key)
1315
+ const it = items[i === -1 ? 0 : Math.max(0, Math.min(items.length - 1, i + step))]
1316
+ if (e.shiftKey && extend(items.map((x) => x.key), it.key)) { reveal(box, it.key); return true }
1317
+ setRowSelection(new Set([it.key])); selectAnchorRef.current = it.key; selectCursorRef.current = null
1318
+ if (it.folder) { setPickedFile(null); setPickedFolder(it.key) } else { setPickedFolder(null); setPickedFile(it.o) }
1319
+ reveal(box, it.key)
1236
1320
  return true
1237
1321
  }
1238
1322
  if (view === 'rows') {
@@ -1246,7 +1330,7 @@ export function MediaLibraryBrowse({
1246
1330
  }
1247
1331
  const isFolder = cur?.endsWith('/')
1248
1332
  const open = isFolder && rowsExpanded.has(cur)
1249
- if (e.metaKey && e.key === 'ArrowUp') { const up = cur ? dirOf(cur) : dirOf(prefix); if (up) select(up); return true }
1333
+ if (e.metaKey && e.key === 'ArrowUp') { const up = cur ? dirOf(cur) : dirOf(prefix); if (up) select(up); else goUp(); return true }
1250
1334
  if (e.metaKey && e.key === 'ArrowDown') {
1251
1335
  if (!cur) return true
1252
1336
  if (isFolder) goFolder(cur)
@@ -1280,6 +1364,18 @@ export function MediaLibraryBrowse({
1280
1364
  if (e.key === '/' && !e.metaKey && !e.ctrlKey) { e.preventDefault(); setSearchOpen(true) }
1281
1365
  if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); setSearchOpen(true) }
1282
1366
  if (arrowNav(e)) { e.preventDefault(); return }
1367
+ /* ENTER OPENS THE SELECTED FOLDER, ⇧ENTER GOES TO THE PARENT (user 2026-09-24). The same
1368
+ * two moves as a double-click and ⌘↑, so the child you leave is still the one selected.
1369
+ * A focused tile has already opened itself on Enter (it calls preventDefault). */
1370
+ if (e.key === 'Enter' && !e.metaKey && !e.ctrlKey && !e.altKey && !e.defaultPrevented && !quickLook && !quickLookFolder && !searchOpen) {
1371
+ if (e.shiftKey) { e.preventDefault(); goUp(); return }
1372
+ if (view !== 'columns') {
1373
+ const root = rootItems.find((it) => it.key === pickedBucket)
1374
+ const sel = rowSelection.size === 1 ? [...rowSelection][0] : pickedFolder
1375
+ if (root) { e.preventDefault(); root.open(); return }
1376
+ if (sel?.endsWith('/')) { e.preventDefault(); goFolder(sel); return }
1377
+ }
1378
+ }
1283
1379
  /* the columns keep their own space handler — theirs knows the column's cursor */
1284
1380
  if (e.key === ' ' && view !== 'columns' && !searchOpen) {
1285
1381
  e.preventDefault()
@@ -1294,6 +1390,16 @@ export function MediaLibraryBrowse({
1294
1390
 
1295
1391
  const [quickLook, setQuickLook] = useState(null)
1296
1392
  const [quickLookFolder, setQuickLookFolder] = useState(null)
1393
+ /* …and the open window takes the new selection as its set, on the file the cursor reached */
1394
+ useEffect(() => {
1395
+ if (!quickLook || rowSelection.size < 2) return
1396
+ const order = view === 'grid' ? gridOrderRef.current : sortedObjects
1397
+ const files = order.filter((o) => rowSelection.has(o.key) && !o.key.endsWith('/'))
1398
+ if (files.length < 2) return
1399
+ if (files.length === quickLook.files.length && files.every((f, i) => f.key === quickLook.files[i].key)) return
1400
+ const at = files.findIndex((f) => f.key === selectCursorRef.current)
1401
+ setQuickLook({ files, index: at === -1 ? 0 : at })
1402
+ }, [rowSelection]) // eslint-disable-line react-hooks/exhaustive-deps
1297
1403
  const columnsRef = useRef(null)
1298
1404
  /* `flat` IS THE WALL'S, NOT THIS PAGE'S (user 2026-09-22: "there is no option to switch to flat
1299
1405
  * mode, bc its not in files view. this is row mode.. this makes no sense"). It reached here from
@@ -1306,6 +1412,10 @@ export function MediaLibraryBrowse({
1306
1412
  const view = stored === 'list' ? 'rows' : stored
1307
1413
  const isWall = view === 'grid'
1308
1414
  const folderView = view === 'rows' ? 'rows' : 'columns'
1415
+ /* BELOW `md` BOTH TREE VIEWS ARE THE PHONE LIST. `ColumnBrowser` already forks to its one-level
1416
+ * stack there; the row view had no phone form and drew the desktop list and its preview pane
1417
+ * squeezed into 390 — 35px of rows with no names. Same query, same breakpoint as the columns. */
1418
+ const phone = useMediaQuery('(max-width: 767px)')
1309
1419
  const setView = (v) => {
1310
1420
  onViewChange?.(v)
1311
1421
  setSettings({
@@ -1324,6 +1434,14 @@ export function MediaLibraryBrowse({
1324
1434
  const previewWidth = settings.columnWidths?.preview ?? 320
1325
1435
  const previewFile = pickedFile && pickedFile.key.startsWith(prefix) ? pickedFile : null
1326
1436
  const previewFolder = pickedFolder && pickedFolder.startsWith(prefix) ? pickedFolder : null
1437
+ /* a bucket's totals come from `folderTree` when the consumer passes one; the open bucket can
1438
+ * always count its own listing */
1439
+ const bucketTotals = (id) => {
1440
+ const t = folderTree?.[id]
1441
+ if (t && t.files != null) return { files: t.files, bytes: t.bytes ?? 0 }
1442
+ if (id === bucketMeta.id) return { files: objects.length, bytes: objects.reduce((n, o) => n + (o.size || 0), 0) }
1443
+ return null
1444
+ }
1327
1445
  /* THE PREVIEW PANE, ONE FUNCTION FOR THE ROWS AND THE GRID (user 2026-09-23: *"why did you not
1328
1446
  * treat this like column and row, with container padding + preview pane? CONSISTENCY"*). Several
1329
1447
  * selected → the stack; a file → its preview; a folder → its summary; nothing → the reserved
@@ -1341,6 +1459,8 @@ export function MediaLibraryBrowse({
1341
1459
  }} />
1342
1460
  ) : previewFolder ? (
1343
1461
  <FolderPreview key={previewFolder} path={previewFolder} objects={objects} width={previewWidth} formatDate={formatDate} />
1462
+ ) : previewRoot ? (
1463
+ <ContainerPreview key={previewRoot.key} name={previewRoot.name} icon={previewRoot.icon} facts={previewRoot.facts} width={previewWidth} />
1344
1464
  ) : (
1345
1465
  <div className="kol-column-browser-preview shrink-0" style={{ width: previewWidth }} />
1346
1466
  )
@@ -1427,14 +1547,14 @@ export function MediaLibraryBrowse({
1427
1547
  if (!root) return undefined
1428
1548
  const id = requestAnimationFrame(() => root.scrollTo({ left: root.scrollWidth, behavior: 'smooth' }))
1429
1549
  return () => cancelAnimationFrame(id)
1430
- }, [folderView, prefix, appRoot, pickedFile, bucketMeta.id])
1550
+ }, [folderView, prefix, atTitleRoot, pickedFile, bucketMeta.id])
1431
1551
 
1432
1552
  if (error) return <p className="kol-mono-12 text-ui-error">Error: {error}</p>
1433
1553
  /* NOT SELECTABLE (user 2026-09-23, superseding 2026-09-22's selectable crumbs: *"instead of this
1434
1554
  * highlight, could we make a hover click to copy path?"*). A drag across the line painted the
1435
1555
  * browser's selection block over it; the path is one click on the current crumb now. */
1436
1556
  const crumbCls = (active) => `cursor-pointer select-none transition-colors ${active ? 'text-oq-96' : 'text-oq-48 hover:text-oq-64'}`
1437
- const fullPath = (tail = '') => [ROOT, ...(appRoot ? [] : [label, ...crumbs]), ...(tail ? [tail] : [])].join('/')
1557
+ const fullPath = (tail = '') => [ROOT, ...(atTitleRoot ? [] : [label, ...crumbs]), ...(tail ? [tail] : [])].join('/')
1438
1558
 
1439
1559
  /* ── THE WALL'S LIST, on the same surface (2026-09-22) ──────────────────────────────────────
1440
1560
  * `flat` belongs to the FILE views and not to the tree (that ruling stands): a wall shows the
@@ -1459,20 +1579,48 @@ export function MediaLibraryBrowse({
1459
1579
  * key space, or the subset the filter bar left standing. `wallPane` is the same two file views
1460
1580
  * the Files page rendered, in a pane of the browser's own height so the page does not jump
1461
1581
  * between views. */
1582
+ /* AT THE TITLE ROOT, ROWS AND GRID LIST THE BUCKETS (same ticket: they listed the bucket's
1583
+ * folders under a crumb that named the title). One row / tile per bucket; a click opens it. */
1584
+ const bucketList = buckets.length ? buckets : [bucketMeta]
1585
+ const titleLevel = atTitleRoot && !single
1586
+ /* what the root levels hold: above the title, the title as one folder; inside it, the buckets.
1587
+ * Each is a folder with its own icon — a click highlights it, a double-click opens it. */
1588
+ const previewRoot = (() => {
1589
+ if (!titleLevel || pickedBucket == null) return null
1590
+ if (atTop && pickedBucket === ROOT_KEY) {
1591
+ const all = bucketList.map((b) => bucketTotals(b.id)).filter(Boolean)
1592
+ return { key: ROOT_KEY, name: ROOT, icon: 'folder', facts: [
1593
+ ['Kind', 'folder'],
1594
+ ['Buckets', `${bucketList.length}`],
1595
+ ...(all.length === bucketList.length ? [['Items', `${all.reduce((n, x) => n + x.files, 0)}`], ['Size', formatSize(all.reduce((n, x) => n + x.bytes, 0))]] : []),
1596
+ ] }
1597
+ }
1598
+ const b = bucketList.find((x) => (x.id ?? x.label) === pickedBucket)
1599
+ if (!b) return null
1600
+ const tot = bucketTotals(b.id)
1601
+ return { key: pickedBucket, name: b.label || 'bucket', icon: 'database', facts: [
1602
+ ['Kind', 'bucket'],
1603
+ ...(tot ? [['Items', `${tot.files}`], ['Size', formatSize(tot.bytes)]] : []),
1604
+ ...(b.writable != null ? [['Access', b.writable ? 'read · write' : 'read-only']] : []),
1605
+ ] }
1606
+ })()
1607
+ const rootItems = !titleLevel ? [] : atTop
1608
+ ? [{ key: ROOT_KEY, name: ROOT, icon: 'folder', open: goTitleRoot }]
1609
+ : bucketList.map((b) => ({ key: b.id ?? b.label, name: b.label || 'bucket', icon: 'database', open: () => openBucket(b) }))
1462
1610
  const treeBody = (keep) => {
1463
1611
  const treeObjects = keep ? sortedObjects.filter((o) => keep.has(o.key)) : sortedObjects
1464
1612
  return (
1465
- folderView === 'columns' ? (
1613
+ folderView === 'columns' || phone ? (
1466
1614
  <div ref={columnsRef} className="relative">
1467
1615
  <ColumnBrowser
1468
1616
  autoFocus={autoFocus}
1469
- className={appRoot ? 'is-root' : ''}
1617
+ className={atTitleRoot ? 'is-root' : ''}
1470
1618
  height={settings.columnHeight}
1471
1619
  onHeightChange={(px) => setSettings({ ...settings, columnHeight: px })}
1472
1620
  columnWidths={settings.columnWidths}
1473
1621
  onColumnResize={(i, px) => setSettings({ ...settings, columnWidths: { ...(settings.columnWidths ?? {}), [i]: px } })}
1474
1622
  objects={treeObjects.filter((o) => !isSystemFile(o.key)).map((o) => ({ ...o, key: `${VROOT}${o.key}` }))}
1475
- prefix={single ? prefix : (appRoot ? `${ROOT}/` : `${VROOT}${prefix}`)}
1623
+ prefix={single ? prefix : (atTitleRoot ? `${ROOT}/` : `${VROOT}${prefix}`)}
1476
1624
  onPrefix={(v) => {
1477
1625
  /* single: the browser's path IS the bucket path, no segment to strip */
1478
1626
  if (single) { setAppRoot(false); setPrefix(v); return }
@@ -1563,9 +1711,9 @@ export function MediaLibraryBrowse({
1563
1711
  <div className={`flex-1 min-w-0 flex flex-col${showRowPreview ? ' border-r' : ''}`} style={{ borderColor: 'var(--kol-oq-08)' }}>
1564
1712
  <ul ref={rowsMarquee.ref} {...rowsMarquee.props}
1565
1713
  /* the background deselects, here as in the columns */
1566
- onClick={(e) => { if (!e.target.closest('[data-marquee-key]')) { setRowSelection(new Set()); setPickedFile(null); setPickedFolder(null) } }}
1714
+ onClick={(e) => { if (!e.target.closest('[data-marquee-key]')) { setRowSelection(new Set()); setPickedFile(null); setPickedFolder(null); setPickedBucket(null) } }}
1567
1715
  className="relative kol-column-browser-column kol-row-browser flex-1 min-w-0 flex flex-col overflow-y-auto"
1568
- onContextMenu={(e) => menu.openAt(e, { type: 'level', path: prefix })}
1716
+ onContextMenu={titleLevel ? undefined : (e) => menu.openAt(e, { type: 'level', path: prefix })}
1569
1717
  onDragOver={(e) => {
1570
1718
  const files = dropFilesTo && isFileDrag(e)
1571
1719
  const d = !isFileDrag(e) && dragFor?.(prefix)
@@ -1587,6 +1735,11 @@ export function MediaLibraryBrowse({
1587
1735
  d.onDrop(prefix)
1588
1736
  }}>
1589
1737
  {rowsMarquee.rect && <div className="kol-marquee" style={rowsMarquee.rect} />}
1738
+ {titleLevel ? rootItems.map((it) => (
1739
+ <FolderRow key={it.key} name={it.name} icon={it.icon} cols={rowCols}
1740
+ selected={pickedBucket === it.key}
1741
+ onClick={(e) => { e.stopPropagation(); setPickedBucket(it.key) }} onDoubleClick={it.open} />
1742
+ )) : <>
1590
1743
  {rowLevel('', treeObjects).folders.map((f) => {
1591
1744
  const path = f
1592
1745
  const open = rowsExpanded.has(path)
@@ -1632,6 +1785,7 @@ export function MediaLibraryBrowse({
1632
1785
  if (!selectRow(o.key, e, visibleRowOrder(treeObjects))) rowPickFile(o)
1633
1786
  }} />
1634
1787
  ))}
1788
+ </>}
1635
1789
  </ul>
1636
1790
  </div>
1637
1791
  {showRowPreview && previewPane()}
@@ -1647,13 +1801,42 @@ export function MediaLibraryBrowse({
1647
1801
  * inside it, the same preview pane beside them, the same count line under it — with the size
1648
1802
  * slider at its right end, Finder's. */
1649
1803
  const tileSize = settings.tileSize ?? 180
1804
+ /* the tile size is the GRID's, not a level's (user 2026-09-24: the slider vanished at the root
1805
+ * levels) — one control, drawn wherever the grid is */
1806
+ const sizeSlider = (
1807
+ <input type="range" className="slider-black w-32 cursor-pointer" min={100} max={360} step={20} value={tileSize}
1808
+ onChange={(e) => setSettings({ ...settings, tileSize: Number(e.target.value) })} aria-label="Tile size" />
1809
+ )
1650
1810
  const wallPane = (files) => (
1651
1811
  <div className="flex flex-col gap-2" style={{ height: settings.columnHeight ?? SETTINGS_BASE.columnHeight }}>
1652
1812
  <div className="relative border rounded flex overflow-hidden flex-1 min-h-0" style={{ borderColor: 'var(--kol-oq-08)' }}>
1653
- <div className={`flex-1 min-w-0 overflow-y-auto p-4${showGridPreview ? ' border-r' : ''}`} style={{ borderColor: 'var(--kol-oq-08)' }}
1654
- onClick={(e) => { if (!e.target.closest('[data-marquee-key]')) { setRowSelection(new Set()); setPickedFile(null); setPickedFolder(null) } }}>
1655
- <WallBody files={files} layout="grid" tiles="media" cardMin={tileSize} marquee={gridMarquee} onSorted={(list) => { gridOrderRef.current = list }}
1656
- onBackgroundClick={() => { setRowSelection(new Set()); setPickedFile(null); setPickedFolder(null) }}
1813
+ {/* THE MARQUEE IS THE PANE'S, not the tile grid's (user 2026-09-23: a drag only started where
1814
+ * there were tiles — the grid is as tall as its tiles, the pane is the whole frame). The rows'
1815
+ * band has always been on their full-height list; this is the grid catching up. */}
1816
+ <div ref={gridMarquee.ref} {...gridMarquee.props}
1817
+ className={`relative flex-1 min-w-0 overflow-y-auto p-4${showGridPreview ? ' border-r' : ''}`} style={{ borderColor: 'var(--kol-oq-08)' }}
1818
+ /* THE CONTAINER IS A LEVEL, like a column (kol-client-olina 2026-09-23: *"context menu anywhere
1819
+ * by right click"*). A tile's own menu stops the event first, so it still wins. */
1820
+ onContextMenu={titleLevel ? undefined : (e) => menu.openAt(e, { type: 'level', path: prefix })}
1821
+ onClick={(e) => { if (!e.target.closest('[data-marquee-key]')) { setRowSelection(new Set()); setPickedFile(null); setPickedFolder(null); setPickedBucket(null) } }}>
1822
+ {gridMarquee.rect && <div className="kol-marquee" style={gridMarquee.rect} />}
1823
+ {titleLevel ? (
1824
+ <div className="grid gap-x-3 gap-y-5" style={{ gridTemplateColumns: `repeat(auto-fill, minmax(${tileSize}px, 1fr))` }}>
1825
+ {rootItems.map((it) => (
1826
+ <MediaTile key={it.key} preview={<Icon name={it.icon} size="100%" className="text-oq-48" />} name={it.name}
1827
+ selected={pickedBucket === it.key}
1828
+ onClick={(e) => { e.stopPropagation(); setPickedBucket(it.key) }} onDoubleClick={it.open} />
1829
+ ))}
1830
+ </div>
1831
+ ) : <WallBody files={files} layout="grid" tiles="media" cardMin={tileSize} onSorted={(list) => { gridOrderRef.current = list }}
1832
+ /* THE GRID DRAWS THE LEVEL'S SUBFOLDERS, ahead of the files (same source: kol-client-olina
1833
+ * 2026-09-23 — `projects/` read `0 files` over a blank pane). `flat` is the subtree's files
1834
+ * and no folders, as it was. */
1835
+ folders={settings.flat ? [] : folders.map((f) => prefix + f)}
1836
+ onOpenFolder={goFolder}
1837
+ onPickFolder={(path, e) => { if (selectRow(path, e, [...folders.map((f) => prefix + f), ...files.map((f) => f.key)])) return; rowPickFolder(path) }}
1838
+ onFolderContextMenu={(e, path) => menu.openAt(e, { type: 'folder', path, targets: targetsFor(path) })}
1839
+ onBackgroundClick={() => { setRowSelection(new Set()); setPickedFile(null); setPickedFolder(null); setPickedBucket(null) }}
1657
1840
  sortBy={settings.sortBy} sortDir={settings.sortDir}
1658
1841
  pageSize={settings.pageSize} listId={`${prefix}|${view}|${settings.flat}|${refreshKey}|${bucketMeta.id}`}
1659
1842
  mediaUrl={mediaUrl} downloadUrl={downloadUrl} keySet={keySet} videoPreview={settings.videoPreview} formatDate={formatDate}
@@ -1666,7 +1849,7 @@ export function MediaLibraryBrowse({
1666
1849
  onOpen={(o, files) => openQuickLook({ files, index: Math.max(0, files.findIndex((f) => f.key === o.key)) })}
1667
1850
  /* COPY AND DOWNLOAD LIVE IN THE MENU AND QUICK LOOK (user 2026-09-23) — as in the rows and the
1668
1851
  * columns. The tile carries no buttons. */
1669
- onContextMenu={(e, o) => menu.openAt(e, { type: 'file', path: o.key, o, targets: targetsFor(o.key) })} />
1852
+ onContextMenu={(e, o) => menu.openAt(e, { type: 'file', path: o.key, o, targets: targetsFor(o.key) })} />}
1670
1853
  </div>
1671
1854
  {showGridPreview && previewPane()}
1672
1855
  </div>
@@ -1699,10 +1882,10 @@ export function MediaLibraryBrowse({
1699
1882
  : treeBody(filtered ? new Set(filtered.map((f) => f.key)) : null))
1700
1883
 
1701
1884
  return (
1702
- <div className={`flex flex-col gap-6 ${className}`.trim()}>
1703
- <LibraryHeader title={title} buckets={buckets} bucketId={bucketMeta.id} appRoot={appRoot} bucketMeta={bucketMeta} writable={writable} headerActions={headerActions} headerTrailing={headerTrailing} onTrash={trash ? () => setTrashOpen(true) : undefined}
1704
- onHome={() => { setAppRoot(true); setPickedFile(null); setPrefix('') }}
1705
- onBucket={(v) => { if (v === 'all') { setAppRoot(true); setPrefix('') } else { setAppRoot(false); switchBucket(v) } }}
1885
+ <div {...press} className={`flex flex-col gap-6 ${className}`.trim()}>
1886
+ <LibraryHeader title={title} buckets={buckets} bucketId={bucketMeta.id} appRoot={atTitleRoot} bucketMeta={bucketMeta} writable={writable} headerActions={headerActions} headerTrailing={headerTrailing} onTrash={trash ? () => setTrashOpen(true) : undefined}
1887
+ onHome={goTitleRoot}
1888
+ onBucket={(v) => { if (v === 'all') goTitleRoot(); else { setAppRoot(false); switchBucket(v) } }}
1706
1889
  onSettings={() => setSettingsOpen(true)} />
1707
1890
 
1708
1891
  {/* THE BANNER SLOT — anything that must sit directly under the header and above the body.
@@ -1728,16 +1911,16 @@ export function MediaLibraryBrowse({
1728
1911
  {/* the elided line — first segment, an ellipsis for anything between,
1729
1912
  * and the current one. Below `md` only. */}
1730
1913
  <div className="flex md:hidden items-center gap-2 kol-mono-12 min-w-0">
1731
- <button className={crumbCls(appRoot)} onClick={() => { setAppRoot(true); setPickedFile(null); setPrefix('') }}>{ROOT}</button>
1732
- {!appRoot && bucketMeta.id && crumbs.length > 1 && (
1914
+ <button className={crumbCls(atTitleRoot && !atTop)} onClick={single ? goTitleRoot : goTop}>{ROOT}</button>
1915
+ {!atTitleRoot && bucketMeta.id && crumbs.length > 1 && (
1733
1916
  <><span className="text-oq-32">/</span><span className="text-oq-32">…</span></>
1734
1917
  )}
1735
- {!appRoot && bucketMeta.id && (
1918
+ {!atTitleRoot && bucketMeta.id && (
1736
1919
  <span className="flex items-center gap-2 min-w-0">
1737
1920
  <span className="text-oq-32">/</span>
1738
1921
  <button
1739
1922
  className={`${crumbCls(true)} truncate`}
1740
- onClick={() => setPrefix(crumbs.length ? crumbs.join('/') + '/' : '')}
1923
+ onClick={() => goFolder(crumbs.length ? crumbs.join('/') + '/' : '')}
1741
1924
  >
1742
1925
  {(crumbs[crumbs.length - 1] ?? label).toUpperCase()}
1743
1926
  </button>
@@ -1745,33 +1928,48 @@ export function MediaLibraryBrowse({
1745
1928
  )}
1746
1929
  </div>
1747
1930
  <div className="hidden md:flex items-center gap-2 kol-mono-12">
1748
- <button className={crumbCls(appRoot)} onClick={() => { setAppRoot(true); setPickedFile(null); setPrefix('') }}>{ROOT}</button>
1749
- {!appRoot && bucketMeta.id && (
1931
+ <button className={crumbCls(atTitleRoot && (!atTop || pickedBucket === ROOT_KEY))} onClick={single ? goTitleRoot : goTop}>{ROOT}</button>
1932
+ {/* A PICKED BUCKET ENDS THE CRUMB at the title level (user 2026-09-24: a selected folder
1933
+ * must show in the crumb, as a selected file does — a bucket is a folder) */}
1934
+ {titleLevel && !atTop && pickedBucket && rootItems.some((it) => it.key === pickedBucket) && (
1935
+ <span className="flex items-center gap-2"><span className="text-oq-32 select-none">/</span>
1936
+ <span className={crumbCls(true)}>{rootItems.find((it) => it.key === pickedBucket).name.toUpperCase()}</span>
1937
+ </span>
1938
+ )}
1939
+ {!atTitleRoot && bucketMeta.id && (
1750
1940
  <span className="flex items-center gap-2">
1751
1941
  <span className="text-oq-32 select-none">/</span>
1752
- {crumbs.length === 0 && !(folderView === 'columns' && pickedFile)
1942
+ {crumbs.length === 0 && !pickedFile && !pickedFolder
1753
1943
  ? <CopyCrumb className={crumbCls(true)} label={label.toUpperCase()} path={fullPath()} />
1754
- : <button className={crumbCls(false)} onClick={() => setPrefix('')}>{label.toUpperCase()}</button>}
1944
+ : <button className={crumbCls(false)} onClick={() => goFolder('')}>{label.toUpperCase()}</button>}
1755
1945
  </span>
1756
1946
  )}
1757
- {!appRoot && crumbs.map((seg, i) => {
1947
+ {!atTitleRoot && crumbs.map((seg, i) => {
1758
1948
  const to = crumbs.slice(0, i + 1).join('/') + '/'
1759
1949
  const last = i === crumbs.length - 1
1760
- const current = last && !(folderView === 'columns' && pickedFile)
1950
+ const current = last && !pickedFile && !pickedFolder
1761
1951
  return (
1762
1952
  <span key={to} className="flex items-center gap-2">
1763
1953
  <span className="text-oq-32 select-none">/</span>
1764
1954
  {current
1765
1955
  ? <CopyCrumb className={crumbCls(true)} label={seg.toUpperCase()} path={fullPath()} />
1766
- : <button className={crumbCls(false)} onClick={() => setPrefix(to)}>{seg.toUpperCase()}</button>}
1956
+ : <button className={crumbCls(false)} onClick={() => goFolder(to)}>{seg.toUpperCase()}</button>}
1767
1957
  </span>
1768
1958
  )
1769
1959
  })}
1770
- {folderView === 'columns' && pickedFile && pickedFile.key.startsWith(prefix) && (
1960
+ {/* THE PICKED FILE ENDS THE CRUMB IN EVERY VIEW (kol-client-olina 2026-09-23 — it was the
1961
+ * columns' alone, so a file picked in rows showed its folder and never itself) */}
1962
+ {pickedFile && pickedFile.key.startsWith(prefix) && (
1771
1963
  <span className="flex items-center gap-2"><span className="text-oq-32 select-none">/</span>
1772
1964
  <CopyCrumb className={crumbCls(true)} label={pickedFile.key.slice(prefix.length).toUpperCase()} path={fullPath(pickedFile.key.slice(prefix.length))} />
1773
1965
  </span>
1774
1966
  )}
1967
+ {/* …and so does a picked folder */}
1968
+ {!pickedFile && pickedFolder && pickedFolder !== prefix && pickedFolder.startsWith(prefix) && (
1969
+ <span className="flex items-center gap-2"><span className="text-oq-32 select-none">/</span>
1970
+ <CopyCrumb className={crumbCls(true)} label={pickedFolder.slice(prefix.length).replace(/\/$/, '').toUpperCase()} path={fullPath(pickedFolder.slice(prefix.length).replace(/\/$/, ''))} />
1971
+ </span>
1972
+ )}
1775
1973
  </div>
1776
1974
  {/* the control cluster is desktop-only — below `md` this is the same
1777
1975
  * setting the drawer already carries, and nothing in this row survives 390.
@@ -1866,7 +2064,9 @@ export function MediaLibraryBrowse({
1866
2064
  )}
1867
2065
  {quickLookFolder && (
1868
2066
  <FullscreenOverlay open onClose={() => setQuickLookFolder(null)} scrim>
1869
- <FolderPreview path={quickLookFolder} objects={objects} width={420} formatDate={formatDate} />
2067
+ {quickLookFolder === ROOT_KEY
2068
+ ? previewRoot && <ContainerPreview name={previewRoot.name} icon={previewRoot.icon} facts={previewRoot.facts} width={420} />
2069
+ : <FolderPreview path={quickLookFolder} objects={objects} width={420} formatDate={formatDate} />}
1870
2070
  </FullscreenOverlay>
1871
2071
  )}
1872
2072
  {quickLook && (
@@ -1906,20 +2106,23 @@ export function MediaLibraryBrowse({
1906
2106
  * height again, and one shared `gap-3` puts both gaps level on its own. */}
1907
2107
  {/* THE COUNT LINE IS EVERY VIEW'S (user 2026-09-23) — and in the grid it carries the tile-size
1908
2108
  * slider at its right end, bare: no glyph, no readout (Finder's). */}
1909
- {isWall ? (
2109
+ {atTitleRoot && !single ? (
2110
+ <div className="flex items-center justify-between gap-4 h-4">
2111
+ <p className="kol-mono-12 text-fg-48">
2112
+ {atTop ? '1 folder' : `${bucketList.length} ${bucketList.length === 1 ? 'bucket' : 'buckets'}`}
2113
+ {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))}`}
2114
+ </p>
2115
+ {isWall && sizeSlider}
2116
+ </div>
2117
+ ) : isWall ? (
1910
2118
  /* ONE LINE'S HEIGHT, like the other views' count line — the slider is 24px tall and made
1911
2119
  * this row 8px taller than the fill budget, so the page scrolled by that much */
1912
2120
  <div className="flex items-center justify-between gap-4 h-4">
1913
2121
  <p className="kol-mono-12 text-fg-48">
1914
- {wallFiles.length} {wallFiles.length === 1 ? 'file' : 'files'} · {formatSize(wallFiles.reduce((n, o) => n + (o.size ?? 0), 0))}
2122
+ {!settings.flat && folders.length > 0 && `${folders.length} folder${folders.length > 1 ? 's' : ''} · `}{wallFiles.length} {wallFiles.length === 1 ? 'file' : 'files'} · {formatSize(wallFiles.reduce((n, o) => n + (o.size ?? 0), 0))}
1915
2123
  </p>
1916
- <input type="range" className="slider-black w-32 cursor-pointer" min={100} max={360} step={20} value={tileSize}
1917
- onChange={(e) => setSettings({ ...settings, tileSize: Number(e.target.value) })} aria-label="Tile size" />
2124
+ {sizeSlider}
1918
2125
  </div>
1919
- ) : appRoot && folderView === 'columns' && folderTree ? (
1920
- <p className="kol-mono-12 text-fg-48">
1921
- {buckets.length} buckets · {Object.values(folderTree).reduce((n, t) => n + (t.files ?? 0), 0)} files · {formatSize(Object.values(folderTree).reduce((n, t) => n + (t.bytes ?? 0), 0))}
1922
- </p>
1923
2126
  ) : (
1924
2127
  <p className="kol-mono-12 text-fg-48">
1925
2128
  {folders.length > 0 && `${folders.length} folder${folders.length > 1 ? 's' : ''} · `}
@@ -2106,6 +2309,8 @@ export function WallBody({
2106
2309
  files, layout = 'list', sortBy = 'name', sortDir = 'asc', pageSize = 200, listId = '', cardMin = 260,
2107
2310
  mediaUrl, downloadUrl, keySet, videoPreview, formatDate = defaultFormatDate,
2108
2311
  selected, selectMode = false, onToggleSelect, onSorted, onContextMenu, onPick,
2312
+ /* the media grid's folder tiles — see the page's `wallPane` */
2313
+ folders = [], onOpenFolder, onPickFolder, onFolderContextMenu,
2109
2314
  /* ONE INSPECTOR (user 2026-09-23). Given `onOpen`, the wall hands the open UP and keeps no
2110
2315
  * lightbox of its own — otherwise a page that also opens one (space, a row double-click) ends up
2111
2316
  * with two stacked overlays of the same file. The standalone wall passes nothing and keeps its
@@ -2161,7 +2366,7 @@ export function WallBody({
2161
2366
  const openAt = (o, idx) => (onOpen ? onOpen(o, sorted) : setLightboxIndex(idx))
2162
2367
  return (
2163
2368
  <div className="flex flex-col gap-3">
2164
- {sorted.length === 0 ? null : layout !== 'grid' ? (
2369
+ {sorted.length === 0 && !(layout === 'grid' && tiles === 'media' && folders.length) ? null : layout !== 'grid' ? (
2165
2370
  <div className="flex flex-col">
2166
2371
  {shown.map((o, idx) => (
2167
2372
  <ContentRow key={o.key} variant="default" media={renderThumb(o, null)} title={renderName(o)} date={formatDate(o.uploaded)} size={formatSize(o.size)}
@@ -2176,6 +2381,13 @@ export function WallBody({
2176
2381
  style={{ gridTemplateColumns: `repeat(auto-fill, minmax(${cardMin}px, 1fr))` }}
2177
2382
  onClick={(e) => { if (!e.target.closest('[data-marquee-key]')) onBackgroundClick?.() }}>
2178
2383
  {marquee?.rect && <div className="kol-marquee" style={marquee.rect} />}
2384
+ {folders.map((path) => (
2385
+ <MediaTile key={path} markKey={path} preview={<Icon name="folder" size="100%" className="text-oq-48" />}
2386
+ name={path.replace(/\/$/, '').split('/').pop()} selected={isSelected(path)}
2387
+ onContextMenu={onFolderContextMenu ? (e) => onFolderContextMenu(e, path) : undefined}
2388
+ onDoubleClick={() => onOpenFolder?.(path)}
2389
+ onClick={onPickFolder ? (e) => onPickFolder(path, e) : undefined} />
2390
+ ))}
2179
2391
  {shown.map((o, idx) => (
2180
2392
  <MediaTile key={o.key} markKey={o.key} preview={renderThumb(o, null)} name={renderName(o)} selected={isSelected(o.key)}
2181
2393
  onContextMenu={onContextMenu ? (e) => onContextMenu(e, o) : undefined}
@@ -72,7 +72,10 @@ export default function ContextMenu({ menu, children, className = '' }) {
72
72
  {/* Any click inside closes — a menu item that leaves the menu open after
73
73
  * acting is the one interaction people report as broken. Items keep their
74
74
  * own onClick; this runs after it on the way up. */}
75
- <div onClick={menu.close} role="none">
75
+ {/* A COLUMN, not a block: the items are `inline-flex`, so in a block they are one line of
76
+ * inline boxes to the float's shrink-to-fit, and the menu took the width of every label
77
+ * side by side — 510px for seven short verbs, past the edge of a phone. */}
78
+ <div onClick={menu.close} role="none" className="flex flex-col">
76
79
  {typeof children === 'function' ? children(menu.payload) : children}
77
80
  </div>
78
81
  </PopoverPanel>