@kolkrabbi/kol-component 0.207.0 → 0.209.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-component",
3
- "version": "0.207.0",
4
- "description": "KOL design-system components atoms through organisms, emitting canonical kol-* classes. Pairs with @kolkrabbi/kol-theme for styling.",
3
+ "version": "0.209.0",
4
+ "description": "KOL design-system components \u2014 atoms through organisms, emitting canonical kol-* classes. Pairs with @kolkrabbi/kol-theme for styling.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "./src/index.js",
@@ -34,7 +34,7 @@
34
34
  "react-dom": "^18.3.0 || ^19.0.0"
35
35
  },
36
36
  "devDependencies": {
37
- "@kolkrabbi/kol-icons": "^0.26.0"
37
+ "@kolkrabbi/kol-icons": "workspace:^"
38
38
  },
39
39
  "files": [
40
40
  "src",
@@ -56,4 +56,4 @@
56
56
  "access": "public"
57
57
  },
58
58
  "sideEffects": false
59
- }
59
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * svgExport — turn a finished SVG string into pixels, and embed the fonts it
3
+ * needs so the export reads like the screen.
4
+ *
5
+ * Packaged 2026-09-04 (`export-and-history-want-packaging`, kol-fxr for
6
+ * kol-client-olina, who had copied ~180 lines of it). **The BUILDER is
7
+ * deliberately not here.** Assembling an SVG welds to an app's own layer
8
+ * schema — slides in one editor, compose layers in another — so it was never
9
+ * the reusable half. This takes a finished string and gives back a Blob.
10
+ */
11
+
12
+ /**
13
+ * Rasterize an SVG string to a PNG Blob at `scale`× the SVG's own dimensions.
14
+ * Triggers no download.
15
+ *
16
+ * @param {string} svgString - A complete, self-contained SVG document
17
+ * @param {number} [scale=1] - The @Nx multiplier
18
+ * @param {number} [fallbackSize=1080] - Used when the SVG declares no intrinsic size
19
+ * @returns {Promise<Blob>} the PNG
20
+ */
21
+ export function svgToPngBlob(svgString, scale = 1, fallbackSize = 1080) {
22
+ return new Promise((resolve, reject) => {
23
+ const blob = new Blob([svgString], { type: 'image/svg+xml' })
24
+ const url = URL.createObjectURL(blob)
25
+ const img = new Image()
26
+ img.onload = () => {
27
+ const canvas = document.createElement('canvas')
28
+ canvas.width = (img.width || fallbackSize) * scale
29
+ canvas.height = (img.height || fallbackSize) * scale
30
+ const ctx = canvas.getContext('2d')
31
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
32
+ URL.revokeObjectURL(url)
33
+ canvas.toBlob((pngBlob) => {
34
+ if (pngBlob) resolve(pngBlob)
35
+ else reject(new Error('PNG encode failed'))
36
+ }, 'image/png')
37
+ }
38
+ img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('SVG rasterize failed')) }
39
+ img.src = url
40
+ })
41
+ }
42
+
43
+ const b64 = (buf) => {
44
+ const bytes = new Uint8Array(buf)
45
+ let bin = ''
46
+ const CH = 0x8000
47
+ for (let i = 0; i < bytes.length; i += CH) bin += String.fromCharCode.apply(null, bytes.subarray(i, i + CH))
48
+ return btoa(bin)
49
+ }
50
+
51
+ const FORMAT = { woff2: 'woff2', woff: 'woff', ttf: 'truetype', otf: 'opentype' }
52
+ const formatFor = (url) => FORMAT[(url.split('?')[0].split('.').pop() || '').toLowerCase()] ?? 'truetype'
53
+
54
+ /**
55
+ * Inline every font a stylesheet references, by REWRITING its `@font-face`
56
+ * blocks and swapping only the `src`.
57
+ *
58
+ * ⚠ **NEVER SYNTHESIZE A FACE.** This is the bug the seam exists to stop
59
+ * (kol-client-olina, 2026-09-04): they harvested bare `url(...)` matches out
60
+ * of Google's CSS and emitted their own `@font-face` without `unicode-range`.
61
+ * Google splits ONE family into latin / latin-ext / cyrillic / greek subsets
62
+ * that are distinguished by that range and nothing else — so the first subset
63
+ * fetched got embedded, matched no glyph, and every family exported in a system
64
+ * fallback while the screen looked perfect. Rewriting the whole block keeps
65
+ * `unicode-range`, the weight and stretch ranges, and anything else the
66
+ * foundry declared.
67
+ *
68
+ * A face that fails to fetch is REPORTED, not swallowed: a silent `catch {}`
69
+ * is how the same export goes out in fallback with nothing to look at.
70
+ *
71
+ * @param {string} cssText - The stylesheet whose `@font-face` blocks to inline
72
+ * @param {Function} [fetchFont] - `(url) => Promise<ArrayBuffer>`; defaults to `fetch`
73
+ * @returns {Promise<{css: string, failed: Array<{url: string, error: Error}>}>} the rewritten CSS and every face that could not be embedded
74
+ */
75
+ export async function inlineFontFaces(cssText, fetchFont) {
76
+ const load = fetchFont ?? (async (url) => {
77
+ const r = await fetch(url)
78
+ if (!r.ok) throw new Error(`font fetch failed: ${r.status} ${url}`)
79
+ return r.arrayBuffer()
80
+ })
81
+
82
+ const failed = []
83
+ const blocks = [...cssText.matchAll(/@font-face\s*{[^}]*}/g)].map((m) => m[0])
84
+ const urls = new Map()
85
+ for (const block of blocks) {
86
+ for (const m of block.matchAll(/url\(\s*['"]?([^'")]+)['"]?\s*\)/g)) {
87
+ if (!m[1].startsWith('data:')) urls.set(m[1], null)
88
+ }
89
+ }
90
+
91
+ await Promise.all([...urls.keys()].map(async (url) => {
92
+ try {
93
+ urls.set(url, `data:font/${formatFor(url)};base64,${b64(await load(url))}`)
94
+ } catch (error) {
95
+ failed.push({ url, error })
96
+ }
97
+ }))
98
+
99
+ /* Only the `src` is touched — every other descriptor in the block, and
100
+ * `unicode-range` above all, survives exactly as authored. */
101
+ const css = cssText.replace(/url\(\s*['"]?([^'")]+)['"]?\s*\)(\s*format\(\s*['"]?[^'")]+['"]?\s*\))?/g,
102
+ (whole, url) => {
103
+ const data = urls.get(url)
104
+ return data ? `url(${data}) format('${formatFor(url)}')` : whole
105
+ })
106
+
107
+ return { css, failed }
108
+ }
109
+
110
+ /**
111
+ * Build one `@font-face` block for a SELF-HOSTED, full-range file — the only
112
+ * case where synthesizing is safe, because there are no subsets to confuse.
113
+ * Anything served as subsets (Google's families, any foundry CSS) must go
114
+ * through `inlineFontFaces` instead.
115
+ *
116
+ * @param {{family: string, url: string, weight?: string, stretch?: string}} face
117
+ * @param {Function} [fetchFont] - `(url) => Promise<ArrayBuffer>`
118
+ * @returns {Promise<string>} the `@font-face` block, fonts inlined
119
+ */
120
+ export async function embedFontFace({ family, url, weight = '1 1000', stretch = '1% 1000%' }, fetchFont) {
121
+ const load = fetchFont ?? (async (u) => {
122
+ const r = await fetch(u)
123
+ if (!r.ok) throw new Error(`font fetch failed: ${r.status} ${u}`)
124
+ return r.arrayBuffer()
125
+ })
126
+ const data = `data:font/${formatFor(url)};base64,${b64(await load(url))}`
127
+ return `@font-face{font-family:'${family}';src:url(${data}) format('${formatFor(url)}');font-weight:${weight};font-stretch:${stretch};}`
128
+ }
129
+
130
+ /**
131
+ * Download a Blob under a filename. The one place a DOM anchor is minted for
132
+ * a save, so a consumer's export path does not hand-roll it.
133
+ *
134
+ * @param {Blob} blob
135
+ * @param {string} filename
136
+ */
137
+ export function downloadBlob(blob, filename) {
138
+ const url = URL.createObjectURL(blob)
139
+ const a = document.createElement('a')
140
+ a.href = url
141
+ a.download = filename
142
+ document.body.appendChild(a)
143
+ a.click()
144
+ a.remove()
145
+ URL.revokeObjectURL(url)
146
+ }
@@ -0,0 +1,121 @@
1
+ import { useCallback, useRef, useState } from 'react'
2
+
3
+ /**
4
+ * useHistory — undo/redo over a value the CONSUMER owns, with transactions.
5
+ *
6
+ * Packaged 2026-09-04 (`export-and-history-want-packaging`, kol-fxr for
7
+ * kol-client-olina, who had copied it on our advice and came back asking for
8
+ * it by name). **Generic over the value, deliberately**: one editor's snapshot
9
+ * is `{ slides, active, selectedIds }`, another's is compose state. The
10
+ * consumer's words, and they are right — *if it knows about layers it stops
11
+ * being reusable*. Nothing here inspects what it stores.
12
+ *
13
+ * TWO THINGS MAKE IT WORTH PACKAGING, and both are behaviour rather than code:
14
+ *
15
+ * 1. **Selection rides INSIDE the snapshot.** Undo restores what was selected,
16
+ * not only what was drawn — so put the selection in the value. An undo that
17
+ * redraws the old shape but leaves a stale selection is the version everyone
18
+ * writes first and nobody wants.
19
+ * 2. **A drag is ONE entry.** `begin()` before a gesture and `end()` after it:
20
+ * every `set` between them updates the live value without pushing, and `end`
21
+ * pushes once. Without it a pointer-move drag floods the stack.
22
+ *
23
+ * ⚠ **NEVER PUSH INSIDE A `setState` UPDATER.** React StrictMode invokes
24
+ * updaters twice, so every entry doubles — silently, in dev only, and it looks
25
+ * like undo "skipping". The next value is computed against a ref here, outside
26
+ * the updater, which is what makes that impossible rather than merely avoided.
27
+ *
28
+ * const { value, set, begin, end, undo, redo, reset, canUndo, canRedo } = useHistory(initial)
29
+ * onPointerDown={begin} onPointerMove={(e) => set(next(e))} onPointerUp={end}
30
+ *
31
+ * @param {*} initialValue - The first snapshot; any shape
32
+ * @param {number} [limit=100] - Entries kept before the oldest is dropped
33
+ * @returns {{value: *, set: Function, begin: Function, end: Function, undo: Function, redo: Function, reset: Function, canUndo: boolean, canRedo: boolean}}
34
+ */
35
+ export default function useHistory(initialValue, limit = 100) {
36
+ const [value, setValue] = useState(initialValue)
37
+ /* The ref is the truth the history reads. State drives the render; this
38
+ * drives the stack, so a push never depends on an updater running once. */
39
+ const valueRef = useRef(initialValue)
40
+ const past = useRef([])
41
+ const future = useRef([])
42
+ const txn = useRef(null) /* the value as it was when begin() was called */
43
+ const [, bump] = useState(0)
44
+ const rerender = () => bump((n) => n + 1)
45
+
46
+ const commit = useCallback((next) => {
47
+ valueRef.current = next
48
+ setValue(next)
49
+ }, [])
50
+
51
+ /* `set` takes a value or a producer. The producer is called HERE, against
52
+ * the ref, never inside setState — see the StrictMode note above. */
53
+ const set = useCallback((nextOrFn) => {
54
+ const prev = valueRef.current
55
+ const next = typeof nextOrFn === 'function' ? nextOrFn(prev) : nextOrFn
56
+ if (Object.is(next, prev)) return
57
+ if (txn.current === null) {
58
+ past.current = [...past.current, prev].slice(-limit)
59
+ future.current = []
60
+ }
61
+ commit(next)
62
+ rerender()
63
+ }, [commit, limit])
64
+
65
+ /* Open a transaction: `set` keeps updating the live value, and the entry
66
+ * that lands on `end` is the value as it was when this was called. */
67
+ const begin = useCallback(() => {
68
+ if (txn.current === null) txn.current = { from: valueRef.current }
69
+ }, [])
70
+
71
+ const end = useCallback(() => {
72
+ const open = txn.current
73
+ txn.current = null
74
+ if (!open) return
75
+ if (Object.is(open.from, valueRef.current)) return /* a gesture that moved nothing is not an entry */
76
+ past.current = [...past.current, open.from].slice(-limit)
77
+ future.current = []
78
+ rerender()
79
+ }, [limit])
80
+
81
+ const undo = useCallback(() => {
82
+ if (!past.current.length) return
83
+ const prev = past.current[past.current.length - 1]
84
+ past.current = past.current.slice(0, -1)
85
+ future.current = [valueRef.current, ...future.current]
86
+ commit(prev)
87
+ rerender()
88
+ }, [commit])
89
+
90
+ const redo = useCallback(() => {
91
+ if (!future.current.length) return
92
+ const next = future.current[0]
93
+ future.current = future.current.slice(1)
94
+ past.current = [...past.current, valueRef.current].slice(-limit)
95
+ commit(next)
96
+ rerender()
97
+ }, [commit, limit])
98
+
99
+ /* Drop the whole stack and start again (a fresh document, a loaded file).
100
+ * Not an undoable step — there is nothing behind it. Omit `next` to return
101
+ * to the value the hook was initialised with. */
102
+ const reset = useCallback((next) => {
103
+ past.current = []
104
+ future.current = []
105
+ txn.current = null
106
+ commit(next === undefined ? initialValue : next)
107
+ rerender()
108
+ }, [commit, initialValue])
109
+
110
+ return {
111
+ value,
112
+ set,
113
+ begin,
114
+ end,
115
+ undo,
116
+ redo,
117
+ reset,
118
+ canUndo: past.current.length > 0,
119
+ canRedo: future.current.length > 0,
120
+ }
121
+ }
package/src/index.js CHANGED
@@ -222,6 +222,11 @@ export { useEyedropper, pickFromCanvasElement } from './hooks/useEyedropper.js'
222
222
  * STRUCTURAL responsive fork, where the markup itself changes and a stylesheet
