@kolkrabbi/kol-component 0.215.0 → 0.216.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,7 +1,7 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-component",
3
- "version": "0.215.0",
4
- "description": "KOL design-system components \u2014 atoms through organisms, emitting canonical kol-* classes. Pairs with @kolkrabbi/kol-theme for styling.",
3
+ "version": "0.216.0",
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",
7
7
  "main": "./src/index.js",
@@ -34,7 +34,7 @@
34
34
  "react-dom": "^18.3.0 || ^19.0.0"
35
35
  },
36
36
  "devDependencies": {
37
- "@kolkrabbi/kol-icons": "workspace:^"
37
+ "@kolkrabbi/kol-icons": "^0.27.0"
38
38
  },
39
39
  "files": [
40
40
  "src",
@@ -56,4 +56,4 @@
56
56
  "access": "public"
57
57
  },
58
58
  "sideEffects": false
59
- }
59
+ }
package/src/index.js CHANGED
@@ -48,6 +48,7 @@ export { default as LabeledControl } from './molecules/LabeledControl.jsx'
48
48
  export { default as OverlayGlassPanel } from './utilities/OverlayGlassPanel.jsx'
49
49
  export { default as Pill } from './atoms/Pill.jsx'
50
50
  export { usePopover, PopoverPanel, Tooltip } from './utilities/Popover.jsx'
51
+ export { default as ContextMenu, useContextMenu } from './utilities/ContextMenu.jsx'
51
52
  export { default as ProsePreview } from './utilities/ProsePreview.jsx'
52
53
  export { default as QuantityInput } from './molecules/QuantityInput.jsx'
53
54
  export { default as RotaryDial } from './atoms/RotaryDial.jsx'
