@kolkrabbi/kol-component 0.38.0 → 0.40.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,9 +1,10 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-component",
3
- "version": "0.38.0",
3
+ "version": "0.40.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",
7
+ "sideEffects": false,
7
8
  "main": "./src/index.js",
8
9
  "module": "./src/index.js",
9
10
  "exports": {
@@ -29,7 +30,7 @@
29
30
  "@floating-ui/react": "^0.27.19",
30
31
  "embla-carousel-react": "^8.6.0",
31
32
  "react-syntax-highlighter": "^16.1.1",
32
- "@kolkrabbi/kol-icons": "^0.15.0"
33
+ "@kolkrabbi/kol-icons": "^0.16.0"
33
34
  },
34
35
  "peerDependencies": {
35
36
  "framer-motion": "^12.0.0",
@@ -1,3 +1,5 @@
1
+ import { useState } from 'react'
2
+
1
3
  const SIZE_MAP = {
2
4
  sm: 'w-8 h-8 kol-helper-12',
3
5
  md: 'w-10 h-10 kol-helper-14',
@@ -5,11 +7,41 @@ const SIZE_MAP = {
5
7
  xl: 'w-24 h-24 kol-helper-20',
6
8
  }
7
9
 
8
- export default function Avatar({ initial, size = 'sm', className = '' }) {
10
+ /**
11
+ * Avatar — the initials disc, or a photo at the same geometry.
12
+ *
13
+ * `src` was added when the ArticleHeader reconciliation (2026-08-15) found the
14
+ * consumer hand-rolling `<img className="w-12 h-12 rounded-full object-cover">`
15
+ * beside a grey-circle fallback, because the atom did initials only. The size
16
+ * ladder is the atom's to own — a caller writing its own w-/h- pair is how a
17
+ * fifth avatar size gets invented. Falls back to the initial on a broken src,
18
+ * so a dead photo URL degrades to the disc instead of a torn-image glyph.
19
+ *
20
+ * @param {string} initial glyph shown when there is no photo
21
+ * @param {string} [src] resolved image src — the consumer resolves it, this
22
+ * atom never builds a URL
23
+ * @param {string} [alt=''] photo alt text
24
+ * @param {'sm'|'md'|'lg'|'xl'} [size='sm']
25
+ */
26
+ export default function Avatar({ initial, src, alt = '', size = 'sm', className = '' }) {
9
27
  const sizeCls = SIZE_MAP[size] ?? SIZE_MAP.sm
28
+ const [failed, setFailed] = useState(false)
29
+ const shell = `kol-avatar rounded-full bg-surface-secondary shrink-0 ${sizeCls} ${className}`
30
+
31
+ if (src && !failed) {
32
+ return (
33
+ <img
34
+ src={src}
35
+ alt={alt}
36
+ className={`${shell} object-cover`}
37
+ onError={() => setFailed(true)}
38
+ />
39
+ )
40
+ }
41
+
10
42
  return (
11
43
  <span
12
- className={`kol-avatar inline-flex items-center justify-center rounded-full bg-surface-secondary text-emphasis font-narrow font-semibold shrink-0 ${sizeCls} ${className}`}
44
+ className={`${shell} inline-flex items-center justify-center text-emphasis font-narrow font-semibold`}
13
45
  >
14
46
  {initial}
15
47
  </span>
@@ -57,11 +57,152 @@ function formatSize(bytes) {
57
57
  const fileName = (key) => key.slice(key.lastIndexOf('/') + 1)
58
58
  const folderOf = (key) => key.slice(0, key.lastIndexOf('/') + 1)
59
59
 
60
- /* A video element with no poster paints an empty box until played, and 222 of
61
- * the reference bucket's 433 objects are video a time fragment makes the
62
- * browser seek and paint frame one instead. */
60
+ /* A video with neither poster nor sibling still paints an empty box until
61
+ * played a time fragment makes the browser seek and paint frame one. Only
62
+ * the fallback now: `pairPosters` finds a real poster where one exists. */
63
63
  const posterSrc = (url) => `${url}#t=0.1`
64
64
 
65
+ /* ── Kind, and the four rules a real bucket forces ─────────────────────────
66
+ * Every rule below exists because the unfiltered list was unusable over the
67
+ * live 3443-object bucket, not because it read tidier. Measurements from
68
+ * lobby/media-library-non-av-blindness (kol-r2b2, 2026-08-15), which tested
69
+ * each one against that data. */
70
+
71
+ /* B2 and R2 hand back `application/octet-stream` for .json, .pgn, .m3u8 and
72
+ * .woff2, so contentType cannot be the primary signal — extension wins, the
73
+ * header is the fallback. `.ts` is an HLS segment here, never TypeScript: this
74
+ * reads object buckets, not source trees. */
75
+ const EXT_KIND = {
76
+ jpg: 'image', jpeg: 'image', png: 'image', gif: 'image', webp: 'image',
77
+ avif: 'image', svg: 'image', bmp: 'image', ico: 'image', tif: 'image',
78
+ tiff: 'image', heic: 'image',
79
+ mp4: 'video', mov: 'video', webm: 'video', m4v: 'video', avi: 'video',
80
+ mkv: 'video', ts: 'video',
81
+ mp3: 'audio', wav: 'audio', ogg: 'audio', flac: 'audio', aac: 'audio',
82
+ m4a: 'audio', aiff: 'audio',
83
+ m3u8: 'playlist',
84
+ json: 'text', yaml: 'text', yml: 'text', csv: 'text', txt: 'text',
85
+ md: 'text', pgn: 'text', xml: 'text', srt: 'text', vtt: 'text',
86
+ js: 'code', mjs: 'code', cjs: 'code', jsx: 'code', tsx: 'code',
87
+ css: 'code', html: 'code', py: 'code', sh: 'code',
88
+ woff: 'font', woff2: 'font', ttf: 'font', otf: 'font', eot: 'font',
89
+ zip: 'archive', tar: 'archive', gz: 'archive', rar: 'archive', '7z': 'archive',
90
+ pdf: 'text',
91
+ }
92
+
93
+ /* One per folder, in the way of everything — hidden, never silently dropped:
94
+ * the count is reported at the foot. */
95
+ const SYSTEM_NAMES = new Set(['.DS_Store', '.bzEmpty', 'Thumbs.db', 'desktop.ini', '.gitkeep'])
96
+
97
+ const extOf = (key) => {
98
+ const base = fileName(key)
99
+ const dot = base.lastIndexOf('.')
100
+ return dot > 0 ? base.slice(dot + 1).toLowerCase() : ''
101
+ }
102
+
103
+ function kindOf(o) {
104
+ if (SYSTEM_NAMES.has(fileName(o.key))) return 'system'
105
+ const byExt = EXT_KIND[extOf(o.key)]
106
+ if (byExt) return byExt
107
+ if (isImage(o.contentType)) return 'image'
108
+ if (isVideo(o.contentType)) return 'video'
109
+ if (o.contentType?.startsWith('audio/')) return 'audio'
110
+ if (o.contentType?.startsWith('text/')) return 'text'
111
+ return 'other'
112
+ }
113
+
114
+ /* `accept` WIDENS, it never gates. 'all' — the default, and what every browse
115
+ * consumer passes — means EVERYTHING. It used to mean "image or video", which
116
+ * discarded 332 objects of the reference bucket before anything downstream
117
+ * could see them; a brand book embedding this saw a library quietly missing
118
+ * every stream it held. A picker asks for what it can pick: `['image','video']`. */
119
+ function acceptsKind(accept) {
120
+ if (!accept || accept === 'all') return () => true
121
+ const wanted = new Set(Array.isArray(accept) ? accept : [accept])
122
+ return (o) => wanted.has(o.kind)
123
+ }
124
+
125
+ /* 2012 `segment_NNN.ts` files are ONE stream. Counted raw they took the
126
+ * bucket's video tally to 2051 instead of 39, and filled the grid with 2.7 GB
127
+ * of unopenable fragments. Fold per folder onto the first segment — a real key,
128
+ * so the row still resolves and still sits in its own folder. */
129
+ const SEGMENT = /segment[_-]?\d+\.ts$/i
130
+
131
+ function foldHlsSegments(list) {
132
+ const streams = new Map()
133
+ const rest = []
134
+ for (const o of list) {
135
+ if (!SEGMENT.test(fileName(o.key))) { rest.push(o); continue }
136
+ const dir = folderOf(o.key)
137
+ const seen = streams.get(dir)
138
+ if (seen) { seen.count += 1; seen.size += o.size ?? 0 }
139
+ else streams.set(dir, { first: o, count: 1, size: o.size ?? 0 })
140
+ }
141
+ for (const [dir, s] of streams) {
142
+ rest.push({
143
+ ...s.first,
144
+ displayName: `${fileName(dir.slice(0, -1))} · ${s.count} segments`,
145
+ size: s.size,
146
+ segments: s.count,
147
+ })
148
+ }
149
+ return rest
150
+ }
151
+
152
+ /* Art prints ship as one picture in four widths (`name-566.jpg` … `-2840.jpg`).
153
+ * Left alone that is 604 rows for 197 pictures, and the grid pulls the 2840px
154
+ * file to paint a 200px tile — 718 KB where 27 KB does. Collapse each set to
155
+ * one row: `key` (what the thumb loads) is the SMALLEST, `fullKey` (what
156
+ * download hands over) the largest. Guards: images only, width ≥ 100 so
157
+ * `2017-03.json` is not read as a variant, and sets of ≥2 only. */
158
+ const VARIANT = /^(.+)-(\d{2,5})$/
159
+
160
+ function foldResolutionSets(list) {
161
+ const sets = new Map()
162
+ const rest = []
163
+
164
+ for (const o of list) {
165
+ const base = fileName(o.key)
166
+ const dot = base.lastIndexOf('.')
167
+ const stem = dot > 0 ? base.slice(0, dot) : base
168
+ const match = o.kind === 'image' ? VARIANT.exec(stem) : null
169
+ if (!match || Number(match[2]) < 100) { rest.push(o); continue }
170
+ const id = `${folderOf(o.key)}${match[1]}`
171
+ const set = sets.get(id)
172
+ if (set) set.push({ o, width: Number(match[2]) })
173
+ else sets.set(id, [{ o, width: Number(match[2]) }])
174
+ }
175
+
176
+ for (const [id, variants] of sets) {
177
+ if (variants.length < 2) { rest.push(variants[0].o); continue }
178
+ const byWidth = [...variants].sort((a, b) => a.width - b.width)
179
+ rest.push({
180
+ ...byWidth[0].o,
181
+ fullKey: byWidth[byWidth.length - 1].o.key,
182
+ displayName: `${fileName(id)} · ${byWidth.length} sizes`,
183
+ size: variants.reduce((n, v) => n + (v.o.size ?? 0), 0),
184
+ variants: byWidth.map((v) => v.o.key),
185
+ })
186
+ }
187
+ return rest
188
+ }
189
+
190
+ /* Every video in the vault ships a sibling `<name>.png`. Using it as the poster
191
+ * means `preload="none"` still paints a frame; without one the browser fetches
192
+ * the video itself just to show frame one, which over a 20.4 GB bucket is the
193
+ * single most expensive thing this component does. */
194
+ const POSTER_EXT = ['png', 'jpg', 'jpeg', 'webp']
195
+
196
+ function pairPosters(list) {
197
+ const images = new Set(list.filter((o) => o.kind === 'image').map((o) => o.key))
198
+ return list.map((o) => {
199
+ if (o.kind !== 'video') return o
200
+ const stem = o.key.slice(0, o.key.lastIndexOf('.'))
201
+ const poster = POSTER_EXT.map((e) => `${stem}.${e}`).find((k) => images.has(k))
202
+ return poster ? { ...o, poster } : o
203
+ })
204
+ }
205
+
65
206
  /**
66
207
  * Flatten the bucket's flat key list into ONE ordered row list, folders and
67
208
  * files interleaved, honouring which folders are open. The list endpoint
@@ -104,7 +245,7 @@ function buildRows(objects, expanded, sort) {
104
245
  if (expanded.has(f)) walk(f, depth + 1)
105
246
  }
106
247
  for (const o of sorted(childrenOf.get(prefix) ?? [])) {
107
- rows.push({ type: 'file', depth, ...o, displayKey: fileName(o.key) })
248
+ rows.push({ type: 'file', depth, ...o, displayKey: o.displayName ?? fileName(o.key) })
108
249
  }
109
250
  }
110
251
  walk('', 0)
@@ -116,7 +257,10 @@ function buildRows(objects, expanded, sort) {
116
257
  * derivation, the open-folder set and the sort key.
117
258
  *
118
259
  * @param {object} client `{ listMedia, mediaUrl, proxied? }` — required
119
- * @param {string} accept 'image' | 'video' | 'all' which types are listed
260
+ * @param {string|string[]} accept 'all' (default) = everything · one kind ·
261
+ * or an allow-list, `['image','video']`, which is what a picker wants.
262
+ * Kinds: image · video · audio · text · code · playlist · font · archive ·
263
+ * other. Browsing never filters by default.
120
264
  */
121
265
  export function MediaLibraryProvider({ client, accept = 'all', children }) {
122
266
  const [objects, setObjects] = useState([])
@@ -148,16 +292,28 @@ export function MediaLibraryProvider({ client, accept = 'all', children }) {
148
292
  })
149
293
 
150
294
  const value = useMemo(() => {
151
- const wanted = (o) =>
152
- accept === 'video' ? isVideo(o.contentType)
153
- : accept === 'image' ? isImage(o.contentType)
154
- : isImage(o.contentType) || isVideo(o.contentType)
295
+ const annotated = objects.map((o) => ({ ...o, kind: kindOf(o) }))
296
+ const systemCount = annotated.reduce((n, o) => n + (o.kind === 'system' ? 1 : 0), 0)
297
+
298
+ /* Fold before pairing: a poster must be matched against real image keys,
299
+ * and resolution sets must be collapsed after that or the poster's own
300
+ * width suffix would swallow it. */
301
+ const visible = foldResolutionSets(
302
+ pairPosters(foldHlsSegments(annotated.filter((o) => o.kind !== 'system'))),
303
+ )
304
+
305
+ const kept = visible.filter(acceptsKind(accept))
306
+ /* 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')
155
309
 
156
- const kept = objects.filter(wanted)
157
310
  return {
158
311
  objects: kept,
159
312
  rows: buildRows(kept, expanded, sort),
160
313
  files: kept,
314
+ viewable,
315
+ kinds: [...new Set(kept.map((o) => o.kind))].sort(),
316
+ systemCount,
161
317
  expanded,
162
318
  toggleFolder,
163
319
  sort,
@@ -224,23 +380,48 @@ function useCopy() {
224
380
  * The loading strategy is deliberately unchanged (lobby/MediaLibraryVideoFallback:
225
381
  * both candidate strategies failed the same way headless, so that measurement
226
382
  * discriminates nothing). This layer needs no decoder to be correct. */
383
+ const KIND_ICON = {
384
+ audio: 'frequency',
385
+ playlist: 'video',
386
+ text: 'file',
387
+ code: 'code',
388
+ font: 'type',
389
+ archive: 'layers',
390
+ other: 'file',
391
+ }
392
+
227
393
  function Thumb({ row, mediaUrl }) {
228
394
  const [painted, setPainted] = useState(false)
229
395
 
230
- if (!isVideo(row.contentType)) {
396
+ if (row.kind === 'image') {
231
397
  return <img src={mediaUrl(row.key)} alt="" loading="lazy" className="w-full h-full object-cover" />
232
398
  }
233
399
 
400
+ /* Everything the browser cannot paint gets the same treatment a video's
401
+ * resting state gets — a kind glyph and the filename, rather than an <img>
402
+ * pointed at a .json and the broken-image chrome that follows. */
403
+ if (row.kind !== 'video') {
404
+ return (
405
+ <div className="kol-media-thumb">
406
+ <span className="kol-media-thumb-fallback" data-painted={false}>
407
+ <Icon name={KIND_ICON[row.kind] ?? 'file'} size={20} />
408
+ <span className="kol-mono-12">{fileName(row.key)}</span>
409
+ </span>
410
+ </div>
411
+ )
412
+ }
413
+
234
414
  return (
235
415
  <div className="kol-media-thumb">
236
- <span className="kol-media-thumb-fallback" data-painted={painted}>
416
+ <span className="kol-media-thumb-fallback" data-painted={painted || !!row.poster}>
237
417
  <Icon name="play" size={20} />
238
418
  <span className="kol-mono-12">{fileName(row.key)}</span>
239
419
  </span>
240
420
  <video
241
- src={posterSrc(mediaUrl(row.key))}
421
+ src={row.poster ? mediaUrl(row.key) : posterSrc(mediaUrl(row.key))}
422
+ poster={row.poster ? mediaUrl(row.poster) : undefined}
242
423
  muted
243
- preload="metadata"
424
+ preload={row.poster ? 'none' : 'metadata'}
244
425
  onLoadedData={() => setPainted(true)}
245
426
  className="relative w-full h-full object-cover"
246
427
  />
@@ -251,7 +432,7 @@ function Thumb({ row, mediaUrl }) {
251
432
  /* Folders, then the tiles or rows. Shared by both views — the modal shell and
252
433
  * the pick action are the ONLY differences between them. */
253
434
  function LibraryBody({ rows, viewMode, onOpen, onPick }) {
254
- const { expanded, toggleFolder, mediaUrl, loading, error } = useMediaLibrary()
435
+ const { expanded, toggleFolder, mediaUrl, loading, error, viewable } = useMediaLibrary()
255
436
  const [copied, copy] = useCopy()
256
437
 
257
438
  if (error) return <p className="kol-helper-12 text-ui-error">Couldn’t load: {error}</p>
@@ -259,13 +440,21 @@ function LibraryBody({ rows, viewMode, onOpen, onPick }) {
259
440
  if (rows.length === 0) return <p className="kol-helper-12 text-meta">Nothing here.</p>
260
441
 
261
442
  const files = rows.filter((r) => r.type === 'file')
262
- const indexOfFile = (row) => files.findIndex((f) => f.key === row.key)
443
+ /* Index into `viewable`, which is what the lightbox pages — indexing into the
444
+ * filtered rows meant a search narrowing the grid opened the wrong file. */
445
+ const openerFor = (row) => {
446
+ const i = viewable.findIndex((f) => f.key === row.key)
447
+ return i < 0 ? undefined : () => onOpen(i)
448
+ }
449
+
450
+ /* Copy hands over the full-size variant, not the thumbnail the tile loaded. */
451
+ const urlFor = (row) => mediaUrl(row.fullKey ?? row.key)
263
452
 
264
453
  const actionsFor = (row) => (
265
454
  <div className="flex items-center gap-2">
266
455
  {onPick && <Button size="sm" onClick={() => onPick(row)}>Use</Button>}
267
- <Button variant="secondary" size="sm" onClick={() => copy(mediaUrl(row.key))}>
268
- {copied === mediaUrl(row.key) ? 'Copied' : 'Copy URL'}
456
+ <Button variant="secondary" size="sm" onClick={() => copy(urlFor(row))}>
457
+ {copied === urlFor(row) ? 'Copied' : 'Copy URL'}
269
458
  </Button>
270
459
  </div>
271
460
  )
@@ -281,9 +470,13 @@ function LibraryBody({ rows, viewMode, onOpen, onPick }) {
281
470
  <MediaRow
282
471
  thumb={<Thumb row={row} mediaUrl={mediaUrl} />}
283
472
  name={
284
- <button type="button" className="kol-mono-12 text-emphasis" onClick={() => onOpen(indexOfFile(row))}>
285
- {row.displayKey}
286
- </button>
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
+ )
287
480
  }
288
481
  date={row.uploaded ? String(row.uploaded).slice(0, 10) : ''}
289
482
  size={formatSize(row.size)}
@@ -304,17 +497,21 @@ function LibraryBody({ rows, viewMode, onOpen, onPick }) {
304
497
  ))}
305
498
  </ul>
306
499
  <ul className="kol-media-grid">
307
- {files.map((row, i) => (
500
+ {files.map((row) => (
308
501
  <MediaCard
309
502
  key={row.key}
310
503
  thumb={
311
- <div className="w-full h-full cursor-pointer" onClick={() => onOpen(i)}>
504
+ <div
505
+ className={openerFor(row) ? 'w-full h-full cursor-pointer' : 'w-full h-full'}
506
+ onClick={openerFor(row)}
507
+ >
312
508
  <Thumb row={row} mediaUrl={mediaUrl} />
313
509
  </div>
314
510
  }
315
511
  name={<p className="kol-mono-12 text-emphasis truncate">{row.displayKey}</p>}
316
512
  meta={`${formatSize(row.size)}${row.uploaded ? ` · ${String(row.uploaded).slice(0, 10)}` : ''}`}
317
- downloadHref={mediaUrl(row.key)}
513
+ /* The set's largest variant, not the thumbnail the tile painted. */
514
+ downloadHref={mediaUrl(row.fullKey ?? row.key)}
318
515
  actions={actionsFor(row)}
319
516
  />
320
517
  ))}
@@ -323,8 +520,10 @@ function LibraryBody({ rows, viewMode, onOpen, onPick }) {
323
520
  )
324
521
  }
325
522
 
326
- /* Finder puts the path at the window FOOT, not stacked above the content. */
327
- function PathBar({ rows }) {
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 }) {
328
527
  const open = rows.filter((r) => r.type === 'folder' && r.depth > 0)
329
528
  const trail = open.length ? open[open.length - 1].key.replace(/\/$/, '').split('/') : []
330
529
  return (
@@ -337,6 +536,11 @@ function PathBar({ rows }) {
337
536
  <span className="kol-helper-12 text-meta">{seg}</span>
338
537
  </span>
339
538
  ))}
539
+ {systemCount > 0 && (
540
+ <span className="kol-helper-12 text-meta ms-auto">
541
+ {systemCount} system file{systemCount === 1 ? '' : 's'} hidden
542
+ </span>
543
+ )}
340
544
  </div>
341
545
  )
342
546
  }
@@ -359,14 +563,14 @@ const SORTS = [
359
563
  * view toggle and the N-of-M count. It was hand-rolled as a static <Input> on
360
564
  * the first pass while this organism sat one import away. */
361
565
  function LibraryChrome({ onOpen, onPick }) {
362
- const { rows, sort, setSort } = useMediaLibrary()
566
+ const { rows, sort, setSort, kinds, systemCount } = useMediaLibrary()
363
567
  const [viewMode, setViewMode] = useState('grid')
364
568
 
365
569
  const items = useMemo(
366
570
  () => rows.map((r) => ({
367
571
  ...r,
368
572
  name: r.type === 'folder' ? r.label : r.displayKey,
369
- kind: r.type === 'folder' ? 'folder' : isVideo(r.contentType) ? 'video' : 'image',
573
+ kind: r.type === 'folder' ? 'folder' : r.kind,
370
574
  })),
371
575
  [rows],
372
576
  )
@@ -383,7 +587,9 @@ function LibraryChrome({ onOpen, onPick }) {
383
587
  onViewModeChange={setViewMode}
384
588
  viewModeOptions={VIEW_OPTIONS}
385
589
  mutuallyExclusiveFilters={['kind']}
386
- filterGroups={[{ label: 'Kind', key: 'kind', values: ['image', 'video', 'folder'] }]}
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
+ filterGroups={[{ label: 'Kind', key: 'kind', values: ['folder', ...kinds] }]}
387
593
  headerActions={
388
594
  <SegmentedToggle
389
595
  size="sm"
@@ -397,7 +603,7 @@ function LibraryChrome({ onOpen, onPick }) {
397
603
  <LibraryBody rows={filtered} viewMode={mode} onOpen={onOpen} onPick={onPick} />
398
604
  )}
399
605
  />
400
- <PathBar rows={rows} />
606
+ <PathBar rows={rows} systemCount={systemCount} />
401
607
  </>
402
608
  )
403
609
  }
@@ -405,14 +611,15 @@ function LibraryChrome({ onOpen, onPick }) {
405
611
  /* The lightbox is MediaViewer — the DS already has ONE fullscreen paged viewer
406
612
  * and this is not a second one. Use / Copy URL ride its `actions` slot. */
407
613
  function LibraryViewer({ index, onIndexChange, onClose, onPick }) {
408
- const { files, mediaUrl } = useMediaLibrary()
614
+ const { viewable, mediaUrl } = useMediaLibrary()
409
615
  const [copied, copy] = useCopy()
410
616
 
411
- const media = files.map((o) => ({
412
- url: mediaUrl(o.key),
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),
413
620
  alt: fileName(o.key),
414
- kind: isVideo(o.contentType) ? 'video' : 'image',
415
- caption: `${fileName(o.key)} · ${formatSize(o.size)}`,
621
+ kind: o.kind === 'video' ? 'video' : 'image',
622
+ caption: `${o.displayName ?? fileName(o.key)} · ${formatSize(o.size)}`,
416
623
  }))
417
624
 
418
625
  return (
@@ -424,7 +631,7 @@ function LibraryViewer({ index, onIndexChange, onClose, onPick }) {
424
631
  onClose={onClose}
425
632
  actions={(item, i) => (
426
633
  <>
427
- {onPick && <Button size="sm" onClick={() => onPick(files[i])}>Use</Button>}
634
+ {onPick && <Button size="sm" onClick={() => onPick(viewable[i])}>Use</Button>}
428
635
  <Button variant="secondary" size="sm" onClick={() => copy(item.url)}>
429
636
  {copied === item.url ? 'Copied' : 'Copy URL'}
430
637
  </Button>
@@ -435,11 +642,11 @@ function LibraryViewer({ index, onIndexChange, onClose, onPick }) {
435
642
  }
436
643
 
437
644
  function PickerShell({ onClose, onPick }) {
438
- const { files, mediaUrl } = useMediaLibrary()
645
+ const { viewable, mediaUrl } = useMediaLibrary()
439
646
  const [viewerIndex, setViewerIndex] = useState(null)
440
647
 
441
648
  const pick = (o) => {
442
- onPick?.(mediaUrl(o.key), { contentType: o.contentType })
649
+ onPick?.(mediaUrl(o.fullKey ?? o.key), { contentType: o.contentType, kind: o.kind })
443
650
  onClose?.()
444
651
  }
445
652
 
@@ -454,7 +661,7 @@ function PickerShell({ onClose, onPick }) {
454
661
  </div>
455
662
  </FullscreenOverlay>
456
663
 
457
- {viewerIndex !== null && files[viewerIndex] && (
664
+ {viewerIndex !== null && viewable[viewerIndex] && (
458
665
  <LibraryViewer
459
666
  index={viewerIndex}
460
667
  onIndexChange={setViewerIndex}
@@ -479,7 +686,8 @@ function PickerShell({ onClose, onPick }) {
479
686
  * @param {string} variant 'page' (in-flow, fills its box) | 'modal' (overlay)
480
687
  * @param {boolean} open modal only — mounts the overlay
481
688
  * @param {object} client `{ listMedia, mediaUrl, proxied? }`; omit inside a provider
482
- * @param {string} accept 'image' | 'video' | 'all'
689
+ * @param {string|string[]} accept 'all' (default) = everything · one kind · an
690
+ * allow-list `['image','video']`
483
691
  * @param {Function} onClose modal only — Esc, backdrop, close button
484
692
  * @param {Function} onSelect `(url, { contentType })`. In `modal` it also closes.
485
693
  */
@@ -514,16 +722,18 @@ export function MediaPicker({ open, client, accept = 'all', onClose, onPick }) {
514
722
  }
515
723
 
516
724
  function BrowserShell({ onSelect }) {
517
- const { files, mediaUrl } = useMediaLibrary()
725
+ const { viewable, mediaUrl } = useMediaLibrary()
518
726
  const [viewerIndex, setViewerIndex] = useState(null)
519
727
 
520
- const pick = onSelect ? (o) => onSelect(mediaUrl(o.key), { contentType: o.contentType }) : undefined
728
+ const pick = onSelect
729
+ ? (o) => onSelect(mediaUrl(o.fullKey ?? o.key), { contentType: o.contentType, kind: o.kind })
730
+ : undefined
521
731
 
522
732
  return (
523
733
  <div className="kol-media-browser">
524
734
  <LibraryChrome onOpen={setViewerIndex} onPick={pick} />
525
735
 
526
- {viewerIndex !== null && files[viewerIndex] && (
736
+ {viewerIndex !== null && viewable[viewerIndex] && (
527
737
  <LibraryViewer
528
738
  index={viewerIndex}
529
739
  onIndexChange={setViewerIndex}