@kolkrabbi/kol-component 0.221.0 → 0.223.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.221.0",
3
+ "version": "0.223.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
@@ -120,6 +120,7 @@ export { default as Slider } from './molecules/Slider.jsx'
120
120
  export { default as SpecList } from './molecules/SpecList.jsx'
121
121
  export { default as SpectrumControls, HueStrip, SBSquare, WheelTriangle } from './organisms/SpectrumControls.jsx'
122
122
  export { default as SplitToolButton } from './molecules/SplitToolButton.jsx'
123
+ export { default as ToolPalette } from './organisms/ToolPalette.jsx'
123
124
  export { default as SwatchControls, SwatchStack, EyedropPick } from './molecules/SwatchControls.jsx'
124
125
  export { default as TabsRow } from './molecules/TabsRow.jsx'
125
126
 
@@ -54,7 +54,14 @@ import { glyphSize } from '../hooks/glyphLadders.js'
54
54
  * @param {boolean} props.active - Whether this tool group is the active tool — lit trigger, `aria-pressed`
55
55
  * @param {'xs'|'sm'|'md'|'lg'} props.size - Trigger box on the pinned-square ladder — 22 · 26 · 32 · 40 (default: 'md'). The glyph follows the SOLO ladder; it is never set at the call site
56
56
  * @param {boolean} props.blurOnClick - Blur the trigger after click so a canvas can reclaim focus and refresh its cursor (default: false)
57
+ * @param {Function} props.onTrigger - Replaces the trigger's arm: fires with the trigger variant's id on every click. For a fold of one-shot ACTIONS (Boolean: the trigger re-runs the last-picked op) — no pressed state is implied (default: arm via `onChange`)
58
+ * @param {boolean} props.disabled - Disables the trigger — `.kol-btn:disabled` dims it (default: false)
59
+ * @param {ElementType} props.iconComponent - Glyph renderer receiving `{ name, size, className, style }` — Button's seam (default: DS `Icon`)
57
60
  * @param {string} props.aria-label - Trigger label fallback when no variant resolves
61
+ *
62
+ * A variant may carry its own `onSelect()` — that row does its own thing instead of `onChange`
63
+ * (Text: "Text" arms the tool, "Kinetic type" inserts a layer). Without `lastPicked` the trigger
64
+ * remembers the last row picked from the menu (kol-fxr ToolPalette, editor-panels-the-held-specs A3).
58
65
  * @param {string} props.className - Additional classes on the trigger
59
66
  */
60
67
 