@@ -164,6 +165,7 @@ export { default as MediaLibrary, MediaLibraryProvider, useMediaLibrary, MediaPi
164
165
  * rendered both mobile viewer views and was module-private, so every consumer
165
166
  * needing a phone viewer hand-rolled one. */
166
167
  export { MediaInspector } from './organisms/MediaLibraryPages.jsx'
168
+ export { default as MediaLibraryExplorer } from './organisms/MediaLibraryExplorer.jsx'
167
169
  export { default as MediaTileGallery } from './organisms/MediaTileGallery.jsx'
168
170
  export { default as MediaViewer } from './organisms/MediaViewer.jsx'
169
171
  export { default as SettingsPanel, LabeledControlSection, SettingsRow, SettingsSwitch, SettingsChoice, SettingsMulti, SettingsChipRow, SettingsFooter } from './organisms/SettingsPanel.jsx'
@@ -239,10 +239,23 @@ const ZONE_BOX = 44
239
239
  * to what it was. */
240
240
  function Row({
241
241
  icon, label, active, cursor = false, trailing, onClick, muted = false,
242
- indent = 0, meta, zones = false, thumb, onDisclose, disclosed,
242
+ indent = 0, meta, zones = false, thumb, onDisclose, disclosed, onContextMenu, drop,
243
243
  }) {
244
+ const [over, setOver] = useState(false)
245
+ /* A COLUMN ROW IS THE SAME ROW as the list view's, so it takes the same two seams: right-click
246
+ * and, for a folder, a drop target. Without them every handler is undefined and nothing moves. */
247
+ const dropProps = drop ? {
248
+ draggable: true,
249
+ onDragStart: (e) => { e.stopPropagation(); drop.onDragStart(e, drop.path) },
250
+ onDragOver: (e) => { if (drop.canDrop(drop.path)) { e.preventDefault(); setOver(true) } },
251
+ onDragLeave: () => setOver(false),
252
+ onDrop: (e) => { e.preventDefault(); e.stopPropagation(); setOver(false); drop.onDrop(drop.path) },
253
+ } : {}
244
254
  return (
245
255
  <li
256
+ onContextMenu={onContextMenu}
257
+ {...dropProps}
258
+ data-drop-over={over || undefined}
246
259
  /* Row metrics are the DS Table's (kol-components-organisms.css .kol-table-cell-*):
247
260
  * 12px 16px padding, mono 12, an oq-08 hairline between rows, none after the last.
248
261
  * The `zones` row sets its own 60px instead — a 44px thumbnail in a
@@ -441,6 +454,11 @@ export default function ColumnBrowser({
441
454
  columnWidth = 260,
442
455
  columnWidths,
443
456
  onColumnResize,
457
+ /* RIGHT-CLICK AND DROP, handed down (2026-09-21). `onRowContextMenu(event, { type, path, o })`
458
+ * and `dragFor(path)` are the page's; this component owns the geometry of a row, never what a
459
+ * row can have done to it. Absent, the columns behave exactly as they did. */
460
+ onRowContextMenu,
461
+ dragFor,
444
462
  /* THE TWO SEAMS THE MOBILE TICKET'S RULINGS POINT AT (ColumnBrowserMobileViews,
445
463
  * kol-r2b2 2026-09-03). Both are questions the DS must not answer for a
446
464
  * consumer, so neither is computed here:
@@ -734,10 +752,13 @@ export default function ColumnBrowser({
734
752
  })}
735
753
  </div>
736
754
  ) : (
737
- <ul className="kol-column-browser-column flex-1 overflow-y-auto">
755
+ <ul className="kol-column-browser-column flex-1 overflow-y-auto"
756
+ onContextMenu={(e) => onRowContextMenu?.(e, { type: 'level', path: prefix })}>
738
757
  {rows.map((r) =>
758
+ /* Same ruling as the desktop column: nothing to say, so it says nothing. This is the
759
+ * mobile stack's row for an empty folder. */
739
760
  r.kind === 'empty' ? (
740
- <li key={r.key} className="kol-mono-12 text-fg-32 px-4 py-3">empty</li>
761
+ <li key={r.key} className="min-h-12" />
741
762
  ) : r.kind === 'folder' ? (
742
763
  <Row
743
764
  key={r.key}
@@ -806,6 +827,32 @@ export default function ColumnBrowser({
806
827
  <ul
807
828
  className="kol-column-browser-column shrink-0 overflow-y-auto border-r"
808
829
  style={{ borderColor: 'var(--kol-oq-08)', width: widthOf(k) }}
830
+ /* THE WHOLE COLUMN IS A TARGET, not just its rows and not just an empty column.
831
+ *
832
+ * RIGHT-CLICK: `openAt` stops propagation, so a row that was hit answers first and
833
+ * this only fires on the blank area below the last row — where you reach for
834
+ * "new folder" in every file manager.
835
+ *
836
+ * DROP: a column IS the folder it is showing, so dropping anywhere in it files the
837
+ * item there. You could drop ON a folder row but not INTO the folder you were looking
838
+ * at, which is the one target that was missing. The highlight goes straight to a data
839
+ * attribute rather than through state — it is transient, it belongs to one node, and a
840
+ * re-render per dragover across every column buys nothing. */
841
+ onContextMenu={(e) => onRowContextMenu?.(e, { type: 'level', path: level })}
842
+ onDragOver={(e) => {
843
+ const d = dragFor?.(level)
844
+ if (!d?.canDrop(level)) return
845
+ e.preventDefault()
846
+ e.currentTarget.dataset.dropOver = '1'
847
+ }}
848
+ onDragLeave={(e) => { if (e.currentTarget === e.target) delete e.currentTarget.dataset.dropOver }}
849
+ onDrop={(e) => {
850
+ delete e.currentTarget.dataset.dropOver
851
+ const d = dragFor?.(level)
852
+ if (!d?.canDrop(level)) return
853
+ e.preventDefault()
854
+ d.onDrop(level)
855
+ }}
809
856
  >
810
857
  {folders.map((f, i) => (
811
858
  <Row
@@ -815,6 +862,8 @@ export default function ColumnBrowser({
815
862
  active={f === activeFolder}
816
863
  cursor={cursorActive && cursor.col === k && cursor.idx === i}
817
864
  trailing={<Icon name="chevron-right" size={12} className="text-fg-32" />}
865
+ onContextMenu={(e) => onRowContextMenu?.(e, { type: 'folder', path: level + f })}
866
+ drop={dragFor?.(level + f)}
818
867
  onClick={() => { setCursor({ col: k, idx: i }); setCursorActive(true); rootRef.current?.focus(); pick(null); onPrefix(level + f) }}
819
868
  />
820
869
  ))}
@@ -825,11 +874,17 @@ export default function ColumnBrowser({
825
874
  label={o.displayKey ?? o.key}
826
875
  active={shown?.key === o.key}
827
876
  cursor={cursorActive && cursor.col === k && cursor.idx === folders.length + i}
877
+ onContextMenu={(e) => onRowContextMenu?.(e, { type: 'file', path: o.key, o })}
878
+ drop={dragFor?.(o.key)}
828
879
  onClick={() => { setCursor({ col: k, idx: folders.length + i }); setCursorActive(true); rootRef.current?.focus(); pickFile(level, o) }}
829
880
  />
830
881
  ))}
882
+ {/* AN EMPTY COLUMN IS VISIBLY EMPTY (user 2026-09-21). It used to print the word
883
+ * "empty", which tells you what you can already see. The row stays as a right-click
884
+ * target — that is the only thing it was ever load-bearing for — and says nothing. */}
831
885
  {folders.length === 0 && files.length === 0 && (
832
- <li className="kol-mono-12 text-fg-32 px-4 py-3">empty</li>
886
+ <li className="flex-1 min-h-12"
887
+ onContextMenu={(e) => onRowContextMenu?.(e, { type: 'level', path: level })} />
833
888
  )}
834
889
  </ul>
835
890
  <ResizeHandle axis="x" onDrag={resizeCol(k)} onEnd={endDrag} />
@@ -8,11 +8,13 @@ import SegmentedToggle from '../atoms/SegmentedToggle.jsx'
8
8
  import SizeOrDownload from '../atoms/SizeOrDownload.jsx'
9
9
  import ViewToggle from '../atoms/ViewToggle.jsx'
10
10
  import FullscreenOverlay from '../utilities/FullscreenOverlay.jsx'
11
+ import { Tooltip } from '../utilities/Popover.jsx'
11
12
  import ContentCard from '../molecules/ContentCard.jsx'
12
13
  import ContentRow from '../molecules/ContentRow.jsx'
13
14
  import ContentFilters from './ContentFilters.jsx'
14
15
  import MediaViewer from './MediaViewer.jsx'
15
16
  import { MediaLibraryBrowse, MediaLibraryLibrary } from './MediaLibraryPages.jsx'
17
+ import MediaLibraryExplorer from './MediaLibraryExplorer.jsx'
16
18
  import { SettingsChipRow, chipCls } from './SettingsPanel.jsx'
17
19
 
18
20
  /**
@@ -629,15 +631,17 @@ function Toolbar({ viewMode, onViewMode }) {
629
631
  )}
630
632
  </div>
631
633
  <div className="flex items-center gap-3">
632
- <button
633
- type="button"
634
- aria-pressed={flat}
635
- title="Show all files recursively"
636
- onClick={() => setFlat(!flat)}
637
- className={chipCls(flat)}
638
- >
639
- Flat
640
- </button>
634
+ {/* The DS's own Tooltip, not a `title` attribute — see LibraryHeader. */}
635
+ <Tooltip label="Show all files recursively">
636
+ <button
637
+ type="button"
638
+ aria-pressed={flat}
639
+ onClick={() => setFlat(!flat)}
640
+ className={chipCls(flat)}
641
+ >
642
+ Flat
643
+ </button>
644
+ </Tooltip>
641
645
  <ViewToggle viewMode={viewMode} onViewChange={onViewMode} variant="icon" />
642
646
  <Divider variant="vertical" />
643
647
  <SortControls />
@@ -970,7 +974,10 @@ function PickerShell({ onClose, onPick }) {
970
974
  * `defaults` to seed) · `folderTree` (browse: the baked tree) · `headerActions`
971
975
  * (the app's own upload / write icons) · `refreshKey`.
972
976
  *
973
- * @param {string} variant 'browse' | 'library' | 'modal' | 'page' (alias of library)
977
+ * @param {string} variant 'explorer' | 'browse' | 'library' | 'modal' | 'page' (alias of library)
978
+ * `explorer` is browse + library as two views of ONE surface — one header, one
979
+ * count, one listing, a switch beside the bucket dropdown. Prefer it for a new
980
+ * consumer; the other two remain for the ones that stack them by hand.
974
981
  * @param {boolean} open modal only — mounts the overlay
975
982
  * @param {object} client `{ listMedia, mediaUrl, proxied? }`; omit inside a provider
976
983
  * @param {string|string[]} accept 'all' (default) = everything · one kind · an
@@ -999,6 +1006,12 @@ export default function MediaLibrary({
999
1006
  return withProvider(<PickerShell onClose={onClose} onPick={onSelect} />, opts)
1000
1007
  }
1001
1008
  if (variant === 'browse') return <MediaLibraryBrowse client={client} {...pageProps} />
1009
+ /* `explorer` = the two above as TWO VIEWS OF ONE SURFACE, one mounted at a
1010
+ * time behind a switch in the header — one wordmark, one count, one listing,
1011
+ * and the upload target reachable without scrolling past a full-height
1012
+ * browser. Added 2026-09-21; `browse` and `library` are unchanged, so no
1013
+ * consumer moves until it chooses to. */
1014
+ if (variant === 'explorer') return <MediaLibraryExplorer client={client} {...pageProps} />
1002
1015
  /* `page` = the library wall (alias, one release): its old knobs map onto the settings seed */
1003
1016
  const seed = variant === 'page' ? { defaults: { pageSize, sortBy: defaultSort?.by, sortDir: defaultSort?.dir, flat, ...(pageProps.defaults ?? {}) } } : {}
1004
1017
  return <MediaLibraryLibrary client={client} {...pageProps} {...seed} />
@@ -0,0 +1,116 @@
1
+ import { useState } from 'react'
2
+ import Divider from '../atoms/Divider.jsx'
3
+ import { MediaLibraryBrowse, MediaLibraryLibrary } from './MediaLibraryPages.jsx'
4
+
5
+ /**
6
+ * MediaLibraryExplorer — the browse tree and the file wall as TWO VIEWS OF ONE
7
+ * SURFACE, which is what `MediaLibraryPages` has called them in its own comments
8
+ * since 2026-09-04 while shipping them as two pages a consumer stacks by hand.
9
+ *
10
+ * WHAT WAS WRONG WITH THE STACK (kol-r2b2, the apps-tier ticket):
11
+ * - Both pages rendered ALWAYS, the browser at a fixed 800px, so the wall and
12
+ * the upload drop zone sat below the fold. Pressing Upload scrolled you past
13
+ * a full-height browser to reach the target.
14
+ * - Both rendered `LibraryHeader`, so the consumer passed `header={false}` to
15
+ * suppress a duplicate wordmark, bucket dropdown and gear.
16
+ * - Both printed a stats line, so the consumer passed `stats={false}` too.
17
+ * - Both called `useBucketLibrary`, so one bucket was listed twice into two
18
+ * object lists that could disagree.
19
+ * - Every shared seam had to be handed to both by the consumer, which is the
20
+ * divergence-defect family the pages' own comments document at length.
21
+ *
22
+ * WHAT THIS DOES: holds the view, mounts exactly one page, and hands it the
23
+ * seams once. One page mounted means one header, one stats line, one listing and
24
+ * one body — every symptom above falls out of that rather than being patched.
25
+ *
26
+ * WHAT IT DOES NOT DO: it does not hoist `useBucketLibrary` into itself. The two
27
+ * pages keep their own state, so switching views re-lists the bucket and drops
28
+ * column scroll position and selection. Against this repo's fixture that is
29
+ * free; against a live bucket it is a 30s-cached GET. Hoisting the hook and the
30
+ * header into a true shared parent is the deeper fix and it is a rewrite of both
31
+ * page bodies — worth doing, not worth blocking the surface on.
32
+ *
33
+ * `prefix` stays OWNED BY THE CONSUMER (it already was), so the folder you are
34
+ * in survives the switch even though the page instance does not.
35
+ *
36
+ * ADDITIVE. `variant="browse"` and `variant="library"` are untouched, so
37
+ * kol-r2b2 and kol-client-olina keep rendering exactly what they render today.
38
+ */
39
+
40
+ const VIEWS = [
41
+ { value: 'browse', label: 'BROWSE' },
42
+ { value: 'files', label: 'FILES' },
43
+ ]
44
+
45
+ export default function MediaLibraryExplorer({
46
+ view: viewProp,
47
+ onViewChange,
48
+ defaultView = 'browse',
49
+ headerActions,
50
+ className = '',
51
+ ...pageProps
52
+ }) {
53
+ const [ownView, setOwnView] = useState(defaultView)
54
+ const view = viewProp ?? ownView
55
+ const setView = (v) => { setOwnView(v); onViewChange?.(v) }
56
+
57
+ /* The switch rides `headerActions` rather than being a fourth thing in the
58
+ * header's own markup: the header already has a slot for consumer chrome, and
59
+ * putting the view control there keeps it beside the bucket dropdown where
60
+ * every reference product puts it. The consumer's own actions follow it.
61
+ *
62
+ * HIDDEN BELOW `md`. At 390 the header carries a wordmark, a bucket dropdown
63
+ * and up to four icons; adding a two-segment strip pushed the strip off the
64
+ * right edge and truncated the wordmark to three glyphs. The pages already
65
+ * take the mobile tab pill (`tabs` / `activeTab` / `onTabChange`), which is
66
+ * the same control for the same question — so the phone keeps the pill and
67
+ * the desktop keeps the strip, rather than both competing for one row.
68
+ *
69
+ * BARE TEXT, NOT A `SegmentedToggle`. This surface already has a switch
70
+ * idiom and uses it twice — ROW·COLUMN on the crumb line and SELECT·FLAT on
71
+ * the filter bar are both `kol-helper-14`, `oq-96` active over `oq-48` at
72
+ * rest, letterspacing 1, no frame. A boxed toggle put a bordered chip next to
73
+ * an unbordered dropdown and two unbordered switches, which read as a style
74
+ * break because it was one. Same control, third instance, same treatment.
75
+ *
76
+ * AND IT SITS LAST, RIGHT OF A DIVIDER. The crumb row directly beneath this
77
+ * header reads `[list][columns] │ ROW COLUMN` — icons, divider, text switch.
78
+ * Placed before the icons the header mirrored the row under it, which is the
79
+ * same inconsistency in a different direction. Icons left, divider, text
80
+ * right; two rows, one arrangement. It rides `headerTrailing` rather than
81
+ * `headerActions` because the gear is rendered by the header itself and the
82
+ * switch has to clear it. */
83
+ /* THE GAPS ARE THE CRUMB ROW'S, MEASURED: `gap-6` (24px) around the divider,
84
+ * `gap-4` (16px) between the two labels. Built flat at gap-4 throughout, the
85
+ * divider sat 8px too tight against the icons and the row below it did not
86
+ * line up with the row above. Two nested containers, same two numbers. */
87
+ /* `ml-4` IS NOT DECORATION. The divider needs 24px on BOTH sides to match the
88
+ * crumb row, where its own `gap-6` container supplies both. Here the LEFT gap
89
+ * comes from the header's `gap-2` between this group and the gear — 8px — so
90
+ * the divider sat tight against the icons and open against the text. 8 + 16
91
+ * puts it back on 24. */
92
+ const trailing = (
93
+ <span className="max-md:hidden flex items-center gap-6 ml-4">
94
+ <Divider variant="vertical" />
95
+ <span className="flex items-center gap-4">
96
+ {VIEWS.map((opt) => (
97
+ <span key={opt.value} role="button" onClick={() => setView(opt.value)}
98
+ aria-pressed={view === opt.value}
99
+ className={`kol-helper-14 cursor-pointer select-none ${view === opt.value ? 'text-oq-96' : 'text-oq-48 hover:text-oq-64'}`}
100
+ style={{ letterSpacing: 1 }}>
101
+ {opt.label}
102
+ </span>
103
+ ))}
104
+ </span>
105
+ </span>
106
+ )
107
+
108
+ const shared = { ...pageProps, headerActions, headerTrailing: trailing, className }
109
+
110
+ /* `header` and `stats` are NOT forwarded and NOT accepted. One surface prints
111
+ * one header and one count; a consumer suppressing either is the workaround
112
+ * this component exists to delete. */
113
+ return view === 'files'
114
+ ? <MediaLibraryLibrary {...shared} />
115
+ : <MediaLibraryBrowse {...shared} />
116
+ }
@@ -1,4 +1,4 @@
1
- import { useEffect, useMemo, useRef, useState } from 'react'
1
+ import { Fragment, useEffect, useMemo, useRef, useState } from 'react'
2
2
  import formatSize from '../utilities/formatSize.js'
3
3
  import { Icon } from '@kolkrabbi/kol-icons'
4
4
  import Button from '../atoms/Button.jsx'
@@ -16,6 +16,8 @@ import SortControls from '../molecules/SortControls.jsx'
16
16
  import SearchInput from '../molecules/SearchInput.jsx'
17
17
  import MobileTabBar, { TABBAR_H } from '../molecules/MobileTabBar.jsx'
18
18
  import { MenuItem, MenuDropdownItem, MenuDropdownDivider } from '../molecules/MenuItem.jsx'
19
+ import { Tooltip } from '../utilities/Popover.jsx'
20
+ import ContextMenu, { useContextMenu } from '../utilities/ContextMenu.jsx'
19
21
  import KindPreview from '../molecules/KindPreview.jsx'
20
22
  import AudioSheet from '../molecules/AudioSheet.jsx'
21
23
  import VideoSheet from '../molecules/VideoSheet.jsx'
@@ -171,13 +173,93 @@ function ImageFrame({ src }) {
171
173
  )
172
174
  }
173
175
 
174
- /* `struck` = folder grouping is bypassed (flat mode) — still navigable, de-emphasised. */
175
- function FolderRow({ name, onClick, struck = false }) {
176
+ /* `struck` = folder grouping is bypassed (flat mode) — still navigable, de-emphasised.
177
+ *
178
+ * `depth` indents a row under the folder it was expanded from; `expanded`/`onToggle` draw the
179
+ * disclosure twisty. A folder row without `onToggle` keeps the old behaviour exactly — one chevron
180
+ * that navigates — so nothing that already renders these moves. */
181
+ function FolderRow({ name, onClick, struck = false, depth = 0, expanded, onToggle, meta, onContextMenu, drag, selected }) {
182
+ const [over, setOver] = useState(false)
183
+ /* A FOLDER IS A DROP TARGET. `drag` carries the page's move verb and the path this row is; the
184
+ * row highlights only while something is actually over it, so an accidental hover reads as
185
+ * nothing. Without `drag` every handler below is undefined and the row is exactly what it was. */
186
+ const dropProps = drag ? {
187
+ draggable: true,
188
+ onDragStart: (e) => { e.stopPropagation(); drag.onDragStart(e, drag.path) },
189
+ onDragOver: (e) => { if (drag.canDrop(drag.path)) { e.preventDefault(); setOver(true) } },
190
+ onDragLeave: () => setOver(false),
191
+ onDrop: (e) => { e.preventDefault(); e.stopPropagation(); setOver(false); drag.onDrop(drag.path) },
192
+ } : {}
176
193
  return (
177
- <li className="flex items-center gap-3 py-2 border-b cursor-pointer hover:bg-fg-04 transition-colors px-1 rounded" style={{ borderColor: 'var(--kol-fg-08)' }} onClick={onClick}>
194
+ <li onContextMenu={onContextMenu} {...dropProps} className={`flex items-center gap-3 py-2 border-b cursor-pointer transition-colors px-1 rounded ${over ? 'bg-fg-08' : selected ? 'bg-fg-04' : 'hover:bg-fg-04'}`} style={{ borderColor: 'var(--kol-fg-08)', paddingLeft: depth * 20 + 4 }} onClick={onClick}>
195
+ {onToggle ? (
196
+ <button type="button" aria-label={expanded ? `Collapse ${name}` : `Expand ${name}`} aria-expanded={!!expanded}
197
+ onClick={(e) => { e.stopPropagation(); onToggle() }}
198
+ className="w-4 h-4 shrink-0 flex items-center justify-center text-fg-32 hover:text-fg-default transition-transform"
199
+ style={{ transform: expanded ? 'rotate(90deg)' : 'none' }}>
200
+ <Icon name="chevron-right" size={12} />
201
+ </button>
202
+ ) : <span className="w-4 shrink-0" />}
178
203
  <div className="w-8 h-8 shrink-0 flex items-center justify-center text-fg-48"><Icon name="folder" size={18} /></div>
179
- <span className={`kol-mono-12 flex-1 ${struck ? 'line-through text-fg-48' : 'text-fg-default'}`}>{name}</span>
180
- <Icon name="chevron-right" size={14} className="text-fg-32" />
204
+ <span className={`kol-mono-12 flex-1 min-w-0 truncate ${struck ? 'line-through text-fg-48' : 'text-fg-default'}`}>{name}</span>
205
+ {meta && <span className="kol-mono-12 text-fg-32 shrink-0">{meta}</span>}
206
+ <Icon name="chevron-right" size={14} className="text-fg-32 shrink-0" />
207
+ </li>
208
+ )
209
+ }
210
+
211
+ /* One expanded level, drawn inline under its folder, recursing for anything expanded beneath it —
212
+ * the disclosure behaviour a list view is expected to have. Depth only indents; the data comes from
213
+ * the same `rowLevel` the top level uses, so an expanded branch cannot disagree with the columns. */
214
+ function RowSubtree({ level, depth, rowLevel, expanded, onToggle, onOpenFolder, onOpenFile, formatDate, folderMeta, thumbnailFor, flat, onRowContextMenu, dragFor }) {
215
+ const { folders, files } = rowLevel(level)
216
+ return (
217
+ <>
218
+ {folders.map((f) => {
219
+ const path = level + f
220
+ const open = expanded.has(path)
221
+ return (
222
+ <Fragment key={path}>
223
+ <FolderRow
224
+ name={f} depth={depth} struck={flat} expanded={open}
225
+ meta={folderMeta?.(path) || undefined}
226
+ onToggle={() => onToggle(path)}
227
+ onContextMenu={(e) => onRowContextMenu?.(e, { type: 'folder', path })}
228
+ drag={dragFor?.(path)}
229
+ onClick={() => onOpenFolder(path)}
230
+ />
231
+ {open && <RowSubtree
232
+ level={path} depth={depth + 1} rowLevel={rowLevel} expanded={expanded} onToggle={onToggle}
233
+ onOpenFolder={onOpenFolder} onOpenFile={onOpenFile} onRowContextMenu={onRowContextMenu} dragFor={dragFor}
234
+ formatDate={formatDate} folderMeta={folderMeta} thumbnailFor={thumbnailFor} flat={flat}
235
+ />}
236
+ </Fragment>
237
+ )
238
+ })}
239
+ {files.map((o) => (
240
+ <FileRow key={o.key} o={o} depth={depth} formatDate={formatDate} thumb={thumbnailFor?.(o)}
241
+ onContextMenu={(e) => onRowContextMenu?.(e, { type: 'file', path: o.key, o })}
242
+ drag={dragFor?.(o.key)}
243
+ onClick={() => onOpenFile(o)} />
244
+ ))}
245
+ </>
246
+ )
247
+ }
248
+
249
+ /* The file half of the row view. Row view listed FOLDERS ONLY and dropped `files` on the floor, so
250
+ * `#img/` drew two rows where the column browser drew four — the two folders, the empty folder the
251
+ * columns knew about, and a file. A list view that cannot show a file is not a list view. */
252
+ function FileRow({ o, onClick, depth = 0, formatDate, thumb, onContextMenu, drag, selected }) {
253
+ const dragProps = drag ? { draggable: true, onDragStart: (e) => { e.stopPropagation(); drag.onDragStart(e, o.key) } } : {}
254
+ return (
255
+ <li onContextMenu={onContextMenu} {...dragProps} className={`flex items-center gap-3 py-2 border-b cursor-pointer transition-colors px-1 rounded ${selected ? 'bg-fg-04' : 'hover:bg-fg-04'}`} style={{ borderColor: 'var(--kol-fg-08)', paddingLeft: depth * 20 + 4 }} onClick={onClick}>
256
+ <span className="w-4 shrink-0" />
257
+ <div className="w-8 h-8 shrink-0 flex items-center justify-center text-fg-48 overflow-hidden rounded">
258
+ {thumb ?? <Icon name={kindOf(o) === 'image' ? 'image' : 'file'} size={18} />}
259
+ </div>
260
+ <span className="kol-mono-12 flex-1 min-w-0 truncate text-fg-default">{o.displayKey ?? o.key.split('/').pop()}</span>
261
+ <span className="kol-mono-12 text-fg-32 shrink-0">{formatDate?.(o.uploaded)}</span>
262
+ <span className="kol-mono-12 text-fg-32 shrink-0 w-20 text-right">{formatSize(o.size)}</span>
181
263
  </li>
182
264
  )
183
265
  }
@@ -316,18 +398,39 @@ function MediaSettings({ bucketMeta, settings, onChange, onReset, onClose, profi
316
398
  * and pushed the gear off the right edge — they resolved it by SWAPPING a
317
399
  * control below `md` rather than adding one, which is the pattern to copy. If a
318
400
  * consumer needs more than one at this width, it belongs in a sheet, not here. */
319
- function LibraryHeader({ title, buckets, bucketId, appRoot, onBucket, bucketMeta, writable, headerActions, onSettings }) {
401
+ function LibraryHeader({ title, buckets, bucketId, appRoot, onBucket, bucketMeta, writable, headerActions, onSettings, onHome, headerTrailing }) {
320
402
  const options = buckets.length ? [{ value: 'all', label: `${title} · all` }, ...buckets.map((b) => ({ value: b.id, label: b.label }))] : []
403
+ /* THE WORDMARK IS THE HOME BUTTON when `onHome` is given (user 2026-09-21: "it should reload,
404
+ * like a home button"). The crumb row's root already did exactly this and the wordmark above it
405
+ * did nothing — the one element every file manager makes clickable was the one that was inert.
406
+ * Without the prop it stays a plain <h1>, so nothing existing changes. */
321
407
  return (
322
408
  <header className="flex items-baseline justify-between gap-4">
323
- <h1 className="kol-sans-display-03 min-w-0 truncate">{title}</h1>
409
+ {onHome ? (
410
+ <button type="button" onClick={onHome}
411
+ className="kol-sans-display-03 min-w-0 truncate text-left cursor-pointer transition-opacity hover:opacity-64"
412
+ aria-label={`${title} — back to the top`}>{title}</button>
413
+ ) : (
414
+ <h1 className="kol-sans-display-03 min-w-0 truncate">{title}</h1>
415
+ )}
324
416
  <div className="flex items-center gap-2 min-w-0">
325
417
  {options.length > 0 && <Dropdown className="min-w-0 max-w-[45vw] md:w-48 md:max-w-none" value={appRoot ? 'all' : bucketId} onChange={onBucket} options={options} />}
326
418
  {headerActions}
419
+ {/* The DS's own `Tooltip` (utilities/Popover → `.kol-tooltip`), not a `title`
420
+ attribute. These two were the only header chrome still handing the label to
421
+ the browser, which drew its native box beside DS-styled ones. */}
327
422
  {!writable && bucketMeta.id && (
328
- <IconFrame name="lock" variant="primary" size="sm" title={`${bucketMeta.label} is read-only here`} aria-label="Read-only" />
423
+ <Tooltip label={`${bucketMeta.label} is read-only here`}>
424
+ <IconFrame name="lock" variant="primary" size="sm" aria-label="Read-only" />
425
+ </Tooltip>
329
426
  )}
330
- <IconFrame name="settings-01" variant="primary" size="sm" onClick={onSettings} aria-label="Display settings" title="Display settings" />
427
+ <Tooltip label="Display settings">
428
+ <IconFrame name="settings-01" variant="primary" size="sm" onClick={onSettings} aria-label="Display settings" />
429
+ </Tooltip>
430
+ {/* AFTER the icons, because the crumb row below reads `[icons] │ ROW COLUMN` and a text
431
+ * switch in this surface sits to the RIGHT of its divider. Put before them it mirrored
432
+ * the row directly beneath it. */}
433
+ {headerTrailing}
331
434
  </div>
332
435
  </header>
333
436
  )
@@ -370,7 +473,15 @@ function sortObjects(objects, sortBy = 'name', sortDir = 'asc') {
370
473
 
371
474
  export function MediaLibraryBrowse({
372
475
  client, title = 'MEDIA', bucket, onBucketChange, prefix: prefixProp, onPrefix, defaults, settings: settingsProp, onSettingsChange,
373
- folderTree, headerActions, refreshKey, onOpen, autoFocus = false, settingsFooter, className = '',
476
+ folderTree, headerActions, headerTrailing, refreshKey, onOpen, autoFocus = false, settingsFooter, className = '', banner,
477
+ /* THE FILE VERBS, as ONE seam (2026-09-21). `{ createFolder, rename, move, remove }`, each
478
+ * optional and each async; whatever is supplied becomes a right-click menu entry and
479
+ * anything absent simply is not offered. One object rather than four props because they
480
+ * arrive together — a consumer with a writable store has all of them, one with a read-only
481
+ * bucket has none — and because the page must never have to know WHICH verbs exist to
482
+ * decide whether to draw a menu at all. The consumer re-lists after a mutation by bumping
483
+ * `refreshKey`; the page does not own the data. */
484
+ fileActions,
374
485
  /* PASS-THROUGHS TO `ColumnBrowser`. A documented prop this page does not
375
486
  * forward is a prop no consumer of the PAGE can reach, which makes the seam
376
487
  * fictional — `thumbnailFor` and `folderMeta` shipped, were announced, and
@@ -400,6 +511,133 @@ export function MediaLibraryBrowse({
400
511
  const switchBucket = (id, pfx = '') => { if (bucket == null) setOwnBucket(id); onBucketChange?.(id); setPrefix(pfx) }
401
512
  const [settingsOpen, setSettingsOpen] = useState(false)
402
513
  const [pickedFile, setPickedFile] = useState(null)
514
+ /* Row view's inline disclosure set — folder paths currently expanded. Kept here rather than in
515
+ * settings: it is a transient reading state, not a preference, and Finder does not persist it
516
+ * across a relaunch either. */
517
+ const [expandedRows, setExpandedRows] = useState(() => new Set())
518
+
519
+ /* ── THE FILE VERBS ────────────────────────────────────────────────────────────────────────
520
+ * Right-click is the surface; `fileActions` is the implementation. The page owns neither the
521
+ * data nor the store — it collects an intent, asks for the one piece of text it needs, calls
522
+ * the consumer's verb and lets the consumer re-list. `prompt`/`confirm` are deliberate: this
523
+ * file already uses both for batch move and delete, and inventing a dialog here would be a
524
+ * second modal idiom in one component. */
525
+ const menu = useContextMenu()
526
+ const canWrite = !!fileActions && writable
527
+ const [busyAction, setBusyAction] = useState(false)
528
+
529
+ const runAction = async (fn) => {
530
+ if (!fn) return
531
+ setBusyAction(true)
532
+ try { await fn() } catch (e) { alert(e.message) } finally { setBusyAction(false) }
533
+ }
534
+
535
+ const doCreateFolder = (parent) => {
536
+ const name = prompt(`New folder in ${parent || 'the bucket root'}:`)
537
+ if (!name?.trim()) return
538
+ runAction(() => fileActions.createFolder(`${parent}${name.trim().replace(/^\/+|\/+$/g, '')}/`))
539
+ }
540
+ const doCreateFile = (parent) => {
541
+ const name = prompt(`New file in ${parent || 'the bucket root'}:`, 'untitled.txt')
542
+ if (!name?.trim()) return
543
+ runAction(() => fileActions.createFile(`${parent}${name.trim().replace(/^\/+/, '')}`))
544
+ }
545
+ const doRename = (path, isFolder) => {
546
+ const current = path.replace(/\/$/, '').split('/').pop()
547
+ const name = prompt(`Rename ${isFolder ? 'folder' : 'file'}:`, current)
548
+ if (!name?.trim() || name === current) return
549
+ const parent = path.replace(/\/$/, '').slice(0, path.replace(/\/$/, '').length - current.length)
550
+ runAction(() => fileActions.rename(path, `${parent}${name.trim()}${isFolder ? '/' : ''}`))
551
+ }
552
+ const doMove = (path) => {
553
+ const dest = prompt(`Move into which folder? (blank = bucket root)`, prefix)
554
+ if (dest === null) return
555
+ const clean = dest ? `${dest.replace(/^\/+|\/+$/g, '')}/` : ''
556
+ runAction(() => fileActions.move(path, clean))
557
+ }
558
+ /* DRAG TO MOVE. HTML5 drag-and-drop, not a pointer-tracking implementation: the rows are a list,
559
+ * the targets are folders, and the browser already does the hit-testing and the cursor. The
560
+ * dragged path lives in a ref rather than dataTransfer alone so `canDrop` can answer without
561
+ * reading the event — a folder may not be dropped into itself or into its own subtree, which is
562
+ * the one rule that makes this safe to wire straight to `move`. */
563
+ /* MULTI-SELECT in the browse views. ⌘/Ctrl-click adds one, ⇧-click takes the run between the
564
+ * anchor and the click, a plain click clears back to navigation. The wall has had its own SELECT
565
+ * mode since 0.119.0; this is the browse half, and it deliberately does NOT reuse that mode's
566
+ * chrome — there the wall IS the surface and a mode bar fits, here selection is a modifier on a
567
+ * tree you are still walking. The menu acts on the whole set when the clicked row is in it. */
568
+ const [rowSelection, setRowSelection] = useState(() => new Set())
569
+ const selectAnchorRef = useRef(null)
570
+
571
+ const selectRow = (path, e, orderedPaths) => {
572
+ const additive = e.metaKey || e.ctrlKey
573
+ const ranged = e.shiftKey && selectAnchorRef.current
574
+ if (!additive && !ranged) { setRowSelection(new Set()); selectAnchorRef.current = path; return false }
575
+ e.preventDefault()
576
+ setRowSelection((prev) => {
577
+ const next = new Set(prev)
578
+ if (ranged) {
579
+ const a = orderedPaths.indexOf(selectAnchorRef.current)
580
+ const b = orderedPaths.indexOf(path)
581
+ if (a !== -1 && b !== -1) for (let i = Math.min(a, b); i <= Math.max(a, b); i++) next.add(orderedPaths[i])
582
+ } else {
583
+ next.has(path) ? next.delete(path) : next.add(path)
584
+ selectAnchorRef.current = path
585
+ }
586
+ return next
587
+ })
588
+ return true
589
+ }
590
+
591
+ /* The set the menu acts on: the selection when you right-clicked something inside it, otherwise
592
+ * just the row you hit — which is what every file manager does and what stops a right-click on an
593
+ * unrelated row from silently deleting five things. */
594
+ const targetsFor = (path) => (rowSelection.has(path) ? [...rowSelection] : [path])
595
+
596
+ const doBatch = (paths, verb, label) => {
597
+ if (paths.length === 1) return null
598
+ if (label === 'Delete' && !confirm(`Delete ${paths.length} items? This cannot be undone.`)) return null
599
+ let dest = null
600
+ if (label === 'Move') {
601
+ dest = prompt(`Move ${paths.length} items into which folder? (blank = bucket root)`, prefix)
602
+ if (dest === null) return null
603
+ dest = dest ? `${dest.replace(/^\/+|\/+$/g, '')}/` : ''
604
+ }
605
+ runAction(async () => {
606
+ for (const p of paths) {
607
+ if (label === 'Delete') await fileActions.remove(p)
608
+ else await fileActions.move(p, dest)
609
+ }
610
+ setRowSelection(new Set())
611
+ })
612
+ void verb
613
+ return true
614
+ }
615
+
616
+ const draggingRef = useRef(null)
617
+ const dragFor = canWrite && fileActions.move ? (path) => ({
618
+ path,
619
+ onDragStart: (e, from) => { draggingRef.current = from; e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', from) },
620
+ canDrop: (dest) => {
621
+ const from = draggingRef.current
622
+ if (!from || from === dest) return false
623
+ if (from.endsWith('/') && dest.startsWith(from)) return false // into itself
624
+ const parent = from.replace(/\/$/, '').split('/').slice(0, -1).join('/')
625
+ return dest !== (parent ? `${parent}/` : '') // already there
626
+ },
627
+ onDrop: (dest) => {
628
+ const from = draggingRef.current
629
+ draggingRef.current = null
630
+ if (!from) return
631
+ const name = from.replace(/\/$/, '').split('/').pop()
632
+ runAction(() => fileActions.move(from, dest))
633
+ void name
634
+ },
635
+ }) : null
636
+
637
+ const doDelete = (path, isFolder) => {
638
+ if (!confirm(`Delete ${isFolder ? `"${path}" and everything in it` : `"${path}"`}? This cannot be undone.`)) return
639
+ runAction(() => fileActions.remove(path))
640
+ }
403
641
  const [quickLook, setQuickLook] = useState(null)
404
642
  const columnsRef = useRef(null)
405
643
  const { folderView = 'columns', flat } = settings
@@ -421,8 +659,17 @@ export function MediaLibraryBrowse({
421
659
  [searched, settings.sortBy, settings.sortDir],
422
660
  )
423
661
 
662
+ const treeFolders = (level) => (folderTree?.[bucketMeta.id]?.folders ?? [])
663
+ .filter((p) => p.startsWith(level) && p.length > level.length && !p.slice(level.length, -1).includes('/'))
664
+ .map((p) => p.slice(level.length))
665
+
424
666
  const scoped = prefix ? sortedObjects.filter((o) => o.key.startsWith(prefix)) : sortedObjects
425
- const { folders, files: dirFiles } = partition(scoped, prefix)
667
+ const { folders: keyFolders, files: dirFiles } = partition(scoped, prefix)
668
+ /* THE COUNT COUNTS WHAT IS ON SCREEN. `partition` derives folders from file keys, so an EMPTY
669
+ * folder is invisible to it — the stats line said "2 folders" under a list showing three, the
670
+ * third being `03-scratch/`, which exists in `folderTree` and nowhere else. Both views draw the
671
+ * merged set now, so the tally is taken from the same place. */
672
+ const folders = [...new Set([...treeFolders(prefix), ...keyFolders])].sort()
426
673
  const keySet = new Set(objects.map((o) => o.key))
427
674
  const levelFiles = flat ? scoped.map((o) => ({ ...o, displayKey: prefix ? o.key.slice(prefix.length) : o.key })) : dirFiles
428
675
  const systemCount = levelFiles.filter((o) => isSystemFile(o.key)).length
@@ -446,9 +693,24 @@ export function MediaLibraryBrowse({
446
693
  const ROOT = title
447
694
  const label = bucketMeta.label || 'bucket'
448
695
  const VROOT = single ? '' : `${ROOT}/${label}/`
449
- const treeFolders = (level) => (folderTree?.[bucketMeta.id]?.folders ?? [])
450
- .filter((p) => p.startsWith(level) && p.length > level.length && !p.slice(level.length, -1).includes('/'))
451
- .map((p) => p.slice(level.length))
696
+ const unroot = (p) => (VROOT && p.startsWith(VROOT) ? p.slice(VROOT.length) : p)
697
+ /* ONE LEVEL, ONE ANSWER. The column view merges the baked tree with the live partition inside its
698
+ * `partition` prop; the row view used the live partition alone and therefore disagreed with the
699
+ * columns about what a folder even is. This is that merge, lifted so both callers share it —
700
+ * folders from `folderTree` (real nodes, empty ones included) unioned with the ones derived from
701
+ * keys, plus the level's own files, which the row view never rendered at all. */
702
+ const rowLevel = (level) => {
703
+ /* `partition` slices every key by `prefix` WITHOUT checking it matches, so it must be handed an
704
+ * already-scoped list. Given the whole bucket it cut four characters off every root key and
705
+ * drew `readme.md` as `me.md` and `video/` as `o/`. Scope first, always. */
706
+ const inLevel = level ? objects.filter((o) => o.key.startsWith(level)) : objects
707
+ const live = partition(inLevel, level)
708
+ const baked = treeFolders(level)
709
+ return {
710
+ folders: [...new Set([...baked, ...live.folders])].sort(),
711
+ files: sortObjects(live.files.filter((o) => !isSystemFile(o.key)), settings.sortBy, settings.sortDir),
712
+ }
713
+ }
452
714
 
453
715
  useEffect(() => {
454
716
  if (folderView !== 'columns') return undefined
@@ -463,10 +725,19 @@ export function MediaLibraryBrowse({
463
725
 
464
726
  return (
465
727
  <div className={`flex flex-col gap-6 ${className}`.trim()}>
466
- <LibraryHeader title={title} buckets={buckets} bucketId={bucketMeta.id} appRoot={appRoot} bucketMeta={bucketMeta} writable={writable} headerActions={headerActions}
728
+ <LibraryHeader title={title} buckets={buckets} bucketId={bucketMeta.id} appRoot={appRoot} bucketMeta={bucketMeta} writable={writable} headerActions={headerActions} headerTrailing={headerTrailing}
729
+ onHome={() => { setAppRoot(true); setPickedFile(null); setPrefix('') }}
467
730
  onBucket={(v) => { if (v === 'all') { setAppRoot(true); setPrefix('') } else { setAppRoot(false); switchBucket(v) } }}
468
731
  onSettings={() => setSettingsOpen(true)} />
469
732
 
733
+ {/* THE BANNER SLOT — anything that must sit directly under the header and above the body.
734
+ * The upload drop zone is why it exists: a consumer rendering it AFTER the page put it
735
+ * below an 800px column browser, so pressing Upload scrolled you past the whole browser to
736
+ * reach the target you had just asked for. A slot is the fix; telling the consumer to
737
+ * re-order is not, because the header is ours and the body is ours and there was no
738
+ * between. */}
739
+ {banner}
740
+
470
741
  <div className="flex flex-col gap-3">
471
742
  {/* Breadcrumb (uppercase, active segment at full ink) · folder-view toggle
472
743
  *
@@ -646,6 +917,13 @@ export function MediaLibraryBrowse({
646
917
  const poster = posterFor(real.key, keySet)
647
918
  return <KindPreview o={real} urlOf={(x) => mediaUrl(x.key)} poster={poster ? mediaUrl(poster) : undefined} kindOf={kindOf} kindLabel={KIND_LABEL} />
648
919
  }}
920
+ /* THE COLUMNS SPEAK IN VIRTUAL PATHS. With more than one bucket every level is
921
+ * prefixed `<title>/<bucket>/` so the stores share one tree — so a path coming OUT
922
+ * of the browser must be un-rooted before it reaches a file verb, or the store is
923
+ * asked to move `MEDIA/R2 · kol-media/img/x.jpg`, which it has never heard of. The
924
+ * row view needs none of this; it is already bucket-relative. */
925
+ onRowContextMenu={(e, payload) => menu.openAt(e, { ...payload, path: unroot(payload.path) })}
926
+ dragFor={dragFor ? (path) => dragFor(unroot(path)) : undefined}
649
927
  partition={(objs, level) => {
650
928
  if (!single) {
651
929
  if (level === '') return { folders: [`${ROOT}/`], files: [] }
@@ -657,16 +935,90 @@ export function MediaLibraryBrowse({
657
935
  }}
658
936
  />
659
937
  </div>
660
- ) : folders.length > 0 && (
661
- <ul className="flex flex-col">{folders.map((f) => <FolderRow key={f} name={f} onClick={() => setPrefix(prefix + f)} struck={flat} />)}</ul>
938
+ ) : (
939
+ /* ROW VIEW — the same level the column view draws, not a different one. It used to render
940
+ * `partition(scoped, prefix).folders` and nothing else, which lost two things: an EMPTY
941
+ * folder (partition derives folders from file keys, so a folder with no files under it
942
+ * does not exist to it — `folderTree` is where real nodes live, and the column view was
943
+ * already merging the two) and every FILE at the level. `#img/` drew 2 rows against the
944
+ * columns' 4. Folders first, then files, each expandable in place like a list view. */
945
+ /* SAME AREA AS THE COLUMNS. The row view used to be content-height, so the two folder
946
+ * views made the page jump between them and the blank space below the last row — where
947
+ * you right-click for "new folder" and drop to file something here — did not exist. It
948
+ * takes the column browser's own height now, from the same setting.
949
+ *
950
+ * The list IS the current folder, so it is a drop target for `prefix`, matching a column
951
+ * being a target for its level. Highlight via a data attribute for the same reason. */
952
+ <ul className="flex flex-col overflow-y-auto kol-row-browser"
953
+ style={{ height: settings.columnHeight ?? SETTINGS_BASE.columnHeight }}
954
+ onContextMenu={(e) => menu.openAt(e, { type: 'level', path: prefix })}
955
+ onDragOver={(e) => {
956
+ const d = dragFor?.(prefix)
957
+ if (!d?.canDrop(prefix)) return
958
+ e.preventDefault()
959
+ e.currentTarget.dataset.dropOver = '1'
960
+ }}
961
+ onDragLeave={(e) => { if (e.currentTarget === e.target) delete e.currentTarget.dataset.dropOver }}
962
+ onDrop={(e) => {
963
+ delete e.currentTarget.dataset.dropOver
964
+ const d = dragFor?.(prefix)
965
+ if (!d?.canDrop(prefix)) return
966
+ e.preventDefault()
967
+ d.onDrop(prefix)
968
+ }}>
969
+ {rowLevel(prefix).folders.map((f) => {
970
+ const path = prefix + f
971
+ const open = expandedRows.has(path)
972
+ const order = [...rowLevel(prefix).folders.map((x) => prefix + x), ...rowLevel(prefix).files.map((x) => x.key)]
973
+ return (
974
+ <Fragment key={f}>
975
+ <FolderRow
976
+ name={f} struck={flat} expanded={open}
977
+ selected={rowSelection.has(path)}
978
+ onContextMenu={(e) => menu.openAt(e, { type: 'folder', path, targets: targetsFor(path) })}
979
+ drag={dragFor?.(path)}
980
+ meta={folderMeta?.(path) || undefined}
981
+ onToggle={() => setExpandedRows((prev) => {
982
+ const next = new Set(prev)
983
+ next.has(path) ? next.delete(path) : next.add(path)
984
+ return next
985
+ })}
986
+ onClick={(e) => { if (!selectRow(path, e, order)) setPrefix(path) }}
987
+ />
988
+ {open && <RowSubtree
989
+ level={path} depth={1} rowLevel={rowLevel} expanded={expandedRows}
990
+ onToggle={(p) => setExpandedRows((prev) => { const n = new Set(prev); n.has(p) ? n.delete(p) : n.add(p); return n })}
991
+ onOpenFolder={setPrefix} onOpenFile={(o) => setPickedFile(o)}
992
+ onRowContextMenu={(e, payload) => menu.openAt(e, payload)} dragFor={dragFor}
993
+ formatDate={formatDate} folderMeta={folderMeta} thumbnailFor={thumbnailFor} flat={flat}
994
+ />}
995
+ </Fragment>
996
+ )
997
+ })}
998
+ {rowLevel(prefix).files.map((o) => (
999
+ <FileRow key={o.key} o={o} formatDate={formatDate} thumb={thumbnailFor?.(o)}
1000
+ selected={rowSelection.has(o.key)}
1001
+ onContextMenu={(e) => menu.openAt(e, { type: 'file', path: o.key, o, targets: targetsFor(o.key) })}
1002
+ drag={dragFor?.(o.key)}
1003
+ onClick={(e) => {
1004
+ const order = [...rowLevel(prefix).folders.map((x) => prefix + x), ...rowLevel(prefix).files.map((x) => x.key)]
1005
+ if (!selectRow(o.key, e, order)) setPickedFile(o)
1006
+ }} />
1007
+ ))}
1008
+ </ul>
662
1009
  )}
663
1010
 
1011
+ {/* `mt-2` BALANCES THE INK, not the boxes (user 2026-09-21: "what was unclear about matching
1012
+ * the gap above to below"). The column pane, the crumb row above it and this line share one
1013
+ * `gap-3`, so box-to-box both gaps are 12px — but the crumb row's box runs 8px lower than
1014
+ * its own text, because the ROW·COLUMN icon buttons are taller than the crumbs. Measured
1015
+ * against the pane's BORDER: 20px above, 12px below. The 8 puts the text gaps level. */}
664
1016
  {appRoot && folderView === 'columns' && folderTree ? (
665
- <p className="kol-mono-12 text-fg-48">
1017
+ <p className="kol-mono-12 text-fg-48 mt-2">
666
1018
  {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))}
667
1019
  </p>
668
1020
  ) : (
669
- <p className="kol-mono-12 text-fg-48">
1021
+ <p className="kol-mono-12 text-fg-48 mt-2">
670
1022
  {folders.length > 0 && `${folders.length} folder${folders.length > 1 ? 's' : ''} · `}
671
1023
  {rawFiles.length} {rawFiles.length === 1 ? 'file' : 'files'} · {formatSize(totalBytes)}
672
1024
  {(!prefix || flat) && rawFiles.length !== bucketFiles && <span className="text-fg-32">{' · bucket: '}{bucketFiles} files · {formatSize(bucketBytes)}</span>}
@@ -675,6 +1027,65 @@ export function MediaLibraryBrowse({
675
1027
  )}
676
1028
  {void onOpen}
677
1029
 
1030
+ {/* ONE MENU FOR EVERY ROW. `useContextMenu` carries the payload from whichever row opened
1031
+ * it, so this is a single instance rather than one per row, and the entries are built from
1032
+ * what `fileActions` actually supplies — a read-only bucket gets no menu at all. */}
1033
+ {canWrite && (
1034
+ <ContextMenu menu={menu}>
1035
+ {(target) => {
1036
+ if (!target) return null
1037
+ const isFolder = target.type === 'folder'
1038
+ const isLevel = target.type === 'level'
1039
+ /* A SELECTION OF MORE THAN ONE takes over the verbs that can act on a set. Rename is
1040
+ * not one of them — renaming five things to one name is not a thing — so it stays
1041
+ * single and disappears from a multi-selection rather than lying about what it does. */
1042
+ const many = (target.targets ?? []).length > 1 ? target.targets : null
1043
+ if (many) {
1044
+ return (
1045
+ <>
1046
+ <MenuDropdownItem disabled>{many.length} selected</MenuDropdownItem>
1047
+ <MenuDropdownDivider />
1048
+ {fileActions.move && (
1049
+ <MenuDropdownItem iconLeft={<Icon name="arrow-right" size={14} />} onClick={() => doBatch(many, 'move', 'Move')}>Move {many.length} to…</MenuDropdownItem>
1050
+ )}
1051
+ {fileActions.remove && (
1052
+ <MenuDropdownItem iconLeft={<Icon name="trash" size={14} />} onClick={() => doBatch(many, 'remove', 'Delete')}>Delete {many.length}</MenuDropdownItem>
1053
+ )}
1054
+ </>
1055
+ )
1056
+ }
1057
+ return (
1058
+ <>
1059
+ {fileActions.createFolder && (
1060
+ <MenuDropdownItem iconLeft={<Icon name="folder" size={14} />} onClick={() => doCreateFolder(isFolder ? target.path : (isLevel ? target.path : prefix))}>
1061
+ New folder{isFolder ? ` in ${target.path.replace(/\/$/, '').split('/').pop()}` : ''}
1062
+ </MenuDropdownItem>
1063
+ )}
1064
+ {fileActions.createFile && (
1065
+ <MenuDropdownItem iconLeft={<Icon name="file" size={14} />} onClick={() => doCreateFile(isFolder ? target.path : (isLevel ? target.path : prefix))}>
1066
+ New file
1067
+ </MenuDropdownItem>
1068
+ )}
1069
+ {!isLevel && <MenuDropdownDivider />}
1070
+ {!isLevel && fileActions.rename && (
1071
+ <MenuDropdownItem iconLeft={<Icon name="edit" size={14} />} onClick={() => doRename(target.path, isFolder)}>Rename</MenuDropdownItem>
1072
+ )}
1073
+ {!isLevel && fileActions.move && (
1074
+ <MenuDropdownItem iconLeft={<Icon name="arrow-right" size={14} />} onClick={() => doMove(target.path)}>Move to…</MenuDropdownItem>
1075
+ )}
1076
+ {!isLevel && fileActions.remove && (
1077
+ <>
1078
+ <MenuDropdownDivider />
1079
+ <MenuDropdownItem iconLeft={<Icon name="trash" size={14} />} onClick={() => doDelete(target.path, isFolder)}>Delete</MenuDropdownItem>
1080
+ </>
1081
+ )}
1082
+ </>
1083
+ )
1084
+ }}
1085
+ </ContextMenu>
1086
+ )}
1087
+ {busyAction && <span className="sr-only" role="status">Working…</span>}
1088
+
678
1089
  {/* THE TAB PILL (item 16) — floats over the list, so the list owes it
679
1090
  room or its last row sits under the bar forever.
680
1091
  THE SPACER GOES LAST, AND THAT IS THE WHOLE POINT
@@ -700,7 +1111,7 @@ export function MediaLibraryBrowse({
700
1111
  /* ══ LIBRARY — the content-filters wall ═══════════════════════════════════ */
701
1112
  export function MediaLibraryLibrary({
702
1113
  client, title = 'MEDIA', bucket, onBucketChange, prefix = '', defaults, settings: settingsProp, onSettingsChange,
703
- headerActions, refreshKey, header = true, stats = true, settingsFooter, className = '',
1114
+ headerActions, headerTrailing, refreshKey, header = true, stats = true, settingsFooter, className = '', banner,
704
1115
  /* the same pill the browse page takes — the wall is one of the surfaces it
705
1116
  switches between, so it has to carry it too (item 16) */
706
1117
  tabs, activeTab, onTabChange,
@@ -857,16 +1268,32 @@ export function MediaLibraryLibrary({
857
1268
  }
858
1269
  return o.displayKey.split('/').pop()
859
1270
  }
1271
+ /* ONE PREVIEWER (2026-09-21). This was a hand-rolled ladder that knew image, a sibling poster
1272
+ * and an autoloaded video, and printed the literal word "video" or "markdown" for everything
1273
+ * else — while the column preview eight hundred lines up already called `KindPreview`, which
1274
+ * renders markdown, code, json, yaml, audio and HLS for real. That is why every kind previewed
1275
+ * in the column pane and nothing previewed in the grid or the list: two previewers for one
1276
+ * object, and the wall's predates the component.
1277
+ *
1278
+ * Images stay the caller's, as they are in the column pane and as `KindPreview` documents —
1279
+ * it has no image branch on purpose, because in a frame the frame draws its own. A video keeps
1280
+ * its poster when the bucket has a sibling still, because a poster is cheaper than the file and
1281
+ * `videoPreview` is the consumer's setting about exactly that. Everything else falls through. */
860
1282
  const renderThumb = (o, onClick) => {
861
- const media = isImage(o.contentType) || isVideo(o.contentType)
862
- const poster = (isVideo(o.contentType) || kindOf(o) === 'playlist') ? posterFor(o.key, keySet) : null
1283
+ const kind = kindOf(o)
1284
+ const poster = (isVideo(o.contentType) || kind === 'playlist') ? posterFor(o.key, keySet) : null
863
1285
  const imgSrc = isImage(o.contentType) ? mediaUrl(o.key) : poster && videoPreview !== 'none' ? mediaUrl(poster) : null
864
1286
  if (imgSrc) return <img src={imgSrc} alt="" loading="lazy" className={onClick ? 'cursor-zoom-in' : undefined} onClick={onClick || undefined} />
1287
+ if (isVideo(o.contentType) && videoPreview === 'none') {
1288
+ return (
1289
+ <div className={`w-full h-full flex items-center justify-center bg-fg-04 overflow-hidden${onClick ? ' cursor-zoom-in' : ''}`} onClick={onClick || undefined}>
1290
+ <span className="kol-mono-12 text-fg-48">video</span>
1291
+ </div>
1292
+ )
1293
+ }
865
1294
  return (
866
- <div className={`w-full h-full flex items-center justify-center bg-fg-04 overflow-hidden${media && onClick ? ' cursor-zoom-in' : ''}`} onClick={media && onClick ? onClick : undefined}>
867
- {isVideo(o.contentType) && videoPreview === 'autoload' ? <video src={mediaUrl(o.key)} className="w-full h-full object-cover" muted preload="metadata" />
868
- : isVideo(o.contentType) ? <span className="kol-mono-12 text-fg-48">video</span>
869
- : <span className="kol-mono-12 text-fg-48">{KIND_LABEL[kindOf(o)] || 'file'}{o.segmentCount ? ` ${o.segmentCount}` : ''}</span>}
1295
+ <div className={`kol-media-thumb w-full h-full flex items-center justify-center bg-fg-04 overflow-hidden${onClick ? ' cursor-zoom-in' : ''}`} onClick={onClick || undefined}>
1296
+ <KindPreview o={o} urlOf={(x) => mediaUrl(x.key)} poster={poster ? mediaUrl(poster) : undefined} kindOf={kindOf} kindLabel={KIND_LABEL} />
870
1297
  </div>
871
1298
  )
872
1299
  }
@@ -879,9 +1306,12 @@ export function MediaLibraryLibrary({
879
1306
  return (
880
1307
  <div className={`flex flex-col gap-6 ${className}`.trim()}>
881
1308
  {header && (
882
- <LibraryHeader title={title} buckets={buckets} bucketId={bucketMeta.id} appRoot={false} bucketMeta={bucketMeta} writable={writable} headerActions={headerActions}
1309
+ <LibraryHeader title={title} buckets={buckets} bucketId={bucketMeta.id} appRoot={false} bucketMeta={bucketMeta} writable={writable} headerActions={headerActions} headerTrailing={headerTrailing}
883
1310
  onBucket={(v) => { if (v === 'all') return; if (bucket == null) setOwnBucket(v); onBucketChange?.(v) }} onSettings={() => setSettingsOpen(true)} />
884
1311
  )}
1312
+ {/* A SEAM ON ONE PAGE IS A SEAM ON BOTH — `banner` is shared chrome, so it lands here in the
1313
+ * same edit it landed on the browse page. See the note on `formatDate` above. */}
1314
+ {banner}
885
1315
  {settingsOpen && (
886
1316
  <MediaSettings bucketMeta={bucketMeta} settings={settings} profile={profile} onChange={setSettings} onReset={() => setSettings(null)} onClose={() => setSettingsOpen(false)} settingsFooter={settingsFooter} />
887
1317
  )}
@@ -0,0 +1,80 @@
1
+ import { useCallback, useEffect, useState } from 'react'
2
+ import { usePopover, PopoverPanel } from './Popover.jsx'
3
+
4
+ /**
5
+ * ContextMenu — a right-click menu, anchored at the pointer.
6
+ *
7
+ * `onContextMenu` appeared NOWHERE in this package before 2026-09-21, which is
8
+ * why every file-manager verb the media product needed (new folder, rename,
9
+ * move, delete) had no surface to be invoked from: the operations existed on
10
+ * the client and nothing could reach them. This is that surface.
11
+ *
12
+ * Built on `usePopover`'s `referenceElement` seam rather than a new floating
13
+ * implementation — a context menu is a popover whose anchor is a point instead
14
+ * of an element, and floating-ui takes a virtual element for exactly this. So
15
+ * it inherits flip, shift, the portal, dismissal and the `.kol-popover` chrome,
16
+ * and a menu near the viewport edge behaves like every other panel.
17
+ *
18
+ * const menu = useContextMenu()
19
+ * <li onContextMenu={(e) => menu.openAt(e, row)}>…</li>
20
+ * <ContextMenu menu={menu}>
21
+ * {(row) => <MenuDropdownItem onClick={() => rename(row)}>Rename</MenuDropdownItem>}
22
+ * </ContextMenu>
23
+ *
24
+ * `children` may be a render function taking the payload handed to `openAt`,
25
+ * so one menu instance serves a whole list instead of one per row.
26
+ */
27
+ export function useContextMenu() {
28
+ const [state, setState] = useState(null) // { x, y, payload } | null
29
+
30
+ const openAt = useCallback((event, payload = null) => {
31
+ event.preventDefault()
32
+ event.stopPropagation()
33
+ setState({ x: event.clientX, y: event.clientY, payload })
34
+ }, [])
35
+
36
+ const close = useCallback(() => setState(null), [])
37
+
38
+ const popover = usePopover({
39
+ open: !!state,
40
+ onOpenChange: (v) => { if (!v) close() },
41
+ placement: 'right-start',
42
+ offset: 2,
43
+ click: false,
44
+ role: 'menu',
45
+ })
46
+
47
+ /* A VIRTUAL ELEMENT — a zero-size rect at the pointer. It goes through
48
+ * `setPositionReference`, NOT the `elements.reference` option: floating-ui
49
+ * rejects a virtual element there ("must be a real DOM element") because that
50
+ * slot also feeds the interaction hooks, which call `getAttribute` on it.
51
+ * `setPositionReference` is the seam for exactly this — position from a point,
52
+ * interactions from nothing. */
53
+ const { setPositionReference } = popover.refs
54
+ useEffect(() => {
55
+ if (!state) return
56
+ const { x, y } = state
57
+ setPositionReference({
58
+ getBoundingClientRect: () => ({ width: 0, height: 0, x, y, top: y, left: x, right: x, bottom: y }),
59
+ })
60
+ }, [state, setPositionReference])
61
+
62
+ return { open: !!state, payload: state?.payload ?? null, openAt, close, popover }
63
+ }
64
+
65
+ export default function ContextMenu({ menu, children, className = '' }) {
66
+ if (!menu.open) return null
67
+ return (
68
+ /* `focus={false}` — FloatingFocusManager reads attributes off the reference,
69
+ * and a point has none. Dismissal is outside-click and Escape, which the
70
+ * popover already wires. */
71
+ <PopoverPanel popover={menu.popover} focus={false} className={`kol-dd-list min-w-44 ${className}`.trim()}>
72
+ {/* Any click inside closes — a menu item that leaves the menu open after
73
+ * acting is the one interaction people report as broken. Items keep their
74
+ * own onClick; this runs after it on the way up. */}
75
+ <div onClick={menu.close} role="none">
76
+ {typeof children === 'function' ? children(menu.payload) : children}
77
+ </div>
78
+ </PopoverPanel>
79
+ )
80
+ }