223
223
  * cannot express it (ColumnBrowserStackMode, 2026-09-03). Sizes stay Tailwind's. */
224
224
  export { default as useMediaQuery } from './hooks/useMediaQuery.js'
225
+ /* The two seams kol-client-olina asked for by name after copying both
226
+ * (export-and-history-want-packaging, 2026-09-04). The SVG BUILDER is
227
+ * deliberately absent — it welds to an app's own layer schema. */
228
+ export { svgToPngBlob, inlineFontFaces, embedFontFace, downloadBlob } from './hooks/svgExport.js'
229
+ export { default as useHistory } from './hooks/useHistory.js'
225
230
  /* The rail gesture's other half. It lived in kol-framework until 2026-09-03 and
226
231
  * moved here for the same reason `useGrabEdge` did: kol-component's own
227
232
  * `EditorShell` needs resizable rails and cannot import framework. framework
@@ -57,6 +57,7 @@ import { GRAB_COLUMN } from '../utilities/motion.js'
57
57
  * @param {Function} kindOf (o) => 'image' | 'video' | 'audio' | string
58
58
  * @param {Object} kindLabel kind → label shown when there is no visual preview
59
59
  * @param {Function} formatSize (bytes) => string
60
+ * @param {Function} formatDate (isoString) => string — the row/preview date, ISO date-only by default
60
61
  * @param {Function} partition (objects, level) => { folders: string[], files: object[] }
61
62
  * @param {Function} renderPreview (file) => ReactNode — replaces the preview column's media frame (the facts stay — Dimensions and Length are read off whatever <img> / <video> / <audio> the node loads); without it images render the organism's <img>, everything else the DS KindPreview
62
63
  * @param {number} height controlled height in px (omit for uncontrolled)
@@ -97,6 +98,54 @@ const defaultFormatSize = (bytes) => {
97
98
  if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
98
99
  return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`
99
100
  }
101
+
102
+ /* THE STACK'S ROW ORDER — walked, not iterated, and lifted out of the component
103
+ * so it is reachable by a check (the defect below is invisible to every
104
+ * structural assertion: the rows are all present and each carries the right
105
+ * `depth`, they are merely in the wrong ORDER).
106
+ *
107
+ * The open folder's children have to land DIRECTLY under its own row. That
108
+ * adjacency IS inline expand, and inline expand is the whole ruling. A flat
109
+ * loop over `openPath` appends each level after the one before it, so an open
110
+ * folder's children surfaced below its last SIBLING and below that level's
111
+ * files (D1, kol-r2b2 2026-09-04) — which read as an indent bug rather than a
112
+ * sequence bug, and is how a comment claiming "the loop IS the path" survived.
113
+ *
114
+ * @param {object[]} objects the flat key space
115
+ * @param {string[]} openPath the open levels, shallowest first
116
+ * @param {Function} partition (objects, level) => { folders, files }
117
+ * @returns {object[]} rows — `folder` | `file` | `empty`, each with its `depth`
118
+ */
119
+ export function stackRows(objects, openPath, partition) {
120
+ const rows = []
121
+ const walk = (depth) => {
122
+ const level = openPath[depth]
123
+ const { folders, files } = partition(objects.filter((o) => o.key.startsWith(level)), level)
124
+ const openFolder = openPath[depth + 1]?.slice(level.length) ?? null
125
+ folders.forEach((f) => {
126
+ const isOpen = f === openFolder
127
+ rows.push({ kind: 'folder', key: level + f, level, name: f, depth, open: isOpen })
128
+ if (isOpen) walk(depth + 1)
129
+ })
130
+ files.forEach((o) => rows.push({ kind: 'file', key: o.key, o, depth, level }))
131
+ if (!folders.length && !files.length) rows.push({ kind: 'empty', key: level + '·empty', depth })
132
+ }
133
+ walk(0)
134
+ return rows
135
+ }
136
+
137
+ /* A ROW SHOWS A DATE, NOT A TIMESTAMP. `2026-06-19T02:00:14.629Z` is storage
138
+ * answering "when" in its own voice, and it shipped into the stack meta line
139
+ * because the value arrives as a string and a string renders (D2, kol-r2b2
140
+ * 2026-09-04). A seam beside `formatSize` for the same reason that one is a
141
+ * seam: how a date reads is the consumer's call, not the DS's. The default is
142
+ * ISO-8601 date-only — unambiguous everywhere, which no locale format is.
143
+ * Anything unparseable comes through verbatim rather than as `Invalid Date`. */
144
+ const defaultFormatDate = (v) => {
145
+ if (!v) return ''
146
+ const d = new Date(v)
147
+ return Number.isNaN(d.getTime()) ? String(v) : d.toISOString().slice(0, 10)
148
+ }
100
149
  /* kol-r2b2's partition: the next path segment is a folder, the rest are files */
