@kolkrabbi/kol-component 0.68.0 → 0.69.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.68.0",
3
+ "version": "0.69.0",
4
4
  "description": "KOL design-system components — atoms through organisms, emitting canonical kol-* classes. Pairs with @kolkrabbi/kol-theme for styling.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/index.js CHANGED
@@ -130,6 +130,7 @@ export { default as LoaderOverlay } from './utilities/LoaderOverlay.jsx'
130
130
  export { default as MediaLibrary, MediaLibraryProvider, useMediaLibrary, MediaPicker, MediaBrowser } from './organisms/MediaLibrary.jsx'
131
131
  export { default as MediaTileGallery } from './organisms/MediaTileGallery.jsx'
132
132
  export { default as MediaViewer } from './organisms/MediaViewer.jsx'
133
+ export { default as SettingsPanel, SettingsRow, SettingsSwitch, SettingsChoice, SettingsChipRow, SettingsFooter } from './organisms/SettingsPanel.jsx'
133
134
  export { default as NewsletterBand } from './organisms/NewsletterBand.jsx'
134
135
  export { default as RecordManager } from './organisms/RecordManager.jsx'
135
136
  export { default as SpectrumGrid } from './organisms/SpectrumGrid.jsx'
@@ -51,6 +51,15 @@ const syntaxTheme = (foregroundToken = 80) => ({
51
51
  display: 'block',
52
52
  borderRadius: 0,
53
53
  border: 'none',
54
+ /* THE BLOCK WRAPS (06-code-surface.md: Block · pre-wrap + overflow-x auto)
55
+ * and this is the one place that decides it. oneDark's code style carries
56
+ * `whiteSpace: 'pre'`, and react-syntax-highlighter spreads the theme's
57
+ * code style AFTER its own `wrapLongLines` pre-wrap — so the prop, the
58
+ * .kol-codeblock rule and customStyle all said wrap while the <code>
59
+ * computed `pre`, and every long line scrolled instead (measured on
60
+ * /stack/vcap at 393: block 359 wide, content 465–759, CodeBlockMobile-
61
+ * Overflow, kol-website 2026-08-25). */
62
+ whiteSpace: 'pre-wrap',
54
63
  color: `color-mix(in srgb, var(--kol-surface-on-primary) ${foregroundToken}%, transparent)`
55
64
  },
56
65
  comment: {
@@ -95,7 +104,11 @@ export default function CodeBlock({ children, code: codeProp, language: language
95
104
  wrapLines={true}
96
105
  wrapLongLines={true}
97
106
  PreTag="div"
107
+ /* `kol-codeblock-line` names each wrapped line so the theme can
108
+ * reserve the copy control's lane on the FIRST one when no chip row
109
+ * exists (kol-components-molecules.css). */
98
110
  lineProps={{
111
+ className: 'kol-codeblock-line',
99
112
  style: {
100
113
  border: 'none',
101
114
  background: 'transparent',
@@ -1,12 +1,16 @@
1
1
  import { createContext, useContext, useEffect, useMemo, useState } from 'react'
2
2
  import { Icon } from '@kolkrabbi/kol-icons'
3
3
  import Button from '../atoms/Button.jsx'
4
+ import Divider from '../atoms/Divider.jsx'
5
+ import Input from '../atoms/Input.jsx'
4
6
  import SegmentedToggle from '../atoms/SegmentedToggle.jsx'
7
+ import ViewToggle from '../atoms/ViewToggle.jsx'
5
8
  import FullscreenOverlay from '../utilities/FullscreenOverlay.jsx'
6
9
  import MediaCard from '../molecules/MediaCard.jsx'
7
10
  import MediaRow from '../molecules/MediaRow.jsx'
8
11
  import ContentFilters from './ContentFilters.jsx'
9
12
  import MediaViewer from './MediaViewer.jsx'
13
+ import { SettingsChipRow, chipCls } from './SettingsPanel.jsx'
10
14
 
11
15
  /**
12
16
  * MediaLibrary — a browser over an object bucket, in two views over one
@@ -21,20 +25,24 @@ import MediaViewer from './MediaViewer.jsx'
21
25
  * Same contract as kol-dashboards / kol-chess / kol-content.
22
26
  *
23
27
  * COMPOSED, NOT BUILT. Every part is an existing DS member:
24
- * ContentFilters — filter groups, animated search, view toggle, N-of-M count
25
28
  * MediaCard — the grid tile (thumb · download chip · name · meta · actions)
26
29
  * MediaRow — the list row (thumb · name · date · size · actions)
27
30
  * MediaViewer — the lightbox, via its `actions` slot
31
+ * ContentFilters — the PICKER's chrome (search, kind filter, view toggle, N-of-M)
28
32
  * FullscreenOverlay — the picker's scrim, dismissal and close button
29
- * The first pass hand-rolled a tile grid and a folder row while MediaCard and
30
- * MediaRow — built from this same source in the 2026-07-03 sweep — sat unused.
31
33
  *
32
- * NAVIGATION IS FINDER'S LIST MODEL, not click-to-enter. Folders are rows in
33
- * the same list with a disclosure chevron and expand IN PLACE, so the parent
34
- * never leaves the screen and there is no breadcrumb stacked above a divider.
35
- * The path bar sits at the FOOT, where Finder puts it.
34
+ * NAVIGATION IS CLICK-TO-ENTER + BREADCRUMB (MediaLibraryReconcile, kol-r2b2
35
+ * 2026-08-26). The page variant is the read-only render of kol-r2b2's
36
+ * `FileList` the one the user works in on media.kolkrabbi.io so brand
37
+ * `/library` and media. are ONE picture: breadcrumb on top, a stats line
38
+ * (level · bucket · system files hidden), folder rows that ENTER a prefix,
39
+ * a bare toolbar (filter · search | Flat · view · sort with direction), a
40
+ * 260px tile grid, and `Show N more` paging. Finder's disclose-in-place tree
41
+ * (2026-08-01) is DROPPED, not kept as a variant — two navigation models in
42
+ * one organism is the fork this ticket exists to end. The picker keeps its
43
+ * ContentFilters chrome and its path bar at the foot; it navigates the same way.
36
44
  *
37
- * Read-only by design. Upload / rename / delete stay in kol-media-admin
45
+ * Read-only by design. Upload / rename / delete / select stay in kol-r2b2
38
46
  * write auth does not belong in a browser-shipped package.
39
47
  */
40
48
 
@@ -203,71 +211,82 @@ function pairPosters(list) {
203
211
  })
204
212
  }
205
213
 
206
- /**
207
- * Flatten the bucket's flat key list into ONE ordered row list, folders and
208
- * files interleaved, honouring which folders are open. The list endpoint
209
- * returns keys with no `prefixes` key and `?delimiter=/` changes nothing
210
- * (probed 2026-08-01), so the tree is derived here — this function is the
211
- * whole navigation feature.
212
- */
213
- function buildRows(objects, expanded, sort) {
214
- const childrenOf = new Map()
215
- const folders = new Set()
216
-
217
- for (const o of objects) {
218
- const dir = folderOf(o.key)
219
- if (dir) {
220
- /* register every ancestor so a deep key materialises its whole chain */
221
- const parts = dir.slice(0, -1).split('/')
222
- for (let i = 0; i < parts.length; i += 1) folders.add(`${parts.slice(0, i + 1).join('/')}/`)
223
- }
224
- if (!childrenOf.has(dir)) childrenOf.set(dir, [])
225
- childrenOf.get(dir).push(o)
214
+ /* ── The prefix-scoped list ────────────────────────────────────────────────
215
+ * The list endpoint returns keys with no `prefixes` key and `?delimiter=/`
216
+ * changes nothing (probed 2026-08-01), so folders are derived here from the
217
+ * keys under the current prefix this function is the whole navigation. */
218
+ function foldersUnder(list, prefix) {
219
+ const names = new Set()
220
+ for (const o of list) {
221
+ const rest = o.key.slice(prefix.length)
222
+ const i = rest.indexOf('/')
223
+ if (i > 0) names.add(rest.slice(0, i))
226
224
  }
225
+ return [...names].sort().map((name) => ({ key: `${prefix}${name}/`, label: name }))
226
+ }
227
227
 
228
- const subFoldersOf = (prefix) =>
229
- [...folders].filter((f) => folderOf(f.slice(0, -1)) === prefix).sort()
228
+ const SORT_OPTIONS = [
229
+ { value: 'name', label: 'Name' },
230
+ { value: 'date', label: 'Date' },
231
+ { value: 'size', label: 'Size' },
232
+ { value: 'kind', label: 'Kind' },
233
+ ]
230
234
 
231
- const sorted = (list) => {
232
- const by = {
233
- name: (a, b) => a.key.localeCompare(b.key),
234
- date: (a, b) => String(b.uploaded ?? '').localeCompare(String(a.uploaded ?? '')),
235
- size: (a, b) => (b.size ?? 0) - (a.size ?? 0),
236
- kind: (a, b) => String(a.contentType ?? '').localeCompare(String(b.contentType ?? '')),
237
- }
238
- return [...list].sort(by[sort] ?? by.name)
239
- }
235
+ /* Chip labels — authored here, no text-transform (the kind key is the value). */
236
+ const KIND_LABEL = {
237
+ image: 'Image', video: 'Video', audio: 'Audio', text: 'Text', code: 'Code',
238
+ playlist: 'Playlist', font: 'Font', archive: 'Archive', other: 'Other',
239
+ }
240
240
 
241
- const rows = []
242
- const walk = (prefix, depth) => {
243
- for (const f of subFoldersOf(prefix)) {
244
- rows.push({ type: 'folder', key: f, label: fileName(f.slice(0, -1)) + '/', depth })
245
- if (expanded.has(f)) walk(f, depth + 1)
246
- }
247
- for (const o of sorted(childrenOf.get(prefix) ?? [])) {
248
- rows.push({ type: 'file', depth, ...o, displayKey: o.displayName ?? fileName(o.key) })
249
- }
250
- }
251
- walk('', 0)
252
- return rows
241
+ /* Sortable-header semantics (FileList): arrow-down = ascending (A→Z, oldest
242
+ * first, smallest first); ties fall back to the name. */
243
+ function sortFiles(files, { by, dir }) {
244
+ const d = dir === 'desc' ? -1 : 1
245
+ const name = (a, b) => a.displayKey.localeCompare(b.displayKey)
246
+ const cmp = {
247
+ name,
248
+ date: (a, b) => String(a.uploaded ?? '').localeCompare(String(b.uploaded ?? '')) || name(a, b),
249
+ size: (a, b) => (a.size ?? 0) - (b.size ?? 0) || name(a, b),
250
+ kind: (a, b) => a.kind.localeCompare(b.kind) || name(a, b),
251
+ }[by] ?? name
252
+ return [...files].sort((a, b) => d * cmp(a, b))
253
253
  }
254
254
 
255
255
  /**
256
- * MediaLibraryProvider — the headless core: one list call, client-side tree
257
- * derivation, the open-folder set and the sort key.
256
+ * MediaLibraryProvider — the headless core: one list call, then a
257
+ * PREFIX-SCOPED view over it: the folders directly under the prefix, the
258
+ * files at that level (or the whole subtree in `flat`), the kind allow-list,
259
+ * the search, the sort with direction, and paging.
258
260
  *
259
261
  * @param {object} client `{ listMedia, mediaUrl, proxied? }` — required
260
262
  * @param {string|string[]} accept 'all' (default) = everything · one kind ·
261
263
  * or an allow-list, `['image','video']`, which is what a picker wants.
262
264
  * Kinds: image · video · audio · text · code · playlist · font · archive ·
263
265
  * other. Browsing never filters by default.
266
+ * @param {number} pageSize rows mounted before `Show N more` (60; 0 = all)
267
+ * @param {object} defaultSort `{ by, dir }` — `{ by: 'date', dir: 'desc' }`
268
+ * @param {boolean} flat start in flat mode (every object under the prefix)
264
269
  */
265
- export function MediaLibraryProvider({ client, accept = 'all', children }) {
270
+ export function MediaLibraryProvider({
271
+ client,
272
+ accept = 'all',
273
+ pageSize = 60,
274
+ defaultSort = { by: 'date', dir: 'desc' },
275
+ flat: initialFlat = false,
276
+ children,
277
+ }) {
266
278
  const [objects, setObjects] = useState([])
267
- const [expanded, setExpanded] = useState(() => new Set())
268
- const [sort, setSort] = useState('name')
269
279
  const [loading, setLoading] = useState(true)
270
280
  const [error, setError] = useState(null)
281
+ const [prefix, setPrefix] = useState('')
282
+ const [flat, setFlat] = useState(initialFlat)
283
+ const [sort, setSortState] = useState(defaultSort)
284
+ const [search, setSearch] = useState('')
285
+ const [kinds, setKinds] = useState(() => new Set())
286
+ /* Paging is keyed to WHAT is listed: prefix / flat / search / kinds change →
287
+ * back to one page, or walking into a folder would inherit the page count
288
+ * from the flat view you just left. Derived, not an effect. */
289
+ const [paging, setPaging] = useState({ id: null, visible: 0 })
271
290
 
272
291
  useEffect(() => {
273
292
  if (!client) return undefined
@@ -283,47 +302,97 @@ export function MediaLibraryProvider({ client, accept = 'all', children }) {
283
302
  return () => { cancelled = true; controller.abort() }
284
303
  }, [client])
285
304
 
286
- const toggleFolder = (key) =>
287
- setExpanded((prev) => {
305
+ /* click an inactive field → ascending; click the active one → flip */
306
+ const sortBy = (by) =>
307
+ setSortState((s) => (s.by === by ? { by, dir: s.dir === 'asc' ? 'desc' : 'asc' } : { by, dir: 'asc' }))
308
+ const toggleKind = (k) =>
309
+ setKinds((prev) => {
288
310
  const next = new Set(prev)
289
- if (next.has(key)) next.delete(key)
290
- else next.add(key)
311
+ if (next.has(k)) next.delete(k)
312
+ else next.add(k)
291
313
  return next
292
314
  })
293
315
 
294
316
  const value = useMemo(() => {
295
317
  const annotated = objects.map((o) => ({ ...o, kind: kindOf(o) }))
296
- const systemCount = annotated.reduce((n, o) => n + (o.kind === 'system' ? 1 : 0), 0)
297
-
318
+ const scoped = prefix ? annotated.filter((o) => o.key.startsWith(prefix)) : annotated
319
+ const folders = foldersUnder(scoped, prefix)
320
+ /* level = the direct children; flat = every object under the prefix */
321
+ const level = flat ? scoped : scoped.filter((o) => !o.key.slice(prefix.length).includes('/'))
322
+ /* .DS_Store × 116 in the website bucket — dropped from the list, counted
323
+ * and reported: hiding them silently would be the same lie as the
324
+ * level-only totals were. */
325
+ const systemCount = level.reduce((n, o) => n + (o.kind === 'system' ? 1 : 0), 0)
298
326
  /* Fold before pairing: a poster must be matched against real image keys,
299
327
  * and resolution sets must be collapsed after that or the poster's own
300
328
  * width suffix would swallow it. */
301
- const visible = foldResolutionSets(
302
- pairPosters(foldHlsSegments(annotated.filter((o) => o.kind !== 'system'))),
329
+ const folded = foldResolutionSets(pairPosters(foldHlsSegments(level.filter((o) => o.kind !== 'system'))))
330
+ const files = folded
331
+ .filter(acceptsKind(accept))
332
+ .map((o) => ({ ...o, displayKey: o.displayName ?? (flat ? o.key.slice(prefix.length) : fileName(o.key)) }))
333
+
334
+ const kindCounts = files.reduce((acc, o) => { acc[o.kind] = (acc[o.kind] || 0) + 1; return acc }, {})
335
+ const kindsPresent = Object.keys(kindCounts).sort().map((k) => ({ value: k, label: KIND_LABEL[k] ?? k, count: kindCounts[k] }))
336
+
337
+ const q = search.trim().toLowerCase()
338
+ const filtered = files.filter(
339
+ (o) => (kinds.size === 0 || kinds.has(o.kind)) && (!q || o.displayKey.toLowerCase().includes(q)),
303
340
  )
341
+ const sorted = sortFiles(filtered, sort)
342
+
343
+ const page = pageSize || Infinity
344
+ const listId = `${prefix}|${flat}|${q}|${[...kinds].sort().join(',')}|${pageSize}`
345
+ const visible = paging.id === listId ? paging.visible : page
346
+ const shown = sorted.slice(0, visible)
347
+ const more = sorted.length - shown.length
304
348
 
305
- const kept = visible.filter(acceptsKind(accept))
306
349
  /* The lightbox pages images and videos; a .json in that list is a broken
307
- * frame with a next-arrow. Its index space is this list, not `files`. */
308
- const viewable = kept.filter((o) => o.kind === 'image' || o.kind === 'video')
350
+ * frame with a next-arrow. Its index space is this list, not `sorted`. */
351
+ const viewable = sorted.filter((o) => o.kind === 'image' || o.kind === 'video')
352
+
353
+ const bucketFiles = annotated.reduce((n, o) => n + (o.kind === 'system' ? 0 : 1), 0)
354
+ const bucketBytes = annotated.reduce((n, o) => n + (o.size ?? 0), 0)
309
355
 
310
356
  return {
311
- objects: kept,
312
- rows: buildRows(kept, expanded, sort),
313
- files: kept,
314
- viewable,
315
- kinds: [...new Set(kept.map((o) => o.kind))].sort(),
316
- systemCount,
317
- expanded,
318
- toggleFolder,
357
+ objects: files,
358
+ files,
359
+ filtered,
360
+ sorted,
361
+ shown,
362
+ more,
363
+ pageSize: page,
364
+ showMore: () => setPaging({ id: listId, visible: visible + page }),
365
+ prefix,
366
+ setPrefix,
367
+ crumbs: prefix ? prefix.replace(/\/$/, '').split('/') : [],
368
+ folders,
369
+ flat,
370
+ setFlat,
371
+ kindsPresent,
372
+ kinds,
373
+ toggleKind,
374
+ search,
375
+ setSearch,
319
376
  sort,
320
- setSort,
377
+ sortBy,
378
+ stats: {
379
+ folders: folders.length,
380
+ files: filtered.length,
381
+ rawFiles: files.length,
382
+ bytes: files.reduce((n, o) => n + (o.size ?? 0), 0),
383
+ bucketFiles,
384
+ bucketBytes,
385
+ atRoot: !prefix,
386
+ systemCount,
387
+ filtering: kinds.size > 0 || q.length > 0,
388
+ },
389
+ viewable,
321
390
  loading,
322
391
  error,
323
392
  mediaUrl: client?.mediaUrl ?? ((key) => key),
324
393
  proxied: client?.proxied ?? ((url) => url),
325
394
  }
326
- }, [objects, expanded, sort, loading, error, accept, client])
395
+ }, [objects, prefix, flat, sort, search, kinds, paging, pageSize, loading, error, accept, client])
327
396
 
328
397
  return <MediaLibraryContext.Provider value={value}>{children}</MediaLibraryContext.Provider>
329
398
  }
@@ -336,27 +405,12 @@ export function useMediaLibrary() {
336
405
  return ctx
337
406
  }
338
407
 
339
- function withProvider(node, { client, accept }) {
408
+ function withProvider(node, { client, accept, pageSize, defaultSort, flat }) {
340
409
  if (!client) return node
341
- return <MediaLibraryProvider client={client} accept={accept}>{node}</MediaLibraryProvider>
342
- }
343
-
344
- /* Indentation per tree depth. A rem step rather than a magic pixel, and it
345
- * rides the spacing scale's 1rem rung. */
346
- const indent = (depth) => ({ paddingInlineStart: `calc(${depth} * var(--kol-spacing-4))` })
347
-
348
- function FolderRow({ row, open, onToggle }) {
349
410
  return (
350
- <li
351
- className="kol-media-folder"
352
- style={indent(row.depth)}
353
- onClick={onToggle}
354
- aria-expanded={open}
355
- >
356
- <Icon name={open ? 'chevron-down' : 'chevron-right'} size={14} />
357
- <Icon name="folder" size={16} />
358
- <span className="kol-mono-12 text-emphasis flex-1">{row.label}</span>
359
- </li>
411
+ <MediaLibraryProvider client={client} accept={accept} pageSize={pageSize} defaultSort={defaultSort} flat={flat}>
412
+ {node}
413
+ </MediaLibraryProvider>
360
414
  )
361
415
  }
362
416
 
@@ -429,151 +483,390 @@ function Thumb({ row, mediaUrl }) {
429
483
  )
430
484
  }
431
485
 
432
- /* Folders, then the tiles or rows. Shared by both views the modal shell and
433
- * the pick action are the ONLY differences between them. */
434
- function LibraryBody({ rows, viewMode, onOpen, onPick }) {
435
- const { expanded, toggleFolder, mediaUrl, loading, error, viewable } = useMediaLibrary()
486
+ /* One folder, click-to-enter. `struck` = flat mode: the folder is bypassed
487
+ * but still navigable, so it stays and reads de-emphasised. Box in
488
+ * .kol-media-folder (kol-components-organisms.css). */
489
+ function FolderRow({ folder, struck = false, onEnter }) {
490
+ return (
491
+ <li className="kol-media-folder" onClick={onEnter}>
492
+ <span className="flex h-8 w-8 shrink-0 items-center justify-center text-meta">
493
+ <Icon name="folder" size={18} />
494
+ </span>
495
+ <span className={`kol-mono-12 flex-1 ${struck ? 'line-through text-meta' : 'text-body'}`}>{folder.label}</span>
496
+ <Icon name="chevron-right" size={14} className="text-subtle" />
497
+ </li>
498
+ )
499
+ }
500
+
501
+ function FolderRows() {
502
+ const { folders, flat, setPrefix } = useMediaLibrary()
503
+ if (!folders.length) return null
504
+ return (
505
+ <ul className="kol-media-list">
506
+ {folders.map((f) => (
507
+ <FolderRow key={f.key} folder={f} struck={flat} onEnter={() => setPrefix(f.key)} />
508
+ ))}
509
+ </ul>
510
+ )
511
+ }
512
+
513
+ /* Breadcrumb — `root / seg / seg`, every crumb a step back. */
514
+ function Breadcrumb({ className = '' }) {
515
+ const { crumbs, setPrefix } = useMediaLibrary()
516
+ return (
517
+ <div className={`flex items-center gap-1 kol-mono-12 text-meta ${className}`}>
518
+ <button type="button" className="hover:text-emphasis transition-colors" onClick={() => setPrefix('')}>root</button>
519
+ {crumbs.map((seg, i) => {
520
+ const to = `${crumbs.slice(0, i + 1).join('/')}/`
521
+ return (
522
+ <span key={to} className="flex items-center gap-1">
523
+ <span>/</span>
524
+ <button type="button" className="hover:text-emphasis transition-colors" onClick={() => setPrefix(to)}>{seg}</button>
525
+ </span>
526
+ )
527
+ })}
528
+ </div>
529
+ )
530
+ }
531
+
532
+ /* The stats line. Whole-bucket figures at root or in flat — the level-only
533
+ * line used to read "0 files · 0 B" at a B2 root: true of the level, a lie
534
+ * about the bucket. */
535
+ function Stats() {
536
+ const { stats: s, flat } = useMediaLibrary()
537
+ return (
538
+ <p className="kol-mono-12 text-meta">
539
+ {s.folders > 0 && `${s.folders} folder${s.folders > 1 ? 's' : ''} · `}
540
+ {s.filtering && s.rawFiles !== s.files ? `${s.files} of ${s.rawFiles}` : s.rawFiles}
541
+ {' '}{s.rawFiles === 1 ? 'file' : 'files'} · {formatSize(s.bytes)}
542
+ {(s.atRoot || flat) && s.rawFiles !== s.bucketFiles && (
543
+ <span className="text-subtle">{' · bucket: '}{s.bucketFiles} files · {formatSize(s.bucketBytes)}</span>
544
+ )}
545
+ {s.systemCount > 0 && (
546
+ <span className="text-subtle">{' · '}{s.systemCount} system file{s.systemCount === 1 ? '' : 's'} hidden</span>
547
+ )}
548
+ </p>
549
+ )
550
+ }
551
+
552
+ /* Sort — label buttons; the active one carries the direction glyph. */
553
+ function SortControls() {
554
+ const { sort, sortBy } = useMediaLibrary()
555
+ return (
556
+ <div className="flex items-center gap-4">
557
+ {SORT_OPTIONS.map((opt) => {
558
+ const active = sort.by === opt.value
559
+ return (
560
+ <button
561
+ key={opt.value}
562
+ type="button"
563
+ aria-pressed={active}
564
+ onClick={() => sortBy(opt.value)}
565
+ className={`kol-mono-12 flex items-center gap-1 transition-colors ${active ? 'text-emphasis' : 'text-meta hover:text-body'}`}
566
+ >
567
+ {opt.label}
568
+ {active && <Icon name={sort.dir === 'asc' ? 'arrow-down' : 'arrow-up'} size={10} />}
569
+ </button>
570
+ )
571
+ })}
572
+ </div>
573
+ )
574
+ }
575
+
576
+ /* The bare toolbar — filter · search on the left; Flat · view · sort on the
577
+ * right. No title: that is the consumer's page header. The icon buttons are
578
+ * DS Buttons on the nav rung (the box has an owner), `pressed` when the
579
+ * filter is open or narrowing. */
580
+ function Toolbar({ viewMode, onViewMode }) {
581
+ const { search, setSearch, kindsPresent, kinds, toggleKind, flat, setFlat } = useMediaLibrary()
582
+ const [filterOpen, setFilterOpen] = useState(false)
583
+ const [searchOpen, setSearchOpen] = useState(false)
584
+
585
+ return (
586
+ <>
587
+ <div className="flex items-center justify-between gap-4">
588
+ <div className="flex items-center gap-2">
589
+ <Button
590
+ variant="nav"
591
+ size="sm"
592
+ iconOnly="filter"
593
+ iconSize={16}
594
+ quiet
595
+ pressed={filterOpen || kinds.size > 0}
596
+ aria-label="Toggle kind filter"
597
+ onClick={() => setFilterOpen((v) => !v)}
598
+ />
599
+ {searchOpen ? (
600
+ <Input
601
+ size="sm"
602
+ variant="outline"
603
+ width="200px"
604
+ value={search}
605
+ autoFocus
606
+ placeholder="search name…"
607
+ onChange={(e) => setSearch(e.target.value)}
608
+ onKeyDown={(e) => { if (e.key === 'Escape') { setSearch(''); setSearchOpen(false) } }}
609
+ onBlur={() => { if (!search) setSearchOpen(false) }}
610
+ />
611
+ ) : (
612
+ <Button
613
+ variant="nav"
614
+ size="sm"
615
+ iconOnly="search"
616
+ iconSize={16}
617
+ quiet
618
+ pressed={search.length > 0}
619
+ aria-label="Search"
620
+ onClick={() => setSearchOpen(true)}
621
+ />
622
+ )}
623
+ </div>
624
+ <div className="flex items-center gap-3">
625
+ <button
626
+ type="button"
627
+ aria-pressed={flat}
628
+ title="Show all files recursively"
629
+ onClick={() => setFlat(!flat)}
630
+ className={chipCls(flat)}
631
+ >
632
+ Flat
633
+ </button>
634
+ <ViewToggle viewMode={viewMode} onViewChange={onViewMode} variant="icon" />
635
+ <Divider variant="vertical" />
636
+ <SortControls />
637
+ </div>
638
+ </div>
639
+ {filterOpen && (
640
+ <div className="flex items-center gap-2">
641
+ <span className="kol-mono-12 text-subtle">Kind</span>
642
+ <SettingsChipRow options={kindsPresent} selected={kinds} onToggle={toggleKind} />
643
+ </div>
644
+ )}
645
+ </>
646
+ )
647
+ }
648
+
649
+ /* The tiles or the rows over ONE paged list — shared by the page and the
650
+ * picker; `onPick` is the only difference between them. */
651
+ function FilesBody({ files, viewMode, onOpen, onPick }) {
652
+ const { mediaUrl, viewable, prefix, stats, more, pageSize, showMore } = useMediaLibrary()
436
653
  const [copied, copy] = useCopy()
437
654
 
438
- if (error) return <p className="kol-helper-12 text-ui-error">Couldn’t load: {error}</p>
439
- if (loading) return <p className="kol-helper-12 text-meta">Loading…</p>
440
- if (rows.length === 0) return <p className="kol-helper-12 text-meta">Nothing here.</p>
655
+ if (files.length === 0) {
656
+ return (
657
+ <p className="kol-mono-12 text-meta">
658
+ {stats.filtering ? 'No files match.' : `No files${prefix ? ` in "${prefix}"` : ''} yet.`}
659
+ </p>
660
+ )
661
+ }
441
662
 
442
- const files = rows.filter((r) => r.type === 'file')
443
663
  /* Index into `viewable`, which is what the lightbox pages — indexing into the
444
664
  * filtered rows meant a search narrowing the grid opened the wrong file. */
445
665
  const openerFor = (row) => {
446
666
  const i = viewable.findIndex((f) => f.key === row.key)
447
667
  return i < 0 ? undefined : () => onOpen(i)
448
668
  }
449
-
450
- /* Copy hands over the full-size variant, not the thumbnail the tile loaded. */
669
+ /* Copy and download hand over the full-size variant, not the thumbnail. */
451
670
  const urlFor = (row) => mediaUrl(row.fullKey ?? row.key)
452
671
 
672
+ const thumbFor = (row) => {
673
+ const open = openerFor(row)
674
+ return (
675
+ <div
676
+ className={`w-full h-full flex items-center justify-center bg-fg-04 overflow-hidden${open ? ' cursor-zoom-in' : ''}`}
677
+ onClick={open}
678
+ >
679
+ <Thumb row={row} mediaUrl={mediaUrl} />
680
+ </div>
681
+ )
682
+ }
453
683
  const actionsFor = (row) => (
454
684
  <div className="flex items-center gap-2">
455
685
  {onPick && <Button size="sm" onClick={() => onPick(row)}>Use</Button>}
456
686
  <Button variant="secondary" size="sm" onClick={() => copy(urlFor(row))}>
457
687
  {copied === urlFor(row) ? 'Copied' : 'Copy URL'}
458
688
  </Button>
689
+ <Button variant="ghost" size="sm" iconOnly="download" iconSize={14} href={urlFor(row)} aria-label={`Download ${row.displayKey}`} />
459
690
  </div>
460
691
  )
692
+ const nameFor = (row) => <p className="kol-mono-12 text-body truncate" title={row.key}>{row.displayKey}</p>
693
+ const date = (row) => (row.uploaded ? String(row.uploaded).slice(0, 10) : '')
461
694
 
462
- if (viewMode === 'list') {
463
- return (
464
- <ul className="kol-media-scroll kol-media-list">
465
- {rows.map((row) =>
466
- row.type === 'folder' ? (
467
- <FolderRow key={row.key} row={row} open={expanded.has(row.key)} onToggle={() => toggleFolder(row.key)} />
468
- ) : (
469
- <div key={row.key} style={indent(row.depth)}>
470
- <MediaRow
471
- thumb={<Thumb row={row} mediaUrl={mediaUrl} />}
472
- name={
473
- openerFor(row) ? (
474
- <button type="button" className="kol-mono-12 text-emphasis" onClick={openerFor(row)}>
475
- {row.displayKey}
476
- </button>
477
- ) : (
478
- <span className="kol-mono-12 text-emphasis">{row.displayKey}</span>
479
- )
480
- }
481
- date={row.uploaded ? String(row.uploaded).slice(0, 10) : ''}
482
- size={formatSize(row.size)}
483
- actions={actionsFor(row)}
484
- />
485
- </div>
486
- ),
487
- )}
488
- </ul>
489
- )
490
- }
695
+ return (
696
+ <>
697
+ {viewMode === 'list' ? (
698
+ <ul className="kol-media-list">
699
+ {files.map((row) => (
700
+ <MediaRow
701
+ key={row.key}
702
+ thumb={thumbFor(row)}
703
+ name={nameFor(row)}
704
+ date={date(row)}
705
+ size={formatSize(row.size)}
706
+ actions={actionsFor(row)}
707
+ />
708
+ ))}
709
+ </ul>
710
+ ) : (
711
+ <ul className="kol-media-grid">
712
+ {files.map((row) => (
713
+ <MediaCard
714
+ key={row.key}
715
+ thumb={thumbFor(row)}
716
+ name={nameFor(row)}
717
+ meta={`${formatSize(row.size)}${date(row) ? ` · ${date(row)}` : ''}`}
718
+ downloadHref={urlFor(row)}
719
+ actions={actionsFor(row)}
720
+ />
721
+ ))}
722
+ </ul>
723
+ )}
724
+ {more > 0 && (
725
+ <button
726
+ type="button"
727
+ onClick={showMore}
728
+ className="kol-mono-12 text-meta hover:text-emphasis transition-colors self-start py-2"
729
+ >
730
+ Show {Math.min(more, pageSize)} more · {more} remaining
731
+ </button>
732
+ )}
733
+ </>
734
+ )
735
+ }
736
+
737
+ /* The lightbox is MediaViewer — the DS already has ONE fullscreen paged viewer
738
+ * and this is not a second one. Use / Copy URL ride its `actions` slot. */
739
+ function LibraryViewer({ index, onIndexChange, onClose, onPick }) {
740
+ const { viewable, mediaUrl } = useMediaLibrary()
741
+ const [copied, copy] = useCopy()
742
+
743
+ /* Full-size in the lightbox — `key` is the thumbnail variant for folded sets. */
744
+ const media = viewable.map((o) => ({
745
+ url: mediaUrl(o.fullKey ?? o.key),
746
+ alt: fileName(o.key),
747
+ kind: o.kind === 'video' ? 'video' : 'image',
748
+ caption: `${o.displayName ?? fileName(o.key)} · ${formatSize(o.size)}`,
749
+ }))
491
750
 
492
751
  return (
493
- <div className="kol-media-scroll">
494
- <ul className="kol-media-list">
495
- {rows.filter((r) => r.type === 'folder').map((row) => (
496
- <FolderRow key={row.key} row={row} open={expanded.has(row.key)} onToggle={() => toggleFolder(row.key)} />
497
- ))}
498
- </ul>
499
- <ul className="kol-media-grid">
500
- {files.map((row) => (
501
- <MediaCard
502
- key={row.key}
503
- thumb={
504
- <div
505
- className={openerFor(row) ? 'w-full h-full cursor-pointer' : 'w-full h-full'}
506
- onClick={openerFor(row)}
507
- >
508
- <Thumb row={row} mediaUrl={mediaUrl} />
509
- </div>
510
- }
511
- name={<p className="kol-mono-12 text-emphasis truncate">{row.displayKey}</p>}
512
- meta={`${formatSize(row.size)}${row.uploaded ? ` · ${String(row.uploaded).slice(0, 10)}` : ''}`}
513
- /* The set's largest variant, not the thumbnail the tile painted. */
514
- downloadHref={mediaUrl(row.fullKey ?? row.key)}
515
- actions={actionsFor(row)}
516
- />
517
- ))}
518
- </ul>
519
- </div>
752
+ <MediaViewer
753
+ open={index !== null}
754
+ media={media}
755
+ index={index ?? 0}
756
+ onIndexChange={onIndexChange}
757
+ onClose={onClose}
758
+ actions={(item, i) => (
759
+ <>
760
+ {onPick && <Button size="sm" onClick={() => onPick(viewable[i])}>Use</Button>}
761
+ <Button variant="secondary" size="sm" onClick={() => copy(item.url)}>
762
+ {copied === item.url ? 'Copied' : 'Copy URL'}
763
+ </Button>
764
+ </>
765
+ )}
766
+ />
520
767
  )
521
768
  }
522
769
 
523
- /* Finder puts the path at the window FOOT, not stacked above the content. The
524
- * hidden-system count rides the same bar — hiding 118 `.DS_Store` files without
525
- * saying so is the same silent drop this component was filed for. */
526
- function PathBar({ rows, systemCount }) {
527
- const open = rows.filter((r) => r.type === 'folder' && r.depth > 0)
528
- const trail = open.length ? open[open.length - 1].key.replace(/\/$/, '').split('/') : []
770
+ /* ── The PAGE FileList's read-only render ─────────────────────────────── */
771
+ function BrowserShell({ onSelect }) {
772
+ const lib = useMediaLibrary()
773
+ const [viewMode, setViewMode] = useState('grid')
774
+ const [viewerIndex, setViewerIndex] = useState(null)
775
+
776
+ const pick = onSelect
777
+ ? (o) => onSelect(lib.mediaUrl(o.fullKey ?? o.key), { contentType: o.contentType, kind: o.kind })
778
+ : undefined
779
+
529
780
  return (
530
- <div className="kol-media-pathbar">
531
- <Icon name="folder" size={12} />
532
- <span className="kol-helper-12 text-meta">root</span>
533
- {trail.map((seg) => (
534
- <span key={seg} className="flex items-center gap-1">
535
- <Icon name="chevron-right" size={10} />
536
- <span className="kol-helper-12 text-meta">{seg}</span>
537
- </span>
538
- ))}
539
- {systemCount > 0 && (
540
- <span className="kol-helper-12 text-meta ms-auto">
541
- {systemCount} system file{systemCount === 1 ? '' : 's'} hidden
542
- </span>
781
+ <div className="kol-media-browser gap-3">
782
+ <Breadcrumb />
783
+ <Stats />
784
+ {lib.error && <p className="kol-mono-12 text-ui-error">Couldn’t load: {lib.error}</p>}
785
+ {lib.loading && <p className="kol-mono-12 text-meta">Loading…</p>}
786
+ {!lib.loading && !lib.error && lib.stats.folders === 0 && lib.stats.rawFiles === 0 && (
787
+ <p className="kol-mono-12 text-meta">No files{lib.prefix ? ` in "${lib.prefix}"` : ''} yet.</p>
788
+ )}
789
+ <FolderRows />
790
+ {lib.stats.rawFiles > 0 && (
791
+ <div className="flex flex-col gap-3">
792
+ <Toolbar viewMode={viewMode} onViewMode={setViewMode} />
793
+ <Divider />
794
+ <FilesBody files={lib.shown} viewMode={viewMode} onOpen={setViewerIndex} onPick={pick} />
795
+ </div>
796
+ )}
797
+ {viewerIndex !== null && lib.viewable[viewerIndex] && (
798
+ <LibraryViewer
799
+ index={viewerIndex}
800
+ onIndexChange={setViewerIndex}
801
+ onClose={() => setViewerIndex(null)}
802
+ onPick={pick}
803
+ />
543
804
  )}
544
805
  </div>
545
806
  )
546
807
  }
547
808
 
809
+ /* ── The PICKER — ContentFilters chrome, path bar at the foot ───────────── */
810
+
548
811
  const VIEW_OPTIONS = [
549
812
  { value: 'grid', icon: 'grid', label: 'Grid' },
550
813
  { value: 'list', icon: 'view-list', label: 'List' },
551
814
  ]
552
815
 
553
- /* Sort is a SegmentedToggle the DS's joined N-way control. The first
554
- * pass hand-rolled four <button className="kol-helper-12"> instead. */
555
- const SORTS = [
556
- { value: 'name', label: 'name' },
557
- { value: 'date', label: 'date' },
558
- { value: 'size', label: 'size' },
559
- { value: 'kind', label: 'kind' },
560
- ]
816
+ /* The path at the FOOT of the picker card; the hidden-system count rides it —
817
+ * hiding 118 `.DS_Store` files without saying so is the same silent drop this
818
+ * component was filed for. */
819
+ function PathBar() {
820
+ const { stats } = useMediaLibrary()
821
+ return (
822
+ <div className="kol-media-pathbar">
823
+ <Icon name="folder" size={12} />
824
+ <Breadcrumb />
825
+ {stats.systemCount > 0 && (
826
+ <span className="kol-helper-12 text-meta ms-auto">
827
+ {stats.systemCount} system file{stats.systemCount === 1 ? '' : 's'} hidden
828
+ </span>
829
+ )}
830
+ </div>
831
+ )
832
+ }
561
833
 
562
- /* The chrome ContentFilters owns the animated search, the filter groups, the
563
- * view toggle and the N-of-M count. It was hand-rolled as a static <Input> on
564
- * the first pass while this organism sat one import away. */
834
+ function PickerBody({ items, viewMode, onOpen, onPick }) {
835
+ const { folders, flat, setPrefix, loading, error, more, pageSize, showMore } = useMediaLibrary()
836
+ if (error) return <p className="kol-helper-12 text-ui-error">Couldn’t load: {error}</p>
837
+ if (loading) return <p className="kol-helper-12 text-meta">Loading…</p>
838
+ const files = items.filter((r) => r.type !== 'folder')
839
+ const shownFolders = items.filter((r) => r.type === 'folder')
840
+ if (items.length === 0) return <p className="kol-helper-12 text-meta">Nothing here.</p>
841
+ return (
842
+ <div className="kol-media-scroll flex flex-col gap-3">
843
+ {shownFolders.length > 0 && (
844
+ <ul className="kol-media-list">
845
+ {shownFolders.map((f) => (
846
+ <FolderRow key={f.key} folder={f} struck={flat} onEnter={() => setPrefix(f.key)} />
847
+ ))}
848
+ </ul>
849
+ )}
850
+ <FilesBody files={files.slice(0, files.length - Math.max(0, more))} viewMode={viewMode} onOpen={onOpen} onPick={onPick} />
851
+ {void folders}{void pageSize}{void showMore}
852
+ </div>
853
+ )
854
+ }
855
+
856
+ /* The picker's chrome — ContentFilters owns the animated search, the kind
857
+ * filter, the view toggle and the N-of-M count; sort rides its header slot. */
565
858
  function LibraryChrome({ onOpen, onPick }) {
566
- const { rows, sort, setSort, kinds, systemCount } = useMediaLibrary()
859
+ const { folders, sorted, sort, sortBy } = useMediaLibrary()
567
860
  const [viewMode, setViewMode] = useState('grid')
568
861
 
569
862
  const items = useMemo(
570
- () => rows.map((r) => ({
571
- ...r,
572
- name: r.type === 'folder' ? r.label : r.displayKey,
573
- kind: r.type === 'folder' ? 'folder' : r.kind,
574
- })),
575
- [rows],
863
+ () => [
864
+ ...folders.map((f) => ({ ...f, type: 'folder', name: f.label, kind: 'folder' })),
865
+ ...sorted.map((o) => ({ ...o, type: 'file', name: o.displayKey })),
866
+ ],
867
+ [folders, sorted],
576
868
  )
869
+ const kinds = useMemo(() => [...new Set(sorted.map((o) => o.kind))].sort(), [sorted])
577
870
 
578
871
  return (
579
872
  <>
@@ -587,60 +880,19 @@ function LibraryChrome({ onOpen, onPick }) {
587
880
  onViewModeChange={setViewMode}
588
881
  viewModeOptions={VIEW_OPTIONS}
589
882
  mutuallyExclusiveFilters={['kind']}
590
- /* Derived — a hard-coded image/video/folder list is how the filter bar
591
- * denied the existence of the audio and data the provider now keeps. */
592
883
  filterGroups={[{ label: 'Kind', key: 'kind', values: ['folder', ...kinds] }]}
593
884
  headerActions={
594
- <SegmentedToggle
595
- size="sm"
596
- value={sort}
597
- onChange={setSort}
598
- options={SORTS}
599
- ariaLabel="Sort by"
600
- />
885
+ <SegmentedToggle size="sm" value={sort.by} onChange={sortBy} options={SORT_OPTIONS} ariaLabel="Sort by" />
601
886
  }
602
887
  renderItem={(filtered, mode) => (
603
- <LibraryBody rows={filtered} viewMode={mode} onOpen={onOpen} onPick={onPick} />
888
+ <PickerBody items={filtered} viewMode={mode} onOpen={onOpen} onPick={onPick} />
604
889
  )}
605
890
  />
606
- <PathBar rows={rows} systemCount={systemCount} />
891
+ <PathBar />
607
892
  </>
608
893
  )
609
894
  }
610
895
 
611
- /* The lightbox is MediaViewer — the DS already has ONE fullscreen paged viewer
612
- * and this is not a second one. Use / Copy URL ride its `actions` slot. */
613
- function LibraryViewer({ index, onIndexChange, onClose, onPick }) {
614
- const { viewable, mediaUrl } = useMediaLibrary()
615
- const [copied, copy] = useCopy()
616
-
617
- /* Full-size in the lightbox — `key` is the thumbnail variant for folded sets. */
618
- const media = viewable.map((o) => ({
619
- url: mediaUrl(o.fullKey ?? o.key),
620
- alt: fileName(o.key),
621
- kind: o.kind === 'video' ? 'video' : 'image',
622
- caption: `${o.displayName ?? fileName(o.key)} · ${formatSize(o.size)}`,
623
- }))
624
-
625
- return (
626
- <MediaViewer
627
- open={index !== null}
628
- media={media}
629
- index={index ?? 0}
630
- onIndexChange={onIndexChange}
631
- onClose={onClose}
632
- actions={(item, i) => (
633
- <>
634
- {onPick && <Button size="sm" onClick={() => onPick(viewable[i])}>Use</Button>}
635
- <Button variant="secondary" size="sm" onClick={() => copy(item.url)}>
636
- {copied === item.url ? 'Copied' : 'Copy URL'}
637
- </Button>
638
- </>
639
- )}
640
- />
641
- )
642
- }
643
-
644
896
  function PickerShell({ onClose, onPick }) {
645
897
  const { viewable, mediaUrl } = useMediaLibrary()
646
898
  const [viewerIndex, setViewerIndex] = useState(null)
@@ -676,34 +928,37 @@ function PickerShell({ onClose, onPick }) {
676
928
  /**
677
929
  * MediaLibrary — ONE component, two variants. The user's ruling 2026-08-01:
678
930
  * "arent different components, they are more like variants, same shit
679
- * different viewing." He is right — `page` and `modal` render the identical
680
- * body and differ only in the shell around it and whether picking closes.
681
- *
682
- * Variant is CONTAINER GEOMETRY ONLY, the ThemeToggle precedent: everything
683
- * else is a prop. `MediaBrowser` and `MediaPicker` survive below as thin
684
- * aliases so no call site breaks.
931
+ * different viewing." `page` and `modal` differ in the shell around the body
932
+ * and in whether picking closes.
685
933
  *
686
- * @param {string} variant 'page' (in-flow, fills its box) | 'modal' (overlay)
934
+ * @param {string} variant 'page' (in-flow, FileList's render) | 'modal' (overlay picker)
687
935
  * @param {boolean} open modal only — mounts the overlay
688
936
  * @param {object} client `{ listMedia, mediaUrl, proxied? }`; omit inside a provider
689
937
  * @param {string|string[]} accept 'all' (default) = everything · one kind · an
690
938
  * allow-list `['image','video']`
939
+ * @param {number} pageSize rows mounted before `Show N more` (60; 0 = all)
940
+ * @param {object} defaultSort `{ by, dir }` (date desc)
941
+ * @param {boolean} flat start in flat mode
691
942
  * @param {Function} onClose modal only — Esc, backdrop, close button
692
- * @param {Function} onSelect `(url, { contentType })`. In `modal` it also closes.
943
+ * @param {Function} onSelect `(url, { contentType, kind })`. In `modal` it also closes.
693
944
  */
694
945
  export default function MediaLibrary({
695
946
  variant = 'page',
696
947
  open = true,
697
948
  client,
698
949
  accept = 'all',
950
+ pageSize = 60,
951
+ defaultSort = { by: 'date', dir: 'desc' },
952
+ flat = false,
699
953
  onClose,
700
954
  onSelect = null,
701
955
  }) {
956
+ const opts = { client, accept, pageSize, defaultSort, flat }
702
957
  if (variant === 'modal') {
703
958
  if (!open) return null
704
- return withProvider(<PickerShell onClose={onClose} onPick={onSelect} />, { client, accept })
959
+ return withProvider(<PickerShell onClose={onClose} onPick={onSelect} />, opts)
705
960
  }
706
- return withProvider(<BrowserShell onSelect={onSelect} />, { client, accept })
961
+ return withProvider(<BrowserShell onSelect={onSelect} />, opts)
707
962
  }
708
963
 
709
964
  /** Alias — `MediaLibrary variant="modal"`. Kept so existing call sites and the
@@ -721,32 +976,8 @@ export function MediaPicker({ open, client, accept = 'all', onClose, onPick }) {
721
976
  )
722
977
  }
723
978
 
724
- function BrowserShell({ onSelect }) {
725
- const { viewable, mediaUrl } = useMediaLibrary()
726
- const [viewerIndex, setViewerIndex] = useState(null)
727
-
728
- const pick = onSelect
729
- ? (o) => onSelect(mediaUrl(o.fullKey ?? o.key), { contentType: o.contentType, kind: o.kind })
730
- : undefined
731
-
732
- return (
733
- <div className="kol-media-browser">
734
- <LibraryChrome onOpen={setViewerIndex} onPick={pick} />
735
-
736
- {viewerIndex !== null && viewable[viewerIndex] && (
737
- <LibraryViewer
738
- index={viewerIndex}
739
- onIndexChange={setViewerIndex}
740
- onClose={() => setViewerIndex(null)}
741
- onPick={pick}
742
- />
743
- )}
744
- </div>
745
- )
746
- }
747
-
748
979
  /** Alias — `MediaLibrary variant="page"`. Without `onSelect` the actions offer
749
- * Copy URL only, which is the read-only page a brand book wants. */
750
- export function MediaBrowser({ client, accept = 'all', onSelect = null }) {
751
- return <MediaLibrary variant="page" client={client} accept={accept} onSelect={onSelect} />
980
+ * Copy URL + download only, which is the read-only page a brand book wants. */
981
+ export function MediaBrowser({ client, accept = 'all', onSelect = null, ...rest }) {
982
+ return <MediaLibrary variant="page" client={client} accept={accept} onSelect={onSelect} {...rest} />
752
983
  }
@@ -0,0 +1,177 @@
1
+ import Button from '../atoms/Button.jsx'
2
+ import SegmentedToggle from '../atoms/SegmentedToggle.jsx'
3
+ import ToggleSwitch from '../atoms/ToggleSwitch.jsx'
4
+ import ShellDrawer from '../molecules/ShellDrawer.jsx'
5
+ import FullscreenOverlay from '../utilities/FullscreenOverlay.jsx'
6
+
7
+ /**
8
+ * SettingsPanel — a settings surface for the thing you are looking at: you
9
+ * change how a list looks WHILE looking at it, so it is a slide-over (or an
10
+ * overlay), never a route (SettingsPanel, kol-r2b2 2026-08-26 — the
11
+ * per-bucket display settings: kinds allow-list, structure, loading, layout).
12
+ *
13
+ * TWO PRESENTATIONS, ONE ANATOMY (the user's ask): `variant="drawer"` is a
14
+ * right-anchored sheet over a scrim, `variant="overlay"` the same panel
15
+ * centred. Only the shell differs; header · intro · sections · footer are the
16
+ * same nodes in both. Neither shell is built here — the drawer IS ShellDrawer
17
+ * (scrim, Escape, focus trap, scroll lock, focus return, the × control) and
18
+ * the overlay IS FullscreenOverlay (scrim, Escape, backdrop dismiss, scroll
19
+ * lock, the corner ×). The source had none of that: scrim click was the only
20
+ * exit, no Escape, no trap, no lock, and a hand-typed `rgba(0,0,0,0.6)` scrim
21
+ * where the DS already owned one.
22
+ *
23
+ * The CONTROLS are the DS controls, not the source's word-buttons: a switch
24
+ * row is `ToggleSwitch`, a choice row is `SegmentedToggle` — the source's
25
+ * segmented variant was flagged by the user as the thing to fix here, not to
26
+ * copy. Sections are the DS `Section` (pass `divided` on every one and the
27
+ * hairline lands between siblings on its own).
28
+ *
29
+ * Text casing is authored at the call site (no text-transform) — the source
30
+ * mixed `Show kinds` / `on` / `grid` in one panel; write them how they should read.
31
+ *
32
+ * @param {boolean} open mounted and shown (default true — the parent
33
+ * usually conditionally renders it)
34
+ * @param {'drawer'|'overlay'} variant presentation
35
+ * @param {number|string} width panel width (default 380)
36
+ * @param {string} title header line 1
37
+ * @param {string} subtitle header line 2 — the context, e.g. the bucket label
38
+ * @param {string} intro the one-line note under the header
39
+ * @param {Function} onClose × · scrim · Escape
40
+ * @param {ReactNode} footer the foot slot — `SettingsFooter` is the default shape
41
+ * @param {ReactNode} children the sections
42
+ */
43
+ export default function SettingsPanel({
44
+ open = true,
45
+ variant = 'drawer',
46
+ width = 380,
47
+ title,
48
+ subtitle,
49
+ intro,
50
+ onClose,
51
+ footer,
52
+ children,
53
+ className = '',
54
+ }) {
55
+ const header = (
56
+ <div className="flex min-w-0 flex-col">
57
+ {title && <span className="kol-mono-12 text-emphasis">{title}</span>}
58
+ {subtitle && <span className="kol-mono-10 text-meta">{subtitle}</span>}
59
+ </div>
60
+ )
61
+ const body = (
62
+ <>
63
+ {intro && <p className="kol-mono-10 text-meta">{intro}</p>}
64
+ <div className="flex flex-col gap-5">{children}</div>
65
+ {footer && <div className="mt-2">{footer}</div>}
66
+ </>
67
+ )
68
+
69
+ if (variant === 'overlay') {
70
+ if (!open) return null
71
+ return (
72
+ <FullscreenOverlay open onClose={onClose}>
73
+ {/* the corner × rides the sheet at --kol-spacing-3; the panel reserves
74
+ * that lane on top (the .kol-media-picker precedent) instead of the
75
+ * control moving */}
76
+ <div
77
+ className={`kol-overlay-panel flex flex-col gap-4 p-6 pt-12 ${className}`}
78
+ style={{ width: typeof width === 'number' ? `${width}px` : width, maxWidth: '100%', maxHeight: '85vh', overflowY: 'auto' }}
79
+ >
80
+ {header}
81
+ {body}
82
+ </div>
83
+ </FullscreenOverlay>
84
+ )
85
+ }
86
+
87
+ return (
88
+ <ShellDrawer open={open} onClose={onClose} side="right" width={width} header={header} className={className}>
89
+ <div className="flex flex-col gap-4">{body}</div>
90
+ </ShellDrawer>
91
+ )
92
+ }
93
+
94
+ /**
95
+ * SettingsRow — label + hint on the left, the control on the right. A GRID,
96
+ * not a flex pair: the source's `justify-between` let a long hint wrap UNDER
97
+ * the control and parked the control at the top of a two-line hint. Here the
98
+ * text column is `minmax(0, 1fr)` so it wraps inside itself, and the control
99
+ * centres on the row.
100
+ */
101
+ export function SettingsRow({ label, hint, children }) {
102
+ return (
103
+ <div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-6 py-1">
104
+ <div className="flex min-w-0 flex-col">
105
+ <span className="kol-mono-12 text-body">{label}</span>
106
+ {hint && <span className="kol-mono-10 text-meta">{hint}</span>}
107
+ </div>
108
+ <div className="shrink-0">{children}</div>
109
+ </div>
110
+ )
111
+ }
112
+
113
+ /** SettingsSwitch — the row's on/off control: the DS ToggleSwitch, bare, sm.
114
+ * `disabledHint` rides `title` so a switch that cannot act says why. */
115
+ export function SettingsSwitch({ on = false, onChange, disabled = false, disabledHint, label }) {
116
+ return (
117
+ <ToggleSwitch
118
+ size="sm"
119
+ checked={on}
120
+ onChange={onChange}
121
+ disabled={disabled}
122
+ title={disabled ? disabledHint : undefined}
123
+ aria-label={label}
124
+ />
125
+ )
126
+ }
127
+
128
+ /** SettingsChoice — the row's one-of-N control: the DS SegmentedToggle, sm.
129
+ * Options are values or `{ value, label }`. */
130
+ export function SettingsChoice({ options = [], value, onChange, ariaLabel }) {
131
+ const opts = options.map((o) => (o != null && typeof o === 'object' ? o : { value: o, label: String(o) }))
132
+ return <SegmentedToggle size="sm" value={value} onChange={onChange} options={opts} ariaLabel={ariaLabel} />
133
+ }
134
+
135
+ /* The toggle-chip pattern — `.kol-control` sm, filled when on, meta ink when
136
+ * off (ViewToggle's text variant wears the same string; MediaLibrary's kind
137
+ * chips too). ponytail: a `ControlChip` atom is the upgrade if a third
138
+ * consumer types this. */
139
+ export const CHIP_CLS = 'kol-control kol-control-sm kol-mono-12'
140
+ export const chipCls = (on) => `${CHIP_CLS} ${on ? 'kol-control--filled' : 'text-meta hover:text-emphasis'}`
141
+
142
+ /**
143
+ * SettingsChipRow — a wrap of toggle chips with optional counts (an
144
+ * allow-list: every chip sets a default, never a gate).
145
+ * @param {Array} options [{ value, label, count? }]
146
+ * @param {Array|Set} selected the values that are on
147
+ * @param {Function} onToggle (value) => void
148
+ */
149
+ export function SettingsChipRow({ options = [], selected = [], onToggle }) {
150
+ const on = selected instanceof Set ? selected : new Set(selected)
151
+ return (
152
+ <div className="flex flex-wrap gap-1">
153
+ {options.map((o) => (
154
+ <button
155
+ key={String(o.value)}
156
+ type="button"
157
+ aria-pressed={on.has(o.value)}
158
+ onClick={() => onToggle?.(o.value)}
159
+ className={chipCls(on.has(o.value))}
160
+ >
161
+ {o.label ?? String(o.value)}
162
+ {o.count > 0 && <span className="text-meta"> {o.count}</span>}
163
+ </button>
164
+ ))}
165
+ </div>
166
+ )
167
+ }
168
+
169
+ /** SettingsFooter — the state word and the reset action. */
170
+ export function SettingsFooter({ customised = false, onReset, resetLabel = 'Reset to defaults' }) {
171
+ return (
172
+ <div className="flex items-center justify-between">
173
+ <span className="kol-mono-10 text-meta">{customised ? 'customised' : 'defaults'}</span>
174
+ {onReset && <Button variant="ghost" size="sm" onClick={onReset}>{resetLabel}</Button>}
175
+ </div>
176
+ )
177
+ }
@@ -12,18 +12,41 @@ import Button from '../atoms/Button.jsx'
12
12
  * typographic multiplication sign standing in for a glyph the icon set has
13
13
  * always shipped.
14
14
  */
15
+ const FOCUSABLE =
16
+ 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
17
+
15
18
  export default function FullscreenOverlay({ open, onClose, closeButton = true, children }) {
16
19
  const sheetRef = useRef(null)
17
20
 
21
+ /* Escape closes; Tab is TRAPPED in the sheet (SettingsPanel, 2026-08-26 —
22
+ * the same trap ShellDrawer carries; before this a Tab from the overlay
23
+ * walked into the page underneath). Focus moves into the sheet on open and
24
+ * back to the opener on close. */
18
25
  useEffect(() => {
19
26
  if (!open) return
20
- const onKey = (e) => { if (e.key === 'Escape') onClose?.() }
27
+ const onKey = (e) => {
28
+ if (e.key === 'Escape') { onClose?.(); return }
29
+ if (e.key !== 'Tab') return
30
+ const sheet = sheetRef.current
31
+ if (!sheet) return
32
+ const nodes = sheet.querySelectorAll(FOCUSABLE)
33
+ if (!nodes.length) { e.preventDefault(); sheet.focus(); return }
34
+ const first = nodes[0]
35
+ const last = nodes[nodes.length - 1]
36
+ const active = document.activeElement
37
+ if (!sheet.contains(active)) { e.preventDefault(); first.focus() }
38
+ else if (e.shiftKey && (active === first || active === sheet)) { e.preventDefault(); last.focus() }
39
+ else if (!e.shiftKey && active === last) { e.preventDefault(); first.focus() }
40
+ }
21
41
  document.addEventListener('keydown', onKey)
22
42
  const prev = document.body.style.overflow
23
43
  document.body.style.overflow = 'hidden'
44
+ const prevFocus = document.activeElement
45
+ sheetRef.current?.focus()
24
46
  return () => {
25
47
  document.removeEventListener('keydown', onKey)
26
48
  document.body.style.overflow = prev
49
+ if (prevFocus instanceof HTMLElement) prevFocus.focus()
27
50
  }
28
51
  }, [open, onClose])
29
52
 
@@ -40,7 +63,7 @@ export default function FullscreenOverlay({ open, onClose, closeButton = true, c
40
63
 
41
64
  return (
42
65
  <div className="kol-overlay" role="dialog" aria-modal="true" onMouseDown={onBackdropClick}>
43
- <div ref={sheetRef} className="kol-overlay-sheet">
66
+ <div ref={sheetRef} tabIndex={-1} className="kol-overlay-sheet outline-none">
44
67
  {closeButton && (
45
68
  <Button
46
69
  variant="outline"