@@ -77,6 +84,9 @@ const SplitToolButton = ({
77
84
  active = false,
78
85
  size = 'md',
79
86
  blurOnClick = false,
87
+ onTrigger,
88
+ disabled = false,
89
+ iconComponent: IconC = Icon,
80
90
  className = '',
81
91
  'aria-label': ariaLabel,
82
92
  }) => {
@@ -89,20 +99,23 @@ const SplitToolButton = ({
89
99
  role: 'menu',
90
100
  })
91
101
 
102
+ const [ownLast, setOwnLast] = useState(null)
92
103
  const byId = (id) => variants.find((v) => v.id === id)
93
- const triggerVariant = (active && byId(value)) || byId(lastPicked) || variants[0]
104
+ const triggerVariant = (active && byId(value)) || byId(lastPicked ?? ownLast) || variants[0]
94
105
  const label = triggerVariant?.label ?? ariaLabel
95
106
  const title = triggerVariant?.shortcut ? `${label} (${triggerVariant.shortcut})` : label
96
107
 
97
108
  /* Arm the last-picked variant on the same click that toggles the menu —
98
109
  * floating-ui's useClick (on the reference) handles the open/close half. */
99
110
  const handleTriggerClick = (event) => {
100
- if (!active && triggerVariant) onChange?.(triggerVariant.id)
111
+ if (onTrigger) { if (triggerVariant) onTrigger(triggerVariant.id) }
112
+ else if (!active && triggerVariant) onChange?.(triggerVariant.id)
101
113
  if (blurOnClick) event.currentTarget.blur()
102
114
  }
103
115
 
104
116
  const handleSelect = (variant) => {
105
- onChange?.(variant.id)
117
+ if (variant.onSelect) variant.onSelect()
118
+ else { onChange?.(variant.id); setOwnLast(variant.id) }
106
119
  setOpen(false)
107
120
  }
108
121
 
@@ -113,11 +126,12 @@ const SplitToolButton = ({
113
126
  {...popover.getReferenceProps({ onClick: handleTriggerClick })}
114
127
  type="button"
115
128
  className={`relative kol-btn kol-btn-ghost kol-btn-icon kol-btn-${size} ${active ? 'kol-btn-pressed' : 'kol-btn-quiet'} ${className}`.trim()}
116
- aria-pressed={active}
129
+ disabled={disabled}
130
+ aria-pressed={onTrigger ? undefined : active}
117
131
  aria-label={label}
118
132
  title={title}
119
133
  >
120
- {triggerVariant && <Icon name={triggerVariant.icon} size={glyphSize(size, true)} />}
134
+ {triggerVariant && <IconC name={triggerVariant.icon} size={glyphSize(size, true)} />}
121
135
  <FoldIndicator />
122
136
  </button>
123
137
  {/* w-max — floats size to content, the menu-family law (2026-08-09). */}
@@ -137,7 +151,7 @@ const SplitToolButton = ({
137
151
  className="w-full kol-helper-12 px-3 h-8 inline-flex items-center gap-2 text-body hover:text-emphasis text-left"
138
152
  >
139
153
  <span className="shrink-0 w-4 inline-flex items-center justify-center">
140
- <Icon name={variant.icon} size={14} />
154
+ <IconC name={variant.icon} size={14} />
141
155
  </span>
142
156
  <span className="flex-1 truncate">{variant.label}</span>
143
157
  <span className="kol-helper-10 text-emphasis shrink-0">
@@ -63,6 +63,7 @@ import { GRAB_COLUMN } from '../utilities/motion.js'
63
63
  * @param {Function} formatDate (isoString) => string — the row/preview date, ISO date-only by default
64
64
  * @param {Function} partition (objects, level) => { folders: string[], files: object[] }
65
65
  * @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
66
+ * @param {Function} renderDetails (file) => ReactNode — rows under the preview column's facts (tags, an Edit action); absent, nothing is added
66
67
  * @param {number|string} height controlled height — px as a number, or ANY CSS length (`'100%'`, `'60vh'`,
67
68
  * `'calc(100dvh - 240px)'`) so the browser can fill the space it is given
68
69
  * (kol-client-olina 2026-09-22); a CSS length draws no height grabber;
@@ -260,10 +261,16 @@ function Row({
260
261
  onDragStart: drop ? (e) => { e.stopPropagation(); drop.onDragStart(e, drop.path) } : undefined,
261
262
  onDragOver: (e) => { if (takesFiles(e) || (drop && !isFileDrag(e) && drop.canDrop(drop.path))) { e.preventDefault(); setOver(true) } },
262
263
  onDragLeave: () => setOver(false),
264
+ /* A ROW THAT CANNOT TAKE THE DROP LETS IT BUBBLE (the file-onto-file bug, 2026-09-25): the
265
+ * enclosing column / list allows the drag, so the drop still fires HERE, and moving into a
266
+ * FILE's key renamed the dragged file. Unguarded, it skipped `canDrop`; now the container
267
+ * takes it — dropped on a file, it lands in that file's folder, as Finder does. */
263
268
  onDrop: (e) => {
264
- e.preventDefault(); e.stopPropagation(); setOver(false)
265
- if (takesFiles(e)) dropFiles(e.dataTransfer.files)
266
- else if (drop && !isFileDrag(e)) drop.onDrop(drop.path)
269
+ setOver(false)
270
+ if (takesFiles(e)) { e.preventDefault(); e.stopPropagation(); dropFiles(e.dataTransfer.files); return }
271
+ if (!drop || isFileDrag(e) || !drop.canDrop(drop.path)) return
272
+ e.preventDefault(); e.stopPropagation()
273
+ drop.onDrop(drop.path)
267
274
  },
268
275
  } : {}
269
276
  return (
@@ -350,7 +357,7 @@ function Row({
350
357
  )
351
358
  }
352
359
 
353
- function Preview({ o, urlOf, kindOf, kindLabel, formatSize, formatDate, renderPreview, width }) {
360
+ function Preview({ o, urlOf, kindOf, kindLabel, formatSize, formatDate, renderPreview, renderDetails, width }) {
354
361
  // Pixel size and length come from the loaded media itself — the bucket stores
355
362
  // none. `{ w, h }` off an <img> load, `{ w, h, len }` off a <video>'s and
356
363
  // `{ len }` off an <audio>'s loadedmetadata (ColumnBrowserMediaFacts, kol-r2b2
@@ -408,6 +415,14 @@ function Preview({ o, urlOf, kindOf, kindLabel, formatSize, formatDate, renderPr
408
415
  </div>
409
416
  ))}
410
417
  </dl>
418
+ {/* `renderDetails(o)` — the consumer's rows under the facts (the media D1 pass: tags, Edit) */}
419
+ {/* its own island: a click or a drag in here is not a click on the browser's background,
420
+ which deselects (a tag field would unpick the file it edits) */}
421
+ {renderDetails && (
422
+ <div onClick={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
423
+ {renderDetails(o)}
424
+ </div>
425
+ )}
411
426
  </div>
412
427
  )
413
428
  }
@@ -474,6 +489,9 @@ export default function ColumnBrowser({
474
489
  formatDate = defaultFormatDate,
475
490
  partition = defaultPartition,
476
491
  renderPreview,
492
+ /* `renderDetails(file)` — extra rows under the preview column's facts (tags, an Edit action);
493
+ * absent, the column is exactly what it was */
494
+ renderDetails,
477
495
  height,
478
496
  defaultHeight = 528,
479
497
  onHeightChange,
@@ -648,6 +666,9 @@ export default function ColumnBrowser({
648
666
 
649
667
  const shiftAnchor = useRef(null)
650
668
  const onKeyDown = (e) => {
669
+ /* typing in a field inside the browser (the preview's tag field) is not navigation */
670
+ const el = e.target
671
+ if (el?.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el?.tagName ?? '')) return
651
672
  /* ⌘↑ / ⌘↓ (user 2026-09-23: *"all modes should 'command up down' to move up and down parent
652
673
  * child"*) — Finder's: ⌘↑ is ←, back to the enclosing folder; ⌘↓ opens what the cursor is on,
653
674
  * → for a folder and Quick Look for a file. */
@@ -1029,7 +1050,7 @@ export default function ColumnBrowser({
1029
1050
  </>
1030
1051
  ) : shown && (
1031
1052
  <>
1032
- <Preview key={shown.key} o={shown} urlOf={urlOf} kindOf={kindOf} kindLabel={kindLabel} formatSize={formatSize} formatDate={formatDate} renderPreview={renderPreview} width={widthOf('preview')} />
1053
+ <Preview key={shown.key} o={shown} urlOf={urlOf} kindOf={kindOf} kindLabel={kindLabel} formatSize={formatSize} formatDate={formatDate} renderPreview={renderPreview} renderDetails={renderDetails} width={widthOf('preview')} />
1033
1054
  <ResizeHandle axis="x" onDrag={resizeCol('preview')} onEnd={endDrag} />
1034
1055
  </>
1035
1056
  )}
@@ -4,6 +4,7 @@ import { Icon } from '@kolkrabbi/kol-icons'
4
4
  import Button from '../atoms/Button.jsx'
5
5
  import Divider from '../atoms/Divider.jsx'
6
6
  import Input from '../atoms/Input.jsx'
7
+ import Tag from '../atoms/Tag.jsx'
7
8
  import IconFrame from '../atoms/IconFrame.jsx'
8
9
  import ActionButton from '../atoms/ActionButton.jsx'
9
10
  import SizeOrDownload from '../atoms/SizeOrDownload.jsx'
@@ -66,6 +67,18 @@ import { nearestRatio } from '../utilities/ratios.js'
66
67
  * `client.buckets()` (kol-media-client ≥0.2.0); a client with none is one bucket
67
68
  * and no dropdown. Settings are kol-r2b2's per-bucket model (`SETTINGS_BASE`),
68
69
  * controlled through `settings` / `onSettingsChange` so a consumer persists them.
70
+ *
71
+ * WHAT A DATABASE BESIDE THE BUCKET ADDS (the media D1 pass, 2026-09-25 — kol-olina keeps these
72
+ * in Cloudflare D1). Every verb is optional; a client without one hides that feature and nothing
73
+ * else changes. Keys are the page's own (bucket keys), `bucket` is the bucket id.
74
+ * tags listed objects carry `tags: string[]`; `setTags(key, tags, bucket)` writes them —
75
+ * preview-pane chips, "Tags…" / "Add tags to N…" in the menu, a Tags filter
76
+ * group, a Tags column under Fields
77
+ * text editing `readText(key, bucket) → { text, draft }` · `writeText(key, text, bucket)` ·
78
+ * `saveDraft(key, text | null, bucket)`; listed objects carry `hasDraft` — Edit in
79
+ * the pane and in Quick Look for markdown · json · yaml · text · code
80
+ * settings `loadSettings(bucket)` · `saveSettings(bucket, settings | null)` (null = reset) —
81
+ * used when the page is UNCONTROLLED; a `settings` prop still wins
69
82
  */
70
83
 
71
84
  /* THE ROW VIEW'S COLUMNS (user 2026-09-22). OFF by default and `rowColumns` turns them on: with
@@ -165,11 +178,32 @@ function useBucketLibrary({ client, bucket, defaults, settings: controlled, onSe
165
178
  const buckets = useMemo(() => client?.buckets?.() ?? [], [client])
166
179
  const bucketMeta = buckets.find((b) => b.id === bucket) ?? buckets[0] ?? { id: null, label: '', writable: false }
167
180
  const bucketId = bucketMeta.id
168
- const [own, setOwn] = useState(() => ({ ...SETTINGS_BASE, ...(defaults?.[bucketId] ?? defaults ?? {}) }))
181
+ const base = () => ({ ...SETTINGS_BASE, ...(defaults?.[bucketId] ?? defaults ?? {}) })
182
+ const [own, setOwn] = useState(base)
183
+ /* PERSONALISATION (the media D1 pass, 2026-09-25): an uncontrolled page whose client offers
184
+ * `loadSettings(bucket)` / `saveSettings(bucket, settings | null)` keeps each person's view
185
+ * settings THERE — kol-olina's D1 — instead of for this tab only. `null` is reset. A `settings`
186
+ * prop still wins: a consumer that owns them is left alone, exactly as before. */
187
+ const remote = !controlled && typeof client?.loadSettings === 'function'
188
+ useEffect(() => {
189
+ if (!remote) return undefined
190
+ let live = true
191
+ Promise.resolve(client.loadSettings(bucketId ?? undefined))
192
+ .then((got) => { if (live) setOwn({ ...base(), ...(got ?? {}) }) })
193
+ .catch(() => {})
194
+ return () => { live = false }
195
+ }, [remote, client, bucketId]) // eslint-disable-line react-hooks/exhaustive-deps
169
196
  const settings = controlled ?? own
170
197
  const setSettings = (next) => {
171
- if (next === null) { const back = { ...SETTINGS_BASE, ...(defaults?.[bucketId] ?? defaults ?? {}) }; if (!controlled) setOwn(back); onSettingsChange?.(back); return }
198
+ if (next === null) {
199
+ const back = base()
200
+ if (!controlled) setOwn(back)
201
+ if (remote) Promise.resolve(client.saveSettings?.(bucketId ?? undefined, null)).then((got) => got && setOwn({ ...back, ...got })).catch(() => {})
202
+ onSettingsChange?.(back)
203
+ return
204
+ }
172
205
  if (!controlled) setOwn(next)
206
+ if (remote) Promise.resolve(client.saveSettings?.(bucketId ?? undefined, next)).catch(() => {})
173
207
  onSettingsChange?.(next)
174
208
  }
175
209
  const [loaded, setLoaded] = useState({ key: null, bucket: null, objects: [], error: null })
@@ -213,6 +247,30 @@ function ImageFrame({ src, vector }) {
213
247
  }
214
248
 
215
249
 
250
+ /* TAGS IN THE PREVIEW PANE (the media D1 pass, 2026-09-25). The file's tags as removable chips and
251
+ * one field that adds on Enter — comma-separated adds several. Tags are the CLIENT's (`setTags`);
252
+ * without it, or on a read-only bucket, the chips show and nothing edits them. */
253
+ function TagEditor({ tags = [], onChange }) {
254
+ const add = (raw) => {
255
+ const next = raw.split(',').map((t) => t.trim().toLowerCase()).filter(Boolean)
256
+ if (next.length) onChange([...new Set([...tags, ...next])])
257
+ }
258
+ if (!onChange && !tags.length) return null
259
+ return (
260
+ <div className="flex flex-col gap-2">
261
+ <span className="kol-mono-12 text-fg-48">Tags</span>
262
+ {tags.length > 0 && (
263
+ <div className="flex flex-wrap gap-1">
264
+ {tags.map((t) => (
265
+ <Tag key={t} variant="secondary" size="sm" onRemove={onChange ? () => onChange(tags.filter((x) => x !== t)) : undefined}>{t}</Tag>
266
+ ))}
267
+ </div>
268
+ )}
269
+ {onChange && <Input size="xs" value="" placeholder="Add a tag" aria-label="Add a tag" onCommit={add} />}
270
+ </div>
271
+ )
272
+ }
273
+
216
274
  /* `depth` indents a row under the folder it was expanded from; `expanded`/`onToggle` draw the
217
275
  * disclosure twisty. A folder row without `onToggle` keeps the old behaviour exactly — one chevron
218
276
  * that navigates — so nothing that already renders these moves. */
@@ -232,10 +290,16 @@ function FolderRow({ name, icon = 'folder', onClick, onDoubleClick, depth = 0, e
232
290
  const dropProps = drag || dropFiles ? {
233
291
  onDragOver: (e) => { if (takesFiles(e) || (drag && !isFileDrag(e) && drag.canDrop(drag.path))) { e.preventDefault(); setOver(true) } },
234
292
  onDragLeave: () => setOver(false),
293
+ /* A ROW THAT CANNOT TAKE THE DROP LETS IT BUBBLE (the file-onto-file bug, 2026-09-25): the
294
+ * enclosing column / list allows the drag, so the drop still fires HERE, and moving into a
295
+ * FILE's key renamed the dragged file. Unguarded, it skipped `canDrop`; now the container
296
+ * takes it — dropped on a file, it lands in that file's folder, as Finder does. */
235
297
  onDrop: (e) => {
236
- e.preventDefault(); e.stopPropagation(); setOver(false)
237
- if (takesFiles(e)) dropFiles(e.dataTransfer.files)
238
- else if (drag && !isFileDrag(e)) drag.onDrop(drag.path)
298
+ setOver(false)
299
+ if (takesFiles(e)) { e.preventDefault(); e.stopPropagation(); dropFiles(e.dataTransfer.files); return }
300
+ if (!drag || isFileDrag(e) || !drag.canDrop(drag.path)) return
301
+ e.preventDefault(); e.stopPropagation()
302
+ drag.onDrop(drag.path)
239
303
  },
240
304
  } : {}
241
305
  return (
@@ -264,6 +328,7 @@ function FolderRow({ name, icon = 'folder', onClick, onDoubleClick, depth = 0, e
264
328
  line up or the header above them is a lie. The trailing chevron went with them: it
265
329
  promised a click that opens, and a click now SELECTS (opening is a double-click). */}
266
330
  {cols && <>
331
+ {cols.tags != null && <span className="shrink-0" style={{ width: cols.tags }} />}
267
332
  <span className="kol-mono-12 text-fg-32 shrink-0 truncate" style={{ width: cols.date }}>{meta}</span>
268
333
  <span className="kol-mono-12 text-fg-24 shrink-0 truncate" style={{ width: cols.size }}>—</span>
269
334
  </>}
@@ -340,6 +405,7 @@ function FileRow({ o, onClick, onDoubleClick, depth = 0, formatDate, thumb, cols
340
405
  </span>
341
406
  <span className="flex-1" />
342
407
  {cols && <>
408
+ {cols.tags != null && <span className="kol-mono-12 text-fg-48 shrink-0 truncate" style={{ width: cols.tags }}>{(o.tags ?? []).map((t) => `#${t}`).join(' ')}</span>}
343
409
  <span className="kol-mono-12 text-fg-32 shrink-0 truncate" style={{ width: cols.date }}>{formatDate?.(o.uploaded)}</span>
344
410
  <span className="kol-mono-12 text-fg-32 shrink-0 truncate" style={{ width: cols.size }}>{formatSize(o.size)}</span>
345
411
  </>}
@@ -451,12 +517,112 @@ function RowHeader({ sortBy = 'name', sortDir = 'asc', onSort, width, cols, onRe
451
517
  <span className="w-4 shrink-0" aria-hidden="true" />
452
518
  <span className="w-8 shrink-0" aria-hidden="true" />
453
519
  <span className="flex-1 min-w-0">{label('name', 'Name')}</span>
520
+ {/* Tags does not sort — a file has several — so its header is a plain label */}
521
+ {cols.tags != null && (
522
+ <div className="relative shrink-0 border-l" style={{ width: cols.tags, borderColor: 'var(--kol-oq-08)' }}>
523
+ {handle('tags')}
524
+ <span className="kol-helper-12 text-fg-48">Tags</span>
525
+ </div>
526
+ )}
454
527
  {column('date', 'Date')}
455
528
  {column('size', 'Size')}
456
529
  </div>
457
530
  )
458
531
  }
459
532
 
533
+ /* THE KINDS THAT OPEN AS TEXT — what `FileEditor` takes (the media D1 pass, 2026-09-25) */
534
+ const TEXT_KINDS = new Set(['markdown', 'json', 'yaml', 'text', 'code'])
535
+
536
+ /* FILE EDITING (the media D1 pass, 2026-09-25) — Quick Look's window with the text in it. Every
537
+ * pause writes a DRAFT through `client.saveDraft` (kol-olina keeps it in D1, beside the file), so
538
+ * closing the window or the tab loses nothing; ⌘S or Save writes the FILE (`client.writeText`) and
539
+ * clears the draft; Revert drops the draft and goes back to the file. The file itself changes only
540
+ * on Save — a draft is never what anyone else sees. */
541
+ const DRAFT_PAUSE = 800
542
+ function FileEditor({ o, client, bucket, onClose, onSaved, onDraft }) {
543
+ const [base, setBase] = useState(null) // the saved file's text; null while loading
544
+ const [text, setText] = useState('')
545
+ const [status, setStatus] = useState('loading') // loading · saved · draft · editing · saving · error
546
+ const timer = useRef(null)
547
+ const latest = useRef('')
548
+ const baseRef = useRef(null) // `base` for the unmount flush, which would otherwise see the first render's
549
+ const keep = (b) => { baseRef.current = b; setBase(b) }
550
+ useEffect(() => {
551
+ let live = true
552
+ client.readText(o.key, bucket).then(({ text: file, draft }) => {
553
+ if (!live) return
554
+ keep(file ?? '')
555
+ setText(draft ?? file ?? '')
556
+ latest.current = draft ?? file ?? ''
557
+ setStatus(draft != null ? 'draft' : 'saved')
558
+ }).catch((e) => live && setStatus(`error: ${e.message}`))
559
+ return () => { live = false }
560
+ }, [o.key]) // eslint-disable-line react-hooks/exhaustive-deps
561
+ const flushDraft = () => {
562
+ if (timer.current == null) return undefined
563
+ clearTimeout(timer.current); timer.current = null
564
+ const t = latest.current
565
+ const clean = t === baseRef.current
566
+ return Promise.resolve(client.saveDraft?.(o.key, clean ? null : t, bucket))
567
+ .then(() => { setStatus(clean ? 'saved' : 'draft'); onDraft?.(o.key, !clean) })
568
+ .catch(() => {})
569
+ }
570
+ useEffect(() => () => { flushDraft() }, []) // eslint-disable-line react-hooks/exhaustive-deps
571
+ const edit = (t) => {
572
+ setText(t); latest.current = t; setStatus('editing')
573
+ clearTimeout(timer.current)
574
+ timer.current = setTimeout(flushDraft, DRAFT_PAUSE)
575
+ }
576
+ const save = async () => {
577
+ clearTimeout(timer.current); timer.current = null
578
+ setStatus('saving')
579
+ try {
580
+ const res = await client.writeText(o.key, latest.current, bucket)
581
+ keep(latest.current); setStatus('saved')
582
+ onSaved?.(o.key, res)
583
+ } catch (e) { setStatus(`error: ${e.message}`) }
584
+ }
585
+ const revert = async () => {
586
+ clearTimeout(timer.current); timer.current = null
587
+ await client.saveDraft?.(o.key, null, bucket)
588
+ setText(baseRef.current ?? ''); latest.current = baseRef.current ?? ''; setStatus('saved')
589
+ onSaved?.(o.key, null)
590
+ }
591
+ const dirty = base != null && text !== base
592
+ /* ⌘S anywhere while the editor is open — not only with the caret in the text */
593
+ const saveRef = useRef(null)
594
+ saveRef.current = () => { if (baseRef.current != null && latest.current !== baseRef.current) save() }
595
+ useEffect(() => {
596
+ const onKey = (e) => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') { e.preventDefault(); saveRef.current() } }
597
+ window.addEventListener('keydown', onKey)
598
+ return () => window.removeEventListener('keydown', onKey)
599
+ }, [])
600
+ const label = { loading: 'Loading…', saved: 'Saved', draft: 'Draft — not saved to the file', editing: 'Editing…', saving: 'Saving…' }[status] ?? status
601
+ return (
602
+ <FullscreenOverlay open onClose={() => { flushDraft(); onClose() }} closeButton={false} scrim>
603
+ <QuickLookFrame title={o.displayKey ?? o.key.split('/').pop()} meta={label} onClose={() => { flushDraft(); onClose() }}
604
+ actions={(
605
+ <span className="flex items-center gap-2">
606
+ {dirty && <Button variant="nav" size="sm" onClick={revert}>Revert</Button>}
607
+ <Button variant="secondary" size="sm" onClick={save} disabled={!dirty || status === 'saving'}>Save</Button>
608
+ </span>
609
+ )}>
610
+ <textarea
611
+ value={text}
612
+ onChange={(e) => edit(e.target.value)}
613
+ disabled={base == null}
614
+ spellCheck={false}
615
+ aria-label={`Edit ${o.key.split('/').pop()}`}
616
+ className="kol-mono-12 block bg-transparent text-fg-default p-4 resize-none outline-none"
617
+ /* inline: Tailwind does not generate arbitrary values from package source (see the SVG box
618
+ in MediaInspector) — the window's own caps */
619
+ style={{ width: 'min(900px, var(--kol-ql-max-w))', height: 'min(640px, var(--kol-ql-media-h))' }}
620
+ />
621
+ </QuickLookFrame>
622
+ </FullscreenOverlay>
623
+ )
624
+ }
625
+
460
626
  /**
461
627
  * MediaInspector — the full-screen viewer for one file in a set: image, video
462
628
  * (scrubbable, audio on), audio, or a document page, in one `QuickLookFrame`, with
@@ -482,8 +648,9 @@ function RowHeader({ sortBy = 'name', sortDir = 'asc', onSort, width, cols, onRe
482
648
  * @param {Function} downloadUrl - `(key) => url` for the download control
483
649
  * @param {Set|Object} keySet - The bucket's key set, used to find a video's poster
484
650
  * @param {boolean} showNav - ‹ › and n / N in the header (default on); off when not paging a selection
651
+ * @param {Function} onEdit - `(file) => void` — an Edit action in the header for a text file (markdown · json · yaml · text · code); absent, no Edit
485
652
  */
486
- export function MediaInspector({ files, index, onClose, onPrev, onNext, mediaUrl, downloadUrl, keySet, showNav = true }) {
653
+ export function MediaInspector({ files, index, onClose, onPrev, onNext, mediaUrl, downloadUrl, keySet, showNav = true, onEdit }) {
487
654
  const o = files[index]
488
655
  const [dims, setDims] = useState(null)
489
656
  /* the window's size survives paging — Finder keeps it — so it lives here, not in the frame,
@@ -522,6 +689,11 @@ export function MediaInspector({ files, index, onClose, onPrev, onNext, mediaUrl
522
689
  * the header; bare at rest with a wash on hover, `sm` so the glyphs sit with the 12px text. */
523
690
  actions: (
524
691
  <span className="flex items-center gap-1">
692
+ {onEdit && TEXT_KINDS.has(kind) && (
693
+ <Tooltip label="Edit">
694
+ <Button variant="nav" size="sm" iconOnly="edit" onClick={() => onEdit(o)} aria-label="Edit" />
695
+ </Tooltip>
696
+ )}
525
697
  <CopyAction label="Copy URL" text={mediaUrl(o.key)} />
526
698
  <Tooltip label="Download">
527
699
  <Button variant="nav" size="sm" iconOnly="download" href={downloadUrl(o.key)} download={o.key.split('/').pop()} aria-label="Download" />
@@ -976,6 +1148,49 @@ export function MediaLibraryBrowse({
976
1148
  const press = useLongPress()
977
1149
  const modal = useModal()
978
1150
  const canWrite = !!fileActions && writable
1151
+ /* TAGS ARE THE CLIENT'S (the media D1 pass, 2026-09-25) — `client.setTags(key, tags, bucket)`
1152
+ * writes them, the listing carries them back as `o.tags`. The list is patched in place so a chip
1153
+ * lands at once; the next listing confirms it. */
1154
+ const canTag = writable && typeof client?.setTags === 'function'
1155
+ const saveTags = (key, tags) => runAction(async () => {
1156
+ await client.setTags(key, tags, bucketMeta.id ?? undefined)
1157
+ lib.setObjects((list) => list.map((o) => (o.key === key ? { ...o, tags } : o)))
1158
+ setPickedFile((f) => (f?.key === key ? { ...f, tags } : f))
1159
+ })
1160
+ const tagsOf = (key) => objects.find((o) => o.key === key)?.tags ?? []
1161
+ /* EDITING IS THE CLIENT'S TOO — `readText` / `writeText`, with `saveDraft` for the pauses */
1162
+ const canEdit = writable && typeof client?.readText === 'function' && typeof client?.writeText === 'function'
1163
+ const [editing, setEditing] = useState(null)
1164
+ const afterEdit = (key, res) => {
1165
+ lib.setObjects((list) => list.map((o) => (o.key === key ? { ...o, hasDraft: false, ...(res?.size != null && { size: res.size, uploaded: new Date().toISOString() }) } : o)))
1166
+ }
1167
+ const hasTags = canTag || objects.some((o) => o.tags?.length)
1168
+ /* what the preview pane adds under a file's facts, in every view */
1169
+ const detailsFor = (o) => {
1170
+ const tags = tagsOf(o.key)
1171
+ const editable = canEdit && TEXT_KINDS.has(kindOf(o))
1172
+ const hasDraft = objects.find((x) => x.key === o.key)?.hasDraft
1173
+ if (!editable && !canTag && !tags.length) return null
1174
+ return (
1175
+ <div className="flex flex-col gap-4">
1176
+ {editable && (
1177
+ <div className="flex items-center gap-3">
1178
+ <Button variant="secondary" size="sm" iconLeft="edit" onClick={() => setEditing(o)}>Edit</Button>
1179
+ {hasDraft && <span className="kol-mono-12 text-fg-48">Unsaved draft</span>}
1180
+ </div>
1181
+ )}
1182
+ {(canTag || tags.length > 0) && <TagEditor tags={tags} onChange={canTag ? (next) => saveTags(o.key, next) : undefined} />}
1183
+ </div>
1184
+ )
1185
+ }
1186
+ const doTags = async (keys) => {
1187
+ const one = keys.length === 1
1188
+ const current = one ? tagsOf(keys[0]).join(', ') : ''
1189
+ const answer = await modal.prompt(one ? 'Tags, separated by commas:' : `Add tags to ${keys.length} files, separated by commas:`, current, { okLabel: 'Save' })
1190
+ if (answer == null) return
1191
+ const typed = answer.split(',').map((t) => t.trim().toLowerCase()).filter(Boolean)
1192
+ for (const key of keys) await saveTags(key, one ? typed : [...new Set([...tagsOf(key), ...typed])])
1193
+ }
979
1194
  const [busyAction, setBusyAction] = useState(false)
980
1195
 
981
1196
  const runAction = async (fn) => {
@@ -1437,7 +1652,7 @@ export function MediaLibraryBrowse({
1437
1652
  const showRowPreview = folderView === 'rows' && (settings.rowPreview ?? true)
1438
1653
  const showGridPreview = view === 'grid' && (settings.rowPreview ?? true)
1439
1654
  /* OFF by default: a row is a name, and the facts live in the preview (user 2026-09-22) */
1440
- const rowCols = settings.rowColumns ? rowColWidths(settings) : null
1655
+ const rowCols = settings.rowColumns ? { ...rowColWidths(settings), ...(hasTags && { tags: settings.rowColumnWidths?.tags ?? 140 }) } : null
1441
1656
  const previewWidth = settings.columnWidths?.preview ?? 320
1442
1657
  const previewFile = pickedFile && pickedFile.key.startsWith(prefix) ? pickedFile : null
1443
1658
  const previewFolder = pickedFolder && pickedFolder.startsWith(prefix) ? pickedFolder : null
@@ -1459,6 +1674,7 @@ export function MediaLibraryBrowse({
1459
1674
  ) : previewFile ? (
1460
1675
  <ColumnPreview key={previewFile.key} o={previewFile} urlOf={(o) => mediaUrl(o.key)} kindOf={kindOf} kindLabel={KIND_LABEL}
1461
1676
  formatSize={formatSize} formatDate={formatDate} width={previewWidth}
1677
+ renderDetails={detailsFor}
1462
1678
  renderPreview={(o) => {
1463
1679
  if (isImage(o.contentType)) return <ImageFrame src={mediaUrl(o.key)} vector={o.contentType === 'image/svg+xml'} />
1464
1680
  const poster = posterFor(o.key, keySet)
@@ -1580,6 +1796,7 @@ export function MediaLibraryBrowse({
1580
1796
  * serves both: the wall renders it, the tree is scoped to the keys in it */
1581
1797
  const filterItems = isWall ? wallFiles : scoped.filter((o) => !isSystemFile(o.key)).map((o) => ({ ...o, kind: kindOf(o), displayKey: prefix ? o.key.slice(prefix.length) : o.key }))
1582
1798
  const filterKinds = [...new Set(filterItems.map((o) => o.kind))].sort()
1799
+ const filterTags = [...new Set(filterItems.flatMap((o) => o.tags ?? []))].sort()
1583
1800
 
1584
1801
  /* ── THE BODY, one of four (2026-09-22) ────────────────────────────────────────────────────
1585
1802
  * `treeBody` draws the columns or the rows over whatever object list it is handed — the whole
@@ -1652,6 +1869,7 @@ export function MediaLibraryBrowse({
1652
1869
  /* the PROP wins when a consumer passes one; otherwise the `···`
1653
1870
  owns it through settings (item 15) */
1654
1871
  stackView={stackView ?? settings.stackView ?? 'list'}
1872
+ renderDetails={(o) => detailsFor({ ...o, key: o.key.slice(VROOT.length) })}
1655
1873
  renderPreview={(o) => {
1656
1874
  const real = { ...o, key: o.key.slice(VROOT.length) }
1657
1875
  if (isImage(real.contentType)) return <ImageFrame src={mediaUrl(real.key)} vector={real.contentType === 'image/svg+xml'} />
@@ -2079,8 +2297,13 @@ export function MediaLibraryBrowse({
2079
2297
  )}
2080
2298
  {quickLook && (
2081
2299
  <MediaInspector files={quickLook.files} index={quickLook.index} onClose={closeQuickLook} mediaUrl={mediaUrl} downloadUrl={downloadUrl} keySet={keySet} showNav={rowSelection.size > 1}
2300
+ onEdit={canEdit ? (f) => { closeQuickLook(); setEditing(f) } : undefined}
2082
2301
  onPrev={() => stepQuickLook(-1)} onNext={() => stepQuickLook(1)} />
2083
2302
  )}
2303
+ {editing && (
2304
+ <FileEditor o={editing} client={client} bucket={bucketMeta.id ?? undefined} onClose={() => setEditing(null)} onSaved={afterEdit}
2305
+ onDraft={(key, has) => lib.setObjects((list) => list.map((x) => (x.key === key ? { ...x, hasDraft: has } : x)))} />
2306
+ )}
2084
2307
  {settingsOpen && (
2085
2308
  <MediaSettings bucketMeta={bucketMeta} settings={settings} profile={profile} onChange={setSettings} onReset={() => setSettings(null)} onClose={() => setSettingsOpen(false)} settingsFooter={settingsFooter} />
2086
2309
  )}
@@ -2097,7 +2320,7 @@ export function MediaLibraryBrowse({
2097
2320
  title="Files"
2098
2321
  totalCount={filterItems.length}
2099
2322
  searchKeys={['displayKey']}
2100
- filterGroups={[{ label: 'Kind', key: 'kind', values: filterKinds }]}
2323
+ filterGroups={[{ label: 'Kind', key: 'kind', values: filterKinds }, ...(filterTags.length ? [{ label: 'Tags', key: 'tags', values: filterTags }] : [])]}
2101
2324
  mutuallyExclusiveFilters={['kind']}
2102
2325
  /* ONE FUNNEL, NOT TWO (2026-09-22): the crumb row's funnel mounts this bar AND opens
2103
2326
  * its panel, and the bar's own funnel closes the bar — the same switch from either
@@ -2179,6 +2402,7 @@ export function MediaLibraryBrowse({
2179
2402
  <>
2180
2403
  <MenuDropdownItem iconLeft={<Icon name="copy" size={14} />} onClick={() => doCopyUrl(many)}>Copy {many.length} URLs</MenuDropdownItem>
2181
2404
  <MenuDropdownItem iconLeft={<Icon name="download" size={14} />} onClick={() => doDownload(many)}>Download {many.length}</MenuDropdownItem>
2405
+ {canTag && <MenuDropdownItem iconLeft={<Icon name="hash-01" size={14} />} onClick={() => doTags(many)}>Add tags to {many.length}…</MenuDropdownItem>}
2182
2406
  </>
2183
2407
  )}
2184
2408
  {fileActions.remove && (
@@ -2222,6 +2446,9 @@ export function MediaLibraryBrowse({
2222
2446
  {target.type === 'file' && (
2223
2447
  <MenuDropdownItem iconLeft={<Icon name="download" size={14} />} onClick={() => doDownload([target.path])}>Download</MenuDropdownItem>
2224
2448
  )}
2449
+ {target.type === 'file' && canTag && (
2450
+ <MenuDropdownItem iconLeft={<Icon name="hash-01" size={14} />} onClick={() => doTags([target.path])}>Tags…</MenuDropdownItem>
2451
+ )}
2225
2452
  {!isLevel && fileActions.remove && (
2226
2453
  <>
2227
2454
  <MenuDropdownDivider />
@@ -0,0 +1,70 @@
1
+ import { Icon } from '@kolkrabbi/kol-icons'
2
+ import Button from '../atoms/Button.jsx'
3
+ import Divider from '../atoms/Divider.jsx'
4
+ import SplitToolButton from '../molecules/SplitToolButton.jsx'
5
+
6
+ /**
7
+ * ToolPalette — the editor's tool bar as one row (kol-fxr `shell/panels/ToolPalette.jsx`,
8
+ * editor-panels-the-held-specs A3, 2026-09-25). Four kinds of cell, all on one pinned-square rung:
9
+ *
10
+ * - `tool` — arms a mode; lit while `activeId` is its id
11
+ * - `action` — a one-shot operation (flip, rotate, duplicate)
12
+ * - `split` — a fold of variants: `SplitToolButton`. A fold of TOOLS arms (Shape); a fold with
13
+ * `action: true` runs its variants instead, the trigger re-running the last-picked one
14
+ * (Boolean); a variant with `action: true` inside a tool fold is a one-shot row (Text →
15
+ * Kinetic type)
16
+ * - `divider` — a hairline between groups
17
+ *
18
+ * The store is the consumer's (the source read `useTool` + `useComposeState` directly): what
19
+ * is armed, what each action does and **when an item is disabled** — needs a selection, needs
20
+ * two booleanable layers — all arrive in `items` and are never inferred here.
21
+ *
22
+ * `min-w-0 overflow-x-auto`: at a narrow width the row scrolls in its own box instead of
23
+ * painting over the rail beside it, as the source learned.
24
+ *
25
+ * @param {Object} props
26
+ * @param {Array<{kind: 'tool'|'action'|'split'|'divider', id?: string, icon?: string, label?: string, shortcut?: string, disabled?: boolean, action?: boolean, variants?: Array<{id: string, label: string, icon: string, shortcut?: string, action?: boolean}>}>} props.items - The row, left to right
27
+ * @param {string} props.activeId - The armed tool's id — a `tool` item's, or any variant's of a tool fold
28
+ * @param {Function} props.onSelect - (id) => void — a tool (or a tool variant) was armed
29
+ * @param {Function} props.onAction - (id) => void — an action (or an action variant) was run
30
+ * @param {'xs'|'sm'|'md'|'lg'} props.size - The rung — 22 · 26 · 32 · 40 (default: 'md', the source's 32)
31
+ * @param {ElementType} props.iconComponent - Glyph renderer receiving `{ name, size, className, style }` (default: DS `Icon`)
32
+ * @param {string} props.className - Additional classes on the row
33
+ */
34
+ const blur = (e) => e.currentTarget.blur()
35
+
36
+ const ToolPalette = ({
37
+ items = [],
38
+ activeId,
39
+ onSelect,
40
+ onAction,
41
+ size = 'md',
42
+ iconComponent = Icon,
43
+ className = '',
44
+ }) => (
45
+ <div className={`flex items-center gap-1 min-w-0 overflow-x-auto ${className}`.trim()}>
46
+ {items.map((it, i) => {
47
+ if (it.kind === 'divider') return <Divider key={`d${i}`} variant="vertical" height={20} className="mx-1.5 shrink-0" />
48
+ const tip = it.shortcut ? `${it.label} (${it.shortcut})` : it.label
49
+ if (it.kind === 'split') {
50
+ const variants = (it.variants ?? []).map((v) => (v.action && !it.action ? { ...v, onSelect: () => onAction?.(v.id) } : v))
51
+ const run = (id) => (it.action ? onAction : onSelect)?.(id)
52
+ return (
53
+ <SplitToolButton key={it.id} variants={variants} value={activeId} size={size} iconComponent={iconComponent}
54
+ active={!it.action && variants.some((v) => v.id === activeId && !v.action)}
55
+ onChange={run} onTrigger={it.action ? run : undefined}
56
+ disabled={it.disabled} blurOnClick aria-label={it.label} />
57
+ )
58
+ }
59
+ const isTool = it.kind === 'tool'
60
+ return (
61
+ <Button key={it.id} variant="ghost" size={size} quiet iconOnly={it.icon} iconComponent={iconComponent}
62
+ pressed={isTool ? activeId === it.id : undefined} disabled={it.disabled}
63
+ aria-label={it.label} title={tip}
64
+ onClick={(e) => { (isTool ? onSelect : onAction)?.(it.id); blur(e) }} />
65
+ )
66
+ })}
67
+ </div>
68
+ )
69
+
70
+ export default ToolPalette