@kolkrabbi/kol-component 0.240.0 → 0.242.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.240.0",
3
+ "version": "0.242.0",
4
4
  "description": "The core component library",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -26,8 +26,8 @@
26
26
  "embla-carousel-react": "^8.6.0",
27
27
  "pdfjs-dist": "^6.3.289",
28
28
  "react-syntax-highlighter": "^16.1.1",
29
- "@kolkrabbi/kol-search": "^0.3.0",
30
- "@kolkrabbi/kol-markdown": "^0.1.3"
29
+ "@kolkrabbi/kol-markdown": "^0.1.3",
30
+ "@kolkrabbi/kol-search": "^0.3.0"
31
31
  },
32
32
  "peerDependencies": {
33
33
  "@kolkrabbi/kol-icons": ">=0.22.0",
@@ -3,8 +3,8 @@ import { Icon } from '@kolkrabbi/kol-icons'
3
3
  import ActionButton from '../molecules/ActionButton.jsx'
4
4
  import Button from '../atoms/Button.jsx'
5
5
  import Divider from '../atoms/Divider.jsx'
6
+ import Dropdown from '../molecules/Dropdown.jsx'
6
7
  import Input from '../atoms/Input.jsx'
7
- 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'
@@ -12,6 +12,7 @@ import { Tooltip } from '../utilities/Popover.jsx'
12
12
  import ContentCard from '../molecules/ContentCard.jsx'
13
13
  import ContentRow from '../molecules/ContentRow.jsx'
14
14
  import ContentFilters from './ContentFilters.jsx'
15
+ import { MenuItem, MenuDropdownItem, MenuDropdownDivider } from '../molecules/MenuItem.jsx'
15
16
  import MediaViewer from './MediaViewer.jsx'
16
17
  import { MediaLibraryBrowse, MediaLibraryLibrary } from './MediaLibraryPages.jsx'
17
18
  import MediaLibraryExplorer from './MediaLibraryExplorer.jsx'
@@ -136,6 +137,8 @@ function kindOf(o) {
136
137
  * every stream it held. A picker asks for what it can pick: `['image','video']`. */
137
138
  function acceptsKind(accept) {
138
139
  if (!accept || accept === 'all') return () => true
140
+ /* a function reads the object itself — a vector-only picker wants `.svg`, which is `image` by kind */
141
+ if (typeof accept === 'function') return accept
139
142
  const wanted = new Set(Array.isArray(accept) ? accept : [accept])
140
143
  return (o) => wanted.has(o.kind)
141
144
  }
@@ -298,6 +301,14 @@ export function MediaLibraryProvider({
298
301
  * from the flat view you just left. Derived, not an effect. */
299
302
  const [paging, setPaging] = useState({ id: null, visible: 0 })
300
303
 
304
+ /* THE STORE (2026-10-07, design-editor's picker folded into this one): a client with
305
+ * `buckets()` lists one at a time and the modal's header switches it — the browse page's
306
+ * rule, "a client with none is one bucket and no dropdown". Switching lands at the root:
307
+ * a prefix is one bucket's path. */
308
+ const buckets = useMemo(() => client?.buckets?.() ?? [], [client])
309
+ const [bucket, setBucketState] = useState(() => buckets[0]?.id ?? null)
310
+ const setBucket = (id) => { setBucketState(id); setPrefix('') }
311
+
301
312
  useEffect(() => {
302
313
  if (!client) return undefined
303
314
  let cancelled = false
@@ -305,12 +316,12 @@ export function MediaLibraryProvider({
305
316
  setLoading(true)
306
317
  setError(null)
307
318
  client
308
- .listMedia('', { signal: controller.signal })
319
+ .listMedia('', { signal: controller.signal, bucket: bucket ?? undefined })
309
320
  .then((objs) => { if (!cancelled) setObjects(objs) })
310
321
  .catch((e) => { if (!cancelled && e.name !== 'AbortError') setError(e.message) })
311
322
  .finally(() => { if (!cancelled) setLoading(false) })
312
323
  return () => { cancelled = true; controller.abort() }
313
- }, [client])
324
+ }, [client, bucket])
314
325
 
315
326
  /* click an inactive field → ascending; click the active one → flip */
316
327
  const sortBy = (by) =>
@@ -344,6 +355,10 @@ export function MediaLibraryProvider({
344
355
  const kindCounts = files.reduce((acc, o) => { acc[o.kind] = (acc[o.kind] || 0) + 1; return acc }, {})
345
356
  const kindsPresent = Object.keys(kindCounts).sort().map((k) => ({ value: k, label: KIND_LABEL[k] ?? k, count: kindCounts[k] }))
346
357
 
358
+ /* `q` is the paging key's and the stats' view of the search — the engine swap (kol-search,
359
+ * 2026-09-29) dropped its definition and the modal threw on open; found 2026-10-07 when the
360
+ * editor's doors moved onto it, since no consumer had opened this variant in between. */
361
+ const q = search.trim().toLowerCase()
347
362
  const filtered = filterMedia(files.filter((o) => kinds.size === 0 || kinds.has(o.kind)), search, { nameOf: (o) => o.displayKey })
348
363
  const sorted = sortFiles(filtered, sort)
349
364
 
@@ -396,10 +411,13 @@ export function MediaLibraryProvider({
396
411
  viewable,
397
412
  loading,
398
413
  error,
399
- mediaUrl: client?.mediaUrl ?? ((key) => key),
414
+ bucket,
415
+ buckets,
416
+ setBucket,
417
+ mediaUrl: client?.mediaUrl ? (key) => client.mediaUrl(key, bucket ?? undefined) : (key) => key,
400
418
  proxied: client?.proxied ?? ((url) => url),
401
419
  }
402
- }, [objects, prefix, flat, sort, search, kinds, paging, pageSize, loading, error, accept, client])
420
+ }, [objects, prefix, flat, sort, search, kinds, paging, pageSize, loading, error, accept, client, bucket, buckets])
403
421
 
404
422
  return <MediaLibraryContext.Provider value={value}>{children}</MediaLibraryContext.Provider>
405
423
  }
@@ -880,7 +898,7 @@ function PickerBody({ items, viewMode, onOpen, onPick }) {
880
898
  /* The picker's chrome — ContentFilters owns the animated search, the kind
881
899
  * filter, the view toggle and the N-of-M count; sort rides its header slot. */
882
900
  function LibraryChrome({ onOpen, onPick }) {
883
- const { folders, sorted, sort, sortBy } = useMediaLibrary()
901
+ const { folders, sorted, sort, sortBy, bucket, buckets, setBucket } = useMediaLibrary()
884
902
  const [viewMode, setViewMode] = useState('grid')
885
903
 
886
904
  const items = useMemo(
@@ -891,6 +909,11 @@ function LibraryChrome({ onOpen, onPick }) {
891
909
  [folders, sorted],
892
910
  )
893
911
  const kinds = useMemo(() => [...new Set(sorted.map((o) => o.kind))].sort(), [sorted])
912
+ /* THE STORE — the header at `md+`, a row above the list on a phone. `cls` on the control rather
913
+ * than a wrapper: a `display: contents` wrapper lost to the phone hide in one host. */
914
+ const store = (cls = '') => buckets.length > 1 && (
915
+ <Dropdown size="sm" className={`min-w-0 max-w-[45vw] md:w-48 md:max-w-none ${cls}`.trim()} options={buckets.map((b) => ({ value: b.id, label: b.label }))} value={bucket} onChange={setBucket} aria-label="Store" />
916
+ )
894
917
 
895
918
  return (
896
919
  <>
@@ -900,16 +923,40 @@ function LibraryChrome({ onOpen, onPick }) {
900
923
  titleIcon="folder"
901
924
  totalCount={items.length}
902
925
  searchKeys={['name']}
903
- viewMode={viewMode}
904
- onViewModeChange={setViewMode}
905
- viewModeOptions={VIEW_OPTIONS}
906
926
  mutuallyExclusiveFilters={['kind']}
907
927
  filterGroups={[{ label: 'Kind', key: 'kind', values: ['folder', ...kinds] }]}
908
- headerActions={
909
- <SegmentedToggle size="sm" value={sort.by} onChange={sortBy} options={SORT_OPTIONS} ariaLabel="Sort by" />
910
- }
911
- renderItem={(filtered, mode) => (
912
- <PickerBody items={filtered} viewMode={mode} onOpen={onOpen} onPick={onPick} />
928
+ /* THE WALL'S ROW (2026-10-08, one header for the family): the view pair as icons on the row's
929
+ * trailing slot, the sort as the row below at `md+` — what `MediaLibraryLibrary` draws — and
930
+ * below `md` both fold into one `···`, the browse page's fold. It wore GRID · LIST as text and
931
+ * four sort cells in the header. The store (when the client has more than one) rides the
932
+ * header at `md+` and is a row above the list on a phone. Decided on the recommendation. */
933
+ headerActions={store('max-md:hidden')}
934
+ trailingActions={(
935
+ <>
936
+ <div className="hidden md:flex items-center gap-6"><ViewToggle viewMode={viewMode} onViewChange={setViewMode} variant="icon" options={VIEW_OPTIONS} /></div>
937
+ <div className="md:hidden">
938
+ <MenuItem label={<Icon name="more" size={16} />} caret={false} align="end" size="sm" buttonClassName="shrink-0 px-2">
939
+ {({ close }) => (
940
+ <div className="py-1 w-[200px]">
941
+ {VIEW_OPTIONS.map((opt) => (
942
+ <MenuDropdownItem key={opt.value} onClick={() => { setViewMode(opt.value); close() }} shortcut={viewMode === opt.value ? <Icon name="check" size={11} /> : undefined}>{opt.label}</MenuDropdownItem>
943
+ ))}
944
+ <MenuDropdownDivider />
945
+ {SORT_OPTIONS.map((opt) => (
946
+ <MenuDropdownItem key={opt.value} onClick={() => { sortBy(opt.value); close() }} shortcut={sort.by === opt.value ? <Icon name={sort.dir === 'desc' ? 'arrow-down' : 'arrow-up'} size={11} /> : undefined}>{opt.label}</MenuDropdownItem>
947
+ ))}
948
+ </div>
949
+ )}
950
+ </MenuItem>
951
+ </div>
952
+ </>
953
+ )}
954
+ belowActions={<div className="h-8 hidden md:flex items-center"><SortControls /></div>}
955
+ renderItem={(filtered) => (
956
+ <>
957
+ {buckets.length > 1 && <div className="md:hidden flex items-center gap-3 flex-wrap" style={{ marginBottom: 'var(--kol-spacing-3)' }}>{store()}</div>}
958
+ <PickerBody items={filtered} viewMode={viewMode} onOpen={onOpen} onPick={onPick} />
959
+ </>
913
960
  )}
914
961
  />
915
962
  <PathBar />
@@ -931,7 +978,9 @@ function PickerShell({ onClose, onPick }) {
931
978
  {/* While the viewer is up it owns Escape — handing the picker a no-op
932
979
  * close means one keypress steps back one level instead of exiting the
933
980
  * whole picker, which is the behaviour the fxr fork hand-rolled. */}
934
- <FullscreenOverlay open onClose={viewerIndex === null ? onClose : () => {}}>
981
+ {/* `scrim` — a modal OVER the page it was opened from, not an opaque surface in its place
982
+ * (user, 2026-10-07, on labs: "its not overlay, its complete full screen") */}
983
+ <FullscreenOverlay open scrim onClose={viewerIndex === null ? onClose : () => {}}>
935
984
  <div className="kol-media-picker">
936
985
  <LibraryChrome onOpen={setViewerIndex} onPick={pick} />
937
986
  </div>
@@ -1077,6 +1077,10 @@ export function MediaLibraryBrowse({
1077
1077
  /* `bucketLevel` — keep title → bucket → folders with ONE bucket (kol-client-olina 2026-09-23).
1078
1078
  * Absent, a one-bucket consumer collapses the level as ruled 2026-09-03. */
1079
1079
  bucketLevel = false,
1080
+ /* `onPickFile(o | null)` — the selected file, reported as it changes (picker A, 2026-10-08: the
1081
+ * browse surface inside the modal card needs the selection for its Use button). Keys are
1082
+ * bucket-relative. Absent, nothing. */
1083
+ onPickFile,
1080
1084
  }) {
1081
1085
  const [ownPrefix, setOwnPrefix] = useState('')
1082
1086
  const prefix = prefixProp ?? ownPrefix
@@ -1090,6 +1094,7 @@ export function MediaLibraryBrowse({
1090
1094
  const [settingsOpen, setSettingsOpen] = useState(false)
1091
1095
  const [trashOpen, setTrashOpen] = useState(false)
1092
1096
  const [pickedFile, setPickedFile] = useState(null)
1097
+ useEffect(() => { onPickFile?.(pickedFile) }, [pickedFile]) // eslint-disable-line react-hooks/exhaustive-deps
1093
1098
  /* ROW VIEW PICKS, and only one at a time: a click selects and fills the preview, a DOUBLE click
1094
1099
  * opens (user 2026-09-22, Finder's behaviour — "clicking shouldnt automatically open it"). The
1095
1100
  * chevron still expands in place, which is the third thing and always was. */
@@ -2600,8 +2605,12 @@ export function MediaLibraryBrowse({
2600
2605
 
2601
2606
  {/* ONE MENU FOR EVERY ROW. `useContextMenu` carries the payload from whichever row opened
2602
2607
  * it, so this is a single instance rather than one per row, and the entries are built from
2603
- * what `fileActions` actually supplies — a read-only bucket gets no menu at all. */}
2604
- {canWrite && (
2608
+ * what `fileActions` actually supplies. IT MOUNTS ON A READ-ONLY BUCKET TOO (kol-website,
2609
+ * 2026-10-08: on B2 "I cant right click to get context modal … cant download assets"): Copy URL
2610
+ * and Download are read verbs — the client's URLs — and gating the whole menu on `canWrite`
2611
+ * took them away with the write verbs. `fa` is the write seam: empty when the bucket cannot
2612
+ * be written, so every write verb below folds away and the read verbs stay. */}
2613
+ {(() => { const fa = canWrite ? fileActions : {}; return (
2605
2614
  <ContextMenu menu={menu}>
2606
2615
  {(target) => {
2607
2616
  if (!target) return null
@@ -2611,7 +2620,7 @@ export function MediaLibraryBrowse({
2611
2620
  * not one of them — renaming five things to one name is not a thing — so it stays
2612
2621
  * single and disappears from a multi-selection rather than lying about what it does. */
2613
2622
  const many = (target.targets ?? []).length > 1 ? target.targets : null
2614
- const custom = (fileActions.items ?? []).filter((it) => !it.when || it.when(target))
2623
+ const custom = (fa.items ?? []).filter((it) => !it.when || it.when(target))
2615
2624
  const customItems = custom.length > 0 && (
2616
2625
  <>
2617
2626
  <MenuDropdownDivider />
@@ -2625,7 +2634,7 @@ export function MediaLibraryBrowse({
2625
2634
  <>
2626
2635
  <MenuDropdownItem disabled>{many.length} selected</MenuDropdownItem>
2627
2636
  <MenuDropdownDivider />
2628
- {fileActions.move && (
2637
+ {fa.move && (
2629
2638
  <MenuDropdownItem iconLeft={<Icon name="arrow-right" size={14} />} onClick={() => doBatch(many, 'move', 'Move')}>Move {many.length} to…</MenuDropdownItem>
2630
2639
  )}
2631
2640
  {many.every((p) => !p.endsWith('/')) && (
@@ -2635,7 +2644,7 @@ export function MediaLibraryBrowse({
2635
2644
  {canTag && <MenuDropdownItem iconLeft={<Icon name="hash-01" size={14} />} onClick={() => doTags(many)}>Add tags to {many.length}…</MenuDropdownItem>}
2636
2645
  </>
2637
2646
  )}
2638
- {fileActions.remove && (
2647
+ {fa.remove && (
2639
2648
  <MenuDropdownItem iconLeft={<Icon name="trash" size={14} />} onClick={() => doBatch(many, 'remove', 'Delete')}>Delete {many.length}</MenuDropdownItem>
2640
2649
  )}
2641
2650
  {customItems}
@@ -2644,7 +2653,7 @@ export function MediaLibraryBrowse({
2644
2653
  }
2645
2654
  return (
2646
2655
  <>
2647
- {fileActions.createFolder && (
2656
+ {fa.createFolder && (
2648
2657
  <MenuDropdownItem iconLeft={<Icon name="folder" size={14} />} onClick={() => doCreateFolder(isFolder ? target.path : (isLevel ? target.path : prefix))}>
2649
2658
  New folder{isFolder ? ` in ${target.path.replace(/\/$/, '').split('/').pop()}` : ''}
2650
2659
  </MenuDropdownItem>
@@ -2655,7 +2664,7 @@ export function MediaLibraryBrowse({
2655
2664
  <MenuDropdownItem iconLeft={<Icon name="file" size={14} />} onClick={() => newDocument(isFolder ? target.path : (isLevel ? target.path : prefix))}>
2656
2665
  New document…
2657
2666
  </MenuDropdownItem>
2658
- ) : fileActions.createFile && (
2667
+ ) : fa.createFile && (
2659
2668
  <MenuDropdownItem iconLeft={<Icon name="file" size={14} />} onClick={() => doCreateFile(isFolder ? target.path : (isLevel ? target.path : prefix))}>
2660
2669
  New file
2661
2670
  </MenuDropdownItem>
@@ -2666,10 +2675,10 @@ export function MediaLibraryBrowse({
2666
2675
  </MenuDropdownItem>
2667
2676
  )}
2668
2677
  {!isLevel && <MenuDropdownDivider />}
2669
- {!isLevel && fileActions.rename && (
2678
+ {!isLevel && fa.rename && (
2670
2679
  <MenuDropdownItem iconLeft={<Icon name="edit" size={14} />} onClick={() => doRename(target.path, isFolder)}>Rename</MenuDropdownItem>
2671
2680
  )}
2672
- {!isLevel && fileActions.move && (
2681
+ {!isLevel && fa.move && (
2673
2682
  <MenuDropdownItem iconLeft={<Icon name="arrow-right" size={14} />} onClick={() => doMove(target.path)}>Move to…</MenuDropdownItem>
2674
2683
  )}
2675
2684
  {/* A FOLDER GETS COPY URL TOO (user 2026-09-23) — through the SAME `mediaUrl`
@@ -2693,7 +2702,7 @@ export function MediaLibraryBrowse({
2693
2702
  {target.type === 'file' && canEdit && editKindOf(target.o ?? { key: target.path }) && (
2694
2703
  <MenuDropdownItem iconLeft={<Icon name="edit" size={14} />} onClick={() => openEditor(objects.find((o) => o.key === target.path) ?? { key: target.path })}>Edit</MenuDropdownItem>
2695
2704
  )}
2696
- {!isLevel && fileActions.remove && (
2705
+ {!isLevel && fa.remove && (
2697
2706
  <>
2698
2707
  <MenuDropdownDivider />
2699
2708
  <MenuDropdownItem iconLeft={<Icon name="trash" size={14} />} onClick={() => doDelete(target.path, isFolder)}>Delete</MenuDropdownItem>
@@ -2704,7 +2713,7 @@ export function MediaLibraryBrowse({
2704
2713
  )
2705
2714
  }}
2706
2715
  </ContextMenu>
2707
- )}
2716
+ ) })()}
2708
2717
  {busyAction && <span className="sr-only" role="status">Working…</span>}
2709
2718
 
2710
2719
  {/* THE TAB PILL (item 16) — floats over the list, so the list owes it
@@ -3112,8 +3121,31 @@ export function MediaLibraryLibrary({
3112
3121
  layoutOptions={layoutOptions}
3113
3122
  layout={layout}
3114
3123
  onLayoutChange={setLayout}
3115
- trailingActions={<div className="flex items-center gap-6"><ViewToggle viewMode={layout} onViewChange={setLayout} variant="icon" options={LAYOUT_OPTIONS} /></div>}
3116
- belowActions={layout === 'off' ? null : <div className="h-8 flex items-center"><SortControls options={SORT_OPTIONS} sortBy={sortBy} sortDir={sortDir} onSort={handleSort} /></div>}
3124
+ /* BELOW `md` THE VIEWS AND THE SORT FOLD INTO ONE `···` (2026-10-08, apps/media/library at 390:
3125
+ * the FILES row, the three view icons and NAME · DATE · SIZE · KIND stacked three rows over the
3126
+ * list) — the browse page's fold, item 15, so the two pages fold the same way. Decided on the
3127
+ * recommendation, for review. */
3128
+ trailingActions={(
3129
+ <>
3130
+ <div className="hidden md:flex items-center gap-6"><ViewToggle viewMode={layout} onViewChange={setLayout} variant="icon" options={LAYOUT_OPTIONS} /></div>
3131
+ <div className="md:hidden">
3132
+ <MenuItem label={<Icon name="more" size={16} />} caret={false} align="end" size="sm" buttonClassName="shrink-0 px-2">
3133
+ {({ close }) => (
3134
+ <div className="py-1 w-[200px]">
3135
+ {LAYOUT_OPTIONS.map((opt) => (
3136
+ <MenuDropdownItem key={opt.value} onClick={() => { setLayout(opt.value); close() }} shortcut={layout === opt.value ? <Icon name="check" size={11} /> : undefined}>{opt.label}</MenuDropdownItem>
3137
+ ))}
3138
+ <MenuDropdownDivider />
3139
+ {SORT_OPTIONS.map((opt) => (
3140
+ <MenuDropdownItem key={opt.value} onClick={() => { handleSort(opt.value); close() }} shortcut={sortBy === opt.value ? <Icon name={sortDir === 'desc' ? 'arrow-down' : 'arrow-up'} size={11} /> : undefined}>{opt.label}</MenuDropdownItem>
3141
+ ))}
3142
+ </div>
3143
+ )}
3144
+ </MenuItem>
3145
+ </div>
3146
+ </>
3147
+ )}
3148
+ belowActions={layout === 'off' ? null : <div className="h-8 hidden md:flex items-center"><SortControls options={SORT_OPTIONS} sortBy={sortBy} sortDir={sortDir} onSort={handleSort} /></div>}
3117
3149
  /* ONE WALL BODY, shared with the merged surface (2026-09-22) */
3118
3150
  renderItem={(filtered) => (
3119
3151
  <WallBody files={filtered} layout={layout} sortBy={sortBy} sortDir={sortDir} pageSize={pageSize} listId={listId}
@@ -25,9 +25,12 @@ export default function MediaTileGallery({ items = [], layout = 'stack', cols =
25
25
 
26
26
  return (
27
27
  <>
28
+ {/* `cols` is the count from `sm` up; below it the grid is two across (08-breakpoints rule 6 —
29
+ * grid collapse is canonical, first break sm). The count rides a variable so the theme's one
30
+ * phone rule can override it: an inline `gridTemplateColumns` could not be out-ranked. */}
28
31
  <div
29
- className={isGrid ? 'grid gap-4' : 'flex flex-col gap-4'}
30
- style={isGrid ? { gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` } : undefined}
32
+ className={isGrid ? 'kol-media-tiles grid gap-4' : 'flex flex-col gap-4'}
33
+ style={isGrid ? { '--kol-media-tiles-cols': cols } : undefined}
31
34
  >
32
35
  {items.map((item, i) => (
33
36
  <figure key={i} className="min-w-0">
@@ -66,8 +66,13 @@ function ViewerStage({ media, index, onIndexChange }) {
66
66
  return () => document.removeEventListener('keydown', onKey)
67
67
  }, [emblaApi])
68
68
 
69
+ /* THE STAGE SETS ITS OWN WIDTH (2026-10-08, apps/media/viewer): FullscreenOverlay's sheet hugs
70
+ * its content, and the overlay is a grid whose one track is sized by that content — so `w-full`
71
+ * here resolved against nothing, the embla track grew to every slide side by side (six slides,
72
+ * 3888px at 1440) and the stage showed blank with the first image 1.6k off the left edge. The
73
+ * viewport minus the overlay's 24px lane each side is the width a fullscreen stage means. */
69
74
  return (
70
- <div className="relative w-full">
75
+ <div className="relative w-[calc(100vw-3rem)]">
71
76
  <div className="overflow-hidden" ref={emblaRef}>
72
77
  <div className="flex items-center">
73
78
  {media.map((item, i) => (
@@ -122,8 +127,15 @@ export default function MediaViewer({ open, media = [], index = 0, onIndexChange
122
127
  const actionRow = typeof actions === 'function' ? actions(media[index], index) : actions
123
128
 
124
129
  return (
125
- <FullscreenOverlay open onClose={onClose}>
130
+ /* `scrim` (2026-10-08, apps/media/viewer): without it the overlay painted its opaque light
131
+ * surface, and the viewer's inverse ink — caption, the paging chips — drew light on light and
132
+ * read as "an image and nothing else". The chips (md and up) and the caption were always there. */
133
+ <FullscreenOverlay open scrim onClose={onClose}>
126
134
  <ViewerStage media={media} index={index} onIndexChange={onIndexChange} />
135
+ {/* on a phone the chips are off and a swipe pages — the position says there is more */}
136
+ {media.length > 1 && (
137
+ <div className="md:hidden mt-3 text-center kol-helper-12 text-fg-inverse-64" aria-live="polite">{index + 1} / {media.length}</div>
138
+ )}
127
139
  {actionRow && (
128
140
  <div className="mt-4 flex items-center justify-center gap-2">{actionRow}</div>
129
141
  )}