@kolkrabbi/kol-component 0.218.0 → 0.219.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.219.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,13 @@
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
24
  onClick={onClick} onDoubleClick={onDoubleClick} onContextMenu={onContextMenu}
24
25
  onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); onDoubleClick?.(e) } }}>
25
26
  <div className="kol-media-tile-thumb">{preview}</div>
26
27
  <span className="kol-media-tile-name">{name}</span>
28
+ <RowMenuButton variant="grey" onOpen={onContextMenu} className="absolute top-1 right-1" />
27
29
  </div>
28
30
  )
29
31
  }
@@ -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'
@@ -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'
@@ -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
  }
@@ -331,6 +335,7 @@ function FileRow({ o, onClick, onDoubleClick, depth = 0, formatDate, thumb, cols
331
335
  <span className="kol-mono-12 text-fg-32 shrink-0 truncate" style={{ width: cols.date }}>{formatDate?.(o.uploaded)}</span>
332
336
  <span className="kol-mono-12 text-fg-32 shrink-0 truncate" style={{ width: cols.size }}>{formatSize(o.size)}</span>
333
337
  </>}
338
+ <RowMenuButton onOpen={onContextMenu} className="-my-1 -mr-1" />
334
339
  </li>
335
340
  )
336
341
  }
@@ -931,6 +936,8 @@ export function MediaLibraryBrowse({
931
936
  * that does not gets the native ones, exactly as before (kol-client-olina 2026-09-22: the
932
937
  * browser's own grey prompt box was the last non-DS surface on media.olina-productions.com). */
933
938
  const menu = useContextMenu()
939
+ /* touch has no right button: a held press on a row or tile opens the same menu (useLongPress) */
940
+ const press = useLongPress()
934
941
  const modal = useModal()
935
942
  const canWrite = !!fileActions && writable
936
943
  const [busyAction, setBusyAction] = useState(false)
@@ -1120,6 +1127,15 @@ export function MediaLibraryBrowse({
1120
1127
  /* stepping into a folder drops the pick — the preview would otherwise describe something no
1121
1128
  * longer in the list */
1122
1129
  const goFolder = (path) => { setPickedFile(null); setPickedFolder(null); setPrefix(path) }
1130
+ /* ONE "GO UP" FOR ALL THREE VIEWS (kol-client-olina 2026-09-23: *"3 different stops for 3
1131
+ * modes … they should all go all the way to the shared root folder"*): parent folder → … → the
1132
+ * bucket's top → the title root, which is what `onHome` does. With one bucket and no
1133
+ * `bucketLevel` there is no level above the bucket's top, so that is where it ends. Columns walk
1134
+ * the same path with ArrowLeft. */
1135
+ const goUp = () => {
1136
+ if (prefix) { goFolder(dirOf(prefix)); return }
1137
+ if (!single && !appRoot) { setAppRoot(true); setPickedFile(null); setPickedFolder(null) }
1138
+ }
1123
1139
  /* SPACE IS QUICK LOOK IN EVERY VIEW (user 2026-09-23: *"spacebar preview only works in column,
1124
1140
  * fix that. CONSISTENCY"*). `ColumnBrowser` has owned that key since it shipped, so the columns
1125
1141
  * had it and the two views built later did not. The target is whatever is picked — the previewed
@@ -1220,9 +1236,11 @@ export function MediaLibraryBrowse({
1220
1236
  if (el?.tabIndex >= 0) el.focus({ preventScroll: true })
1221
1237
  }
1222
1238
  if (view === 'grid') {
1239
+ /* BEFORE the empty-list bail-out: a folder that holds only subfolders has an empty wall, and
1240
+ * ⌘↑ was swallowed there */
1241
+ if (e.metaKey && e.key === 'ArrowUp') { goUp(); return true }
1223
1242
  const list = gridOrderRef.current
1224
1243
  if (!list.length) return true
1225
- if (e.metaKey && e.key === 'ArrowUp') { if (prefix) goFolder(dirOf(prefix)); return true }
1226
1244
  const i = list.findIndex((f) => f.key === cur)
1227
1245
  if (e.metaKey && e.key === 'ArrowDown') { if (i !== -1) openQuickLook({ files: list, index: i }); return true }
1228
1246
  const box = gridMarquee.ref.current
@@ -1246,7 +1264,7 @@ export function MediaLibraryBrowse({
1246
1264
  }
1247
1265
  const isFolder = cur?.endsWith('/')
1248
1266
  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 }
1267
+ if (e.metaKey && e.key === 'ArrowUp') { const up = cur ? dirOf(cur) : dirOf(prefix); if (up) select(up); else goUp(); return true }
1250
1268
  if (e.metaKey && e.key === 'ArrowDown') {
1251
1269
  if (!cur) return true
1252
1270
  if (isFolder) goFolder(cur)
@@ -1306,6 +1324,10 @@ export function MediaLibraryBrowse({
1306
1324
  const view = stored === 'list' ? 'rows' : stored
1307
1325
  const isWall = view === 'grid'
1308
1326
  const folderView = view === 'rows' ? 'rows' : 'columns'
1327
+ /* BELOW `md` BOTH TREE VIEWS ARE THE PHONE LIST. `ColumnBrowser` already forks to its one-level
1328
+ * stack there; the row view had no phone form and drew the desktop list and its preview pane
1329
+ * squeezed into 390 — 35px of rows with no names. Same query, same breakpoint as the columns. */
1330
+ const phone = useMediaQuery('(max-width: 767px)')
1309
1331
  const setView = (v) => {
1310
1332
  onViewChange?.(v)
1311
1333
  setSettings({
@@ -1462,7 +1484,7 @@ export function MediaLibraryBrowse({
1462
1484
  const treeBody = (keep) => {
1463
1485
  const treeObjects = keep ? sortedObjects.filter((o) => keep.has(o.key)) : sortedObjects
1464
1486
  return (
1465
- folderView === 'columns' ? (
1487
+ folderView === 'columns' || phone ? (
1466
1488
  <div ref={columnsRef} className="relative">
1467
1489
  <ColumnBrowser
1468
1490
  autoFocus={autoFocus}
@@ -1651,8 +1673,18 @@ export function MediaLibraryBrowse({
1651
1673
  <div className="flex flex-col gap-2" style={{ height: settings.columnHeight ?? SETTINGS_BASE.columnHeight }}>
1652
1674
  <div className="relative border rounded flex overflow-hidden flex-1 min-h-0" style={{ borderColor: 'var(--kol-oq-08)' }}>
1653
1675
  <div className={`flex-1 min-w-0 overflow-y-auto p-4${showGridPreview ? ' border-r' : ''}`} style={{ borderColor: 'var(--kol-oq-08)' }}
1676
+ /* THE CONTAINER IS A LEVEL, like a column (kol-client-olina 2026-09-23: *"context menu anywhere
1677
+ * by right click"*). A tile's own menu stops the event first, so it still wins. */
1678
+ onContextMenu={(e) => menu.openAt(e, { type: 'level', path: prefix })}
1654
1679
  onClick={(e) => { if (!e.target.closest('[data-marquee-key]')) { setRowSelection(new Set()); setPickedFile(null); setPickedFolder(null) } }}>
1655
1680
  <WallBody files={files} layout="grid" tiles="media" cardMin={tileSize} marquee={gridMarquee} onSorted={(list) => { gridOrderRef.current = list }}
1681
+ /* THE GRID DRAWS THE LEVEL'S SUBFOLDERS, ahead of the files (same source: kol-client-olina
1682
+ * 2026-09-23 — `projects/` read `0 files` over a blank pane). `flat` is the subtree's files
1683
+ * and no folders, as it was. */
1684
+ folders={settings.flat ? [] : folders.map((f) => prefix + f)}
1685
+ onOpenFolder={goFolder}
1686
+ onPickFolder={(path, e) => { if (selectRow(path, e, [...folders.map((f) => prefix + f), ...files.map((f) => f.key)])) return; rowPickFolder(path) }}
1687
+ onFolderContextMenu={(e, path) => menu.openAt(e, { type: 'folder', path, targets: targetsFor(path) })}
1656
1688
  onBackgroundClick={() => { setRowSelection(new Set()); setPickedFile(null); setPickedFolder(null) }}
1657
1689
  sortBy={settings.sortBy} sortDir={settings.sortDir}
1658
1690
  pageSize={settings.pageSize} listId={`${prefix}|${view}|${settings.flat}|${refreshKey}|${bucketMeta.id}`}
@@ -1699,7 +1731,7 @@ export function MediaLibraryBrowse({
1699
1731
  : treeBody(filtered ? new Set(filtered.map((f) => f.key)) : null))
1700
1732
 
1701
1733
  return (
1702
- <div className={`flex flex-col gap-6 ${className}`.trim()}>
1734
+ <div {...press} className={`flex flex-col gap-6 ${className}`.trim()}>
1703
1735
  <LibraryHeader title={title} buckets={buckets} bucketId={bucketMeta.id} appRoot={appRoot} bucketMeta={bucketMeta} writable={writable} headerActions={headerActions} headerTrailing={headerTrailing} onTrash={trash ? () => setTrashOpen(true) : undefined}
1704
1736
  onHome={() => { setAppRoot(true); setPickedFile(null); setPrefix('') }}
1705
1737
  onBucket={(v) => { if (v === 'all') { setAppRoot(true); setPrefix('') } else { setAppRoot(false); switchBucket(v) } }}
@@ -1911,7 +1943,7 @@ export function MediaLibraryBrowse({
1911
1943
  * this row 8px taller than the fill budget, so the page scrolled by that much */
1912
1944
  <div className="flex items-center justify-between gap-4 h-4">
1913
1945
  <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))}
1946
+ {!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
1947
  </p>
1916
1948
  <input type="range" className="slider-black w-32 cursor-pointer" min={100} max={360} step={20} value={tileSize}
1917
1949
  onChange={(e) => setSettings({ ...settings, tileSize: Number(e.target.value) })} aria-label="Tile size" />
@@ -2106,6 +2138,8 @@ export function WallBody({
2106
2138
  files, layout = 'list', sortBy = 'name', sortDir = 'asc', pageSize = 200, listId = '', cardMin = 260,
2107
2139
  mediaUrl, downloadUrl, keySet, videoPreview, formatDate = defaultFormatDate,
2108
2140
  selected, selectMode = false, onToggleSelect, onSorted, onContextMenu, onPick,
2141
+ /* the media grid's folder tiles — see the page's `wallPane` */
2142
+ folders = [], onOpenFolder, onPickFolder, onFolderContextMenu,
2109
2143
  /* ONE INSPECTOR (user 2026-09-23). Given `onOpen`, the wall hands the open UP and keeps no
2110
2144
  * lightbox of its own — otherwise a page that also opens one (space, a row double-click) ends up
2111
2145
  * with two stacked overlays of the same file. The standalone wall passes nothing and keeps its
@@ -2161,7 +2195,7 @@ export function WallBody({
2161
2195
  const openAt = (o, idx) => (onOpen ? onOpen(o, sorted) : setLightboxIndex(idx))
2162
2196
  return (
2163
2197
  <div className="flex flex-col gap-3">
2164
- {sorted.length === 0 ? null : layout !== 'grid' ? (
2198
+ {sorted.length === 0 && !(layout === 'grid' && tiles === 'media' && folders.length) ? null : layout !== 'grid' ? (
2165
2199
  <div className="flex flex-col">
2166
2200
  {shown.map((o, idx) => (
2167
2201
  <ContentRow key={o.key} variant="default" media={renderThumb(o, null)} title={renderName(o)} date={formatDate(o.uploaded)} size={formatSize(o.size)}
@@ -2176,6 +2210,13 @@ export function WallBody({
2176
2210
  style={{ gridTemplateColumns: `repeat(auto-fill, minmax(${cardMin}px, 1fr))` }}
2177
2211
  onClick={(e) => { if (!e.target.closest('[data-marquee-key]')) onBackgroundClick?.() }}>
2178
2212
  {marquee?.rect && <div className="kol-marquee" style={marquee.rect} />}
2213
+ {folders.map((path) => (
2214
+ <MediaTile key={path} markKey={path} preview={<Icon name="folder" size={56} className="text-oq-48" />}
2215
+ name={path.replace(/\/$/, '').split('/').pop()} selected={isSelected(path)}
2216
+ onContextMenu={onFolderContextMenu ? (e) => onFolderContextMenu(e, path) : undefined}
2217
+ onDoubleClick={() => onOpenFolder?.(path)}
2218
+ onClick={onPickFolder ? (e) => onPickFolder(path, e) : undefined} />
2219
+ ))}
2179
2220
  {shown.map((o, idx) => (
2180
2221
  <MediaTile key={o.key} markKey={o.key} preview={renderThumb(o, null)} name={renderName(o)} selected={isSelected(o.key)}
2181
2222
  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>