101
150
  const defaultPartition = (objects, prefix) => {
102
151
  const folderSet = new Set()
@@ -227,7 +276,7 @@ function Row({
227
276
  )
228
277
  }
229
278
 
230
- function Preview({ o, urlOf, kindOf, kindLabel, formatSize, renderPreview, width }) {
279
+ function Preview({ o, urlOf, kindOf, kindLabel, formatSize, formatDate, renderPreview, width }) {
231
280
  // Pixel size and length come from the loaded media itself — the bucket stores
232
281
  // none. `{ w, h }` off an <img> load, `{ w, h, len }` off a <video>'s and
233
282
  // `{ len }` off an <audio>'s loadedmetadata (ColumnBrowserMediaFacts, kol-r2b2
@@ -243,7 +292,7 @@ function Preview({ o, urlOf, kindOf, kindLabel, formatSize, renderPreview, width
243
292
  ['Size', formatSize(o.size)],
244
293
  ...(sized ? [['Dimensions', dims?.w ? `${dims.w} × ${dims.h} px` : '…']] : []),
245
294
  ...(timed ? [['Length', dims?.len != null ? formatLength(dims.len) : '…']] : []),
246
- ['Date', o.uploaded ? new Date(o.uploaded).toISOString().slice(0, 10) : '—'],
295
+ ['Date', formatDate(o.uploaded) || '—'],
247
296
  ]
248
297
  return (
249
298
  <div className="kol-column-browser-preview shrink-0 overflow-y-auto p-4 flex flex-col gap-4" style={{ width }}>
@@ -343,6 +392,7 @@ export default function ColumnBrowser({
343
392
  kindOf = defaultKindOf,
344
393
  kindLabel = DEFAULT_KIND_LABEL,
345
394
  formatSize = defaultFormatSize,
395
+ formatDate = defaultFormatDate,
346
396
  partition = defaultPartition,
347
397
  renderPreview,
348
398
  height,
@@ -563,23 +613,9 @@ export default function ColumnBrowser({
563
613
 
564
614
  /* One flat row list, walked down the open path: each level's items, with
565
615
  * the open folder's children spliced in directly under it. */
566
- const rows = []
567
- openPath.forEach((level, depth) => {
568
- const { folders, files } = partition(objects.filter((o) => o.key.startsWith(level)), level)
569
- const openFolder = openPath[depth + 1]?.slice(level.length) ?? null
570
- folders.forEach((f) => {
571
- const isOpen = f === openFolder
572
- rows.push({
573
- kind: 'folder', key: level + f, level, name: f, depth, open: isOpen,
574
- })
575
- /* the open folder's own children are pushed by the next iteration —
576
- * nothing recursive here, the loop IS the path */
577
- })
578
- files.forEach((o) => rows.push({ kind: 'file', key: o.key, o, depth, level }))
579
- if (!folders.length && !files.length) rows.push({ kind: 'empty', key: level + '·empty', depth })
580
- })
616
+ const rows = stackRows(objects, openPath, partition)
581
617
 
582
- const metaOf = (o) => [o.size != null && formatSize(o.size), o.uploaded].filter(Boolean).join(' · ')
618
+ const metaOf = (o) => [o.size != null && formatSize(o.size), formatDate(o.uploaded)].filter(Boolean).join(' · ')
583
619
 
584
620
  return (
585
621
  <div
@@ -741,7 +777,7 @@ export default function ColumnBrowser({
741
777
  })}
742
778
  {shown && (
743
779
  <>
744
- <Preview key={shown.key} o={shown} urlOf={urlOf} kindOf={kindOf} kindLabel={kindLabel} formatSize={formatSize} renderPreview={renderPreview} width={widthOf('preview')} />
780
+ <Preview key={shown.key} o={shown} urlOf={urlOf} kindOf={kindOf} kindLabel={kindLabel} formatSize={formatSize} formatDate={formatDate} renderPreview={renderPreview} width={widthOf('preview')} />
745
781
  <ResizeHandle axis="x" onDrag={resizeCol('preview')} onEnd={endDrag} />
746
782
  </>
747
783
  )}
@@ -314,6 +314,17 @@ const profileOf = (objects, rawFiles, systemCount) => ({
314
314
  export function MediaLibraryBrowse({
315
315
  client, title = 'MEDIA', bucket, onBucketChange, prefix: prefixProp, onPrefix, defaults, settings: settingsProp, onSettingsChange,
316
316
  folderTree, headerActions, refreshKey, onOpen, autoFocus = false, settingsFooter, className = '',
317
+ /* PASS-THROUGHS TO `ColumnBrowser`. A documented prop this page does not
318
+ * forward is a prop no consumer of the PAGE can reach, which makes the seam
319
+ * fictional — `thumbnailFor` and `folderMeta` shipped, were announced, and
320
+ * rendered as if they never existed because this signature ended before them
321
+ * (ColumnBrowserMobileViews items 10 + 13, kol-r2b2 2026-09-04). Second time
322
+ * this shape bit: `SettingsPanel` documented a `footer` slot this file
323
+ * hardcoded past. The rule the sweep leaves behind — WHEN A PROP IS ADDED TO
324
+ * `ColumnBrowser`, IT IS ADDED HERE IN THE SAME EDIT, unless the page holds a
325
+ * real opinion about it (it owns `height`, the widths and `partition`, so
326
+ * those are deliberately absent from this list). */
327
+ thumbnailFor, folderMeta, formatDate, stackView,
317
328
  }) {
318
329
  const [ownPrefix, setOwnPrefix] = useState('')
319
330
  const prefix = prefixProp ?? ownPrefix
@@ -483,6 +494,10 @@ export function MediaLibraryBrowse({
483
494
  kindOf={kindOf}
484
495
  kindLabel={KIND_LABEL}
485
496
  formatSize={formatSize}
497
+ formatDate={formatDate}
498
+ thumbnailFor={thumbnailFor}
499
+ folderMeta={folderMeta}
500
+ stackView={stackView}
486
501
  renderPreview={(o) => {
487
502
  const real = { ...o, key: o.key.slice(VROOT.length) }
488
503
  if (isImage(real.contentType)) return <ImageFrame src={mediaUrl(real.key)} />