@kolkrabbi/kol-component 0.209.0 → 0.211.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.209.0",
3
+ "version": "0.211.0",
4
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",
@@ -244,6 +244,23 @@ export default function ContentCard({
244
244
  * One prop is not done until every derivation reads it. */
245
245
  const framed = box.border != null || (bg ?? box.bg) != null
246
246
 
247
+ /* NO COVER IS NOT A MISSING COVER (content-card-needs-no-cover,
248
+ * kol-client-olina 2026-09-04). `ContentMedia` turns absent children into an
249
+ * `AssetPlaceholder` on purpose — a card whose image failed must not collapse
250
+ * into a text blob, and that dashed MISSING plate is the honest answer for a
251
+ * media library. It is the wrong answer for a markdown note in a database
252
+ * row, which has no picture and never will: their `/notes` page drew a wall
253
+ * of dashed frames with nothing misconfigured.
254
+ *
255
+ * The card cannot tell the two apart — only the consumer knows whether a
256
+ * cover is owed — so it is declared, and `media={false}` declares it: no
257
+ * media slot at all, text takes the full width. `false` rather than a new
258
+ * prop because it is React's own idiom for "render nothing", and because
259
+ * `media={false}` today renders an EMPTY framed box, a state nobody can want.
260
+ * `media={null}` and an omitted `media` keep the placeholder, so every
261
+ * existing consumer is untouched. */
262
+ const noMedia = media === false
263
+
247
264
  const textNode = hasPlate ? (
248
265
  <div
249
266
  className="kol-card-plate relative"
@@ -251,7 +268,7 @@ export default function ContentCard({
251
268
  '--kol-plate-pad': padding,
252
269
  '--kol-plate-pad-md': box.padMd,
253
270
  padding,
254
- marginTop: box.layout === 'stack' ? box.mediaGap : undefined,
271
+ marginTop: box.layout === 'stack' && !noMedia ? box.mediaGap : undefined,
255
272
  borderTop: (plateRule ?? box.plateTop) ? '1px solid var(--kol-fg-04)' : undefined,
256
273
  background: box.layout === 'drawer' ? 'var(--kol-surface-inverse)' : box.plateBg,
257
274
  color: box.layout === 'drawer' ? 'var(--kol-fg-inverse)' : undefined,
@@ -311,11 +328,15 @@ export default function ContentCard({
311
328
  box.layout === 'stack' ? (
312
329
  <>
313
330
  {heroHeader}
314
- <div className="relative">
315
- <ContentMedia ratio={r} {...mediaProps}>{media}</ContentMedia>
316
- {controlNode}
317
- {controlStartNode}
318
- </div>
331
+ {/* the frame-corner controls go with the frame — `.kol-frame-control`
332
+ * positions against the media box, and there is none */}
333
+ {!noMedia && (
334
+ <div className="relative">
335
+ <ContentMedia ratio={r} {...mediaProps}>{media}</ContentMedia>
336
+ {controlNode}
337
+ {controlStartNode}
338
+ </div>
339
+ )}
319
340
  {textNode}
320
341
  </>
321
342
  ) : box.layout === 'fill-card' ? (
@@ -326,6 +347,7 @@ export default function ContentCard({
326
347
  * card gave each half 174 and the prose 126, and one module's specs
327
348
  * ran 1177px tall. The 50% half is a `md:` class now, not an inline
328
349
  * style — an inline flex-basis has no breakpoint. */}
350
+ {!noMedia && (
329
351
  <div
330
352
  className={`flex-1 min-w-0 min-h-0 relative overflow-hidden ${expanded ? 'max-md:flex-none max-md:aspect-[var(--kol-card-ratio)] md:flex-[0_0_50%]' : ''}`}
331
353
  style={{ ...(expanded ? { '--kol-card-ratio': r ?? '3 / 2' } : null), ...(doFlip ? { perspective: '1000px' } : null) }}
@@ -344,6 +366,7 @@ export default function ContentCard({
344
366
  {controlNode}
345
367
  {controlStartNode}
346
368
  </div>
369
+ )}
347
370
  {expanded ? (
348
371
  <div
349
372
  className="flex flex-1 flex-col justify-between overflow-auto"
@@ -355,11 +378,13 @@ export default function ContentCard({
355
378
  </>
356
379
  ) : box.layout === 'drawer' ? (
357
380
  <>
358
- <div className="relative h-full">
359
- <ContentMedia ratio={null} {...mediaProps}>{media}</ContentMedia>
360
- {controlNode}
361
- {controlStartNode}
362
- </div>
381
+ {!noMedia && (
382
+ <div className="relative h-full">
383
+ <ContentMedia ratio={null} {...mediaProps}>{media}</ContentMedia>
384
+ {controlNode}
385
+ {controlStartNode}
386
+ </div>
387
+ )}
363
388
  {/* the plate is INVERSE and hidden until hover — `kol-card-drawer` owns
364
389
  * the reveal so the transition sits with the rest of the chrome */}
365
390
  {textNode && <div className="kol-card-drawer">{textNode}</div>}
@@ -371,11 +396,13 @@ export default function ContentCard({
371
396
  * choreography (kol-theme `.kol-card.has-reveal`), the consumer owns the
372
397
  * node (what it says and which face it wears are never the family's). */
373
398
  <>
374
- <div className="kol-card-canvas-media absolute" style={{ inset: 0 }}>
375
- <ContentMedia ratio={null} {...mediaProps}>{media}</ContentMedia>
376
- {controlNode}
377
- {controlStartNode}
378
- </div>
399
+ {!noMedia && (
400
+ <div className="kol-card-canvas-media absolute" style={{ inset: 0 }}>
401
+ <ContentMedia ratio={null} {...mediaProps}>{media}</ContentMedia>
402
+ {controlNode}
403
+ {controlStartNode}
404
+ </div>
405
+ )}
379
406
  {textNode}
380
407
  {reveal != null && (
381
408
  <div className="kol-card-reveal absolute inset-0 flex items-center justify-center p-8 pointer-events-none" style={{ zIndex: 2 }}>{reveal}</div>
@@ -14,7 +14,8 @@ import ContentText from './ContentText.jsx'
14
14
  * (`default` is an alias of `file`). `roster` is the pickable
15
15
  * row: filled tile, no border or divider, a FIXED 56px height the
16
16
  * content fills, a 40px square thumb and two truncated lines.
17
- * @param {ReactNode} media thumb content (omit → placeholder)
17
+ * @param {ReactNode|false} media thumb content (omit → placeholder; `false` → no thumb at all — the
18
+ * content HAS no cover, as against one that failed to load)
18
19
  * @param {number|'fill'} thumb thumb edge px — overrides the ruled default; 0 hides; `'fill'` = a square
19
20
  * the height of the row's content, whatever the rung (WorkListingRowsAndFilters)
20
21
  * @param {string} ratio thumb aspect-ratio — overrides the ruled default.
@@ -175,7 +176,13 @@ export default function ContentRow({
175
176
  const aliased = ALIAS[variantProp] ?? variantProp
176
177
  const variant = aliased.startsWith('showcase') && layout ? LAYOUT_KEY[layout] ?? aliased : aliased
177
178
  const box = BOX[variant] ?? BOX.file
178
- const thumbPx = thumb ?? box.thumb
179
+ /* `media={false}` NO COVER, not a missing one (content-card-needs-no-cover,
180
+ * kol-client-olina 2026-09-04). The row could already hide its thumb with
181
+ * `thumb={0}`, but that is a SIZE answering a question about MEANING, and a
182
+ * consumer listing coverless documents should not have to say it two
183
+ * different ways on the card and the row. `media={false}` reads the same in
184
+ * both; `thumb` still wins when it is passed explicitly. */
185
+ const thumbPx = thumb ?? (media === false ? 0 : box.thumb)
179
186
  const padY = (paddingY != null ? `${paddingY}px` : String(box.pad)).trim().split(/\s+/)[0]
180
187
 
181
188
  /* The md: STEP is a custom property, not a Tailwind variant. Tailwind cannot
@@ -214,6 +214,21 @@ const GAPS = {
214
214
  * because ContentText is exported and a consumer can drive it directly. */
215
215
  const ALIAS = { default: 'file', print: 'catalog', work: 'showcase', typeface: 'showcaseCanvas' }
216
216
 
217
+ /* dev-only, and warns ONCE per offending prop — a card family renders these by
218
+ * the hundred in a list and a per-render warning would bury the first one. */
219
+ const warned = new Set()
220
+ function warnUnknownSlots(unknown) {
221
+ for (const key of Object.keys(unknown)) {
222
+ if (warned.has(key)) continue
223
+ warned.add(key)
224
+ console.warn(
225
+ `ContentText: "${key}" is not a text slot and was ignored. `
226
+ + 'Slots: title · body · eyebrow (kicker) · detail · date · size · meta · tags, '
227
+ + 'each with a matching *Class. This warning is dev-only.',
228
+ )
229
+ }
230
+ }
231
+
217
232
  export default function ContentText({
218
233
  variant: variantProp = 'file',
219
234
  form = 'card',
@@ -222,7 +237,16 @@ export default function ContentText({
222
237
  titleClass, bodyClass, eyebrowClass, kickerClass, detailClass, dateClass, sizeClass, metaClass, tagsClass,
223
238
  text,
224
239
  className = '',
240
+ ...unknown
225
241
  }) {
242
+ /* AN UNKNOWN SLOT SAYS SO (content-card-needs-no-cover aside, kol-client-olina
243
+ * 2026-09-04). They passed `summary`, every card rendered EMPTY, and nothing
244
+ * — build, lint or console — said a word; it cost an hour. A card's text
245
+ * slots are a closed vocabulary and a typo in one is indistinguishable from
246
+ * missing data, which is the worst kind of silent failure: the page looks
247
+ * built and is blank. Dev only, and it names the vocabulary rather than just
248
+ * the mistake, because "summary is not a slot" does not tell you `body` is. */
249
+ if (import.meta.env.DEV) warnUnknownSlots(unknown)
226
250
  /* `kicker` / `kickerClass` = aliases of `eyebrow` / `eyebrowClass` (2026-08-27) */
227
251
  eyebrow = eyebrow ?? kicker
228
252
  eyebrowClass = eyebrowClass ?? kickerClass
@@ -28,6 +28,11 @@ import DocPage from './DocPage.jsx'
28
28
  * `<img>` so it can read the dimensions). Text fetches cap at `textLimit`.
29
29
  *
30
30
  * @param {object} o the object — `{ key, contentType?, displayKey?, segmentCount? }`
31
+ * @param {string} text content ALREADY IN HAND — skips the fetch and joins the same render
32
+ * path. For a source with no file behind it (a database row). Pass
33
+ * `kind` with it: there is no extension to classify by
34
+ * @param {string} kind classification override — `markdown` · `json` · `yaml` · `code` ·
35
+ * `text` · `image` · `video` · `audio`. Wins over `kindOf(o)`
31
36
  * @param {Function} urlOf (o) => string — the object's public URL (default: `o.url`)
32
37
  * @param {string} poster a poster URL for HLS (the sibling image)
33
38
  * @param {Function} kindOf · extOf classification seams (defaults: the DS mediaKinds)
@@ -63,13 +68,38 @@ function useTextContent(url, enabled, limit) {
63
68
  return { ...result, loading: enabled && result.url !== url }
64
69
  }
65
70
 
66
- export default function KindPreview({ o, urlOf = (x) => x.url, poster, kindOf = defaultKindOf, extOf = defaultExtOf, kindLabel = KIND_LABEL, textLimit = TEXT_LIMIT }) {
67
- const kind = kindOf(o)
71
+ export default function KindPreview({ o, text: textProp, kind: kindProp, urlOf = (x) => x.url, poster, kindOf = defaultKindOf, extOf = defaultExtOf, kindLabel = KIND_LABEL, textLimit = TEXT_LIMIT }) {
72
+ /* CONTENT IN HAND, OR A URL TO FETCH (content-card-needs-no-cover,
73
+ * kol-client-olina 2026-09-04; shape ruled by kol-r2b2, whose component this
74
+ * is). A markdown note in a D1 row has no file and no URL behind it, so the
75
+ * fetch has nothing to pull — but the RENDER path is the one this component
76
+ * already owns, and a second previewer in the estate is what the lobby exists
77
+ * to prevent.
78
+ *
79
+ * `text` sits BESIDE `o`, not inside it: `o` is the media object — key, size,
80
+ * contentType, uploaded — and that shape is what `kindOf`, `posterFor` and
81
+ * the resolution-set grouping all read. Prose inside it would make the object
82
+ * mean two things.
83
+ *
84
+ * `kind` is not optional sugar. Classification reads the EXTENSION off the
85
+ * key, and a database row has no filename, so `text` alone lands on `other`
86
+ * and renders the right content as the wrong thing — worse than the
87
+ * placeholder it replaces. The two ship together or the seam half-works for
88
+ * the only caller that asked. Precedent: `urlOf`, `kindOf`, `kindLabel` and
89
+ * `partition` are already caller-supplied for the same reason — the DS does
90
+ * not decide what a consumer's object IS. */
91
+ const kind = kindProp ?? kindOf(o)
68
92
  const url = urlOf(o)
69
93
  const ext = extOf(o.key)
70
94
  const name = o.displayKey ?? o.key
71
95
  const isText = kind === 'text' || kind === 'code' || kind === 'markdown' || kind === 'json' || kind === 'yaml'
72
- const { loading, text, error, truncated } = useTextContent(url, isText, textLimit)
96
+ const fetched = useTextContent(url, isText && textProp == null, textLimit)
97
+ /* content in hand is never loading and never errors — there is nothing to
98
+ * wait for. It still honours `textLimit`, so one long note cannot outgrow a
99
+ * tile the way a fetched one cannot. */
100
+ const { loading, text, error, truncated } = textProp != null
101
+ ? { loading: false, error: null, text: String(textProp).slice(0, textLimit), truncated: String(textProp).length > textLimit }
102
+ : fetched
73
103
 
74
104
  if (kind === 'playlist') {
75
105
  return (
@@ -73,6 +73,10 @@ import { GRAB_COLUMN } from '../utilities/motion.js'
73
73
  * @param {string} className extra classes on the browser
74
74
  *
75
75
  * @param {Function} thumbnailFor (o) => node — the 44px tile's content below the breakpoint; null falls back to the kind glyph. WHERE a thumbnail comes from is the consumer's: R2 and B2 serve originals, so a 44px tile can mean a 2 MB download
76
+ * NOTE, both seams: the key/prefix they receive is the one the browser was GIVEN, verbatim. A
77
+ * consumer that mounts a virtual root above real storage (multi-bucket browse hands these
78
+ * `KOL-R2B2/<label>/…`) must strip its own prefix before it can look anything up or build a URL —
79
+ * the browser has no idea which part of a path is yours (kol-r2b2 2026-09-04).
76
80
  * @param {Function} folderMeta (prefix, view) => string — a folder's own meta line (`date · N items` in list, `N items` in grid). A SEAM, not a computation: counting by prefix is O(n) per folder, and a consumer that already holds a folder tree answers it for free
77
81
  * @param {'list'|'grid'} stackView the mobile view below the breakpoint (default 'list'); `grid` is 3-up tiles, flat — a grid has nowhere to put an inline child list
78
82
  *
@@ -99,38 +103,58 @@ const defaultFormatSize = (bytes) => {
99
103
  return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`
100
104
  }
101
105
 
102
- /* THE STACK'S ROW ORDERwalked, 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
+ /* THE STACK'S ROW LISTthe CURRENT LEVEL'S CONTENTS, and nothing above it.
106
107
  *
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.
108
+ * Lifted out of the component so the order is reachable by a check: the two
109
+ * defects this function has had were both invisible to a structural assertion
110
+ * every row present, every `depth` right, and the LIST still wrong.
113
111
  *
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 }
112
+ * D1 (2026-09-04) was order: a flat loop over the open path appended each level
113
+ * after the whole level above it, so an open folder's children landed after its
114
+ * last sibling and after that level's files. The walk is recursive now and a
115
+ * subtree is contiguous under its parent.
116
+ *
117
+ * `StackModeChromeAndAncestors` (kol-r2b2, 2026-09-04) was MEMBERSHIP, and the
118
+ * correction is theirs against their own spec: the browser walked from the root
119
+ * down the open path, so every ANCESTOR of the current level rendered as a row —
120
+ * at a bucket root, two rows and 120px of a 844px viewport restating the
121
+ * breadcrumb directly above them, with rows indented to 56px because depth
122
+ * counted from the root.
123
+ *
124
+ * The original spec held two rules that fight: "ancestors are reached by back,
125
+ * not by a scroll nobody can see" and "inline expand, capped at three levels of
126
+ * indent" — back means they are off screen, an indent cap implies they are on
127
+ * it. The resolution is that NAVIGATING and EXPANDING were fused into one piece
128
+ * of state and had to come apart:
129
+ *
130
+ * the ROW opens the folder → `onPrefix`, and the list re-bases to it
131
+ * the CHEVRON expands in place → local state, children spliced under it
132
+ *
133
+ * which is what item 11 already said the two targets were for. So `base` is the
134
+ * level the consumer navigated to, depth restarts there, and nothing above it is
135
+ * ever a row.
136
+ *
137
+ * @param {object[]} objects the flat key space
138
+ * @param {string} base the current level — its contents are the list
139
+ * @param {Function} isExpanded (folderPath) => boolean; `() => false` is a flat list
140
+ * @param {Function} partition (objects, level) => { folders, files }
141
+ * @param {number} cap deepest expansion below `base` (default INDENT_CAP)
117
142
  * @returns {object[]} rows — `folder` | `file` | `empty`, each with its `depth`
118
143
  */
119
- export function stackRows(objects, openPath, partition) {
144
+ export function stackRows(objects, base, isExpanded, partition, cap = INDENT_CAP) {
120
145
  const rows = []
121
- const walk = (depth) => {
122
- const level = openPath[depth]
146
+ const walk = (level, depth) => {
123
147
  const { folders, files } = partition(objects.filter((o) => o.key.startsWith(level)), level)
124
- const openFolder = openPath[depth + 1]?.slice(level.length) ?? null
125
148
  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)
149
+ const path = level + f
150
+ const open = depth < cap && isExpanded(path)
151
+ rows.push({ kind: 'folder', key: path, level, name: f, depth, open })
152
+ if (open) walk(path, depth + 1)
129
153
  })
130
154
  files.forEach((o) => rows.push({ kind: 'file', key: o.key, o, depth, level }))
131
155
  if (!folders.length && !files.length) rows.push({ kind: 'empty', key: level + '·empty', depth })
132
156
  }
133
- walk(0)
157
+ walk(base, 0)
134
158
  return rows
135
159
  }
136
160
 
@@ -182,11 +206,17 @@ const COL_ICON = { image: 'image', video: 'video', audio: 'file', playlist: 'vid
182
206
  * the deepest column that holds one is full strength, every column on the way there is the trail. */
183
207
  /* `indent` and `meta` serve the STACK mode and are inert without it: the
184
208
  * columns pass neither, so a desktop row is byte-identical to what it was. */
185
- /* Three levels of indent, then the list re-bases and the back control takes
186
- * over — the user's cap (ColumnBrowserStackMode, 2026-09-03): a deep bucket
187
- * path indents until the name has no room. */
209
+ /* Three levels of inline expansion below the current level, then the chevron
210
+ * stops expanding — the user's cap (ColumnBrowserStackMode, 2026-09-03): a deep
211
+ * path indents until the name has no room. Past it the ROW is the way down; it
212
+ * re-bases the list and the back control names what it left. */
188
213
  const INDENT_CAP = 3
189
214
 
215
+ /* The four-zone row's icon/thumbnail box. ONE constant because the glyph that
216
+ * stands in for a missing thumbnail is sized off it — two numbers here is how
217
+ * the box and its contents drifted apart in the first place. */
218
+ const ZONE_BOX = 44
219
+
190
220
  /* THE FOUR-ZONE ROW (ColumnBrowserMobileViews §1, kol-r2b2 2026-09-03) is the
191
221
  * `zones` form. The first stack build inherited the DS Table's `12px 16px` with
192
222
  * a 14px glyph in a 20px slot, and the user's read was *"kinda underwhelming…
@@ -246,13 +276,23 @@ function Row({
246
276
  <span aria-hidden="true" className="shrink-0" style={{ width: 14 }} />
247
277
  ))}
248
278
 
249
- {/* ZONE 2 — the icon box, or the thumbnail the consumer supplies */}
279
+ {/* ZONE 2 — the icon box, or the thumbnail the consumer supplies.
280
+ THE GLYPH FILLS THE BOX (StackModeChromeAndAncestors §3, kol-r2b2
281
+ 2026-09-04). It sat at 20 in a 44 box while a file's thumbnail filled
282
+ its 44, so the icon column read ragged — thumbnails filling their
283
+ squares, folder marks floating in the middle of theirs. The grid had
284
+ it right already (tile-box 103, thumb 103); this is the list catching
285
+ up, and it is what makes the column read as ONE RAIL.
286
+ Not a glyph-ladder rung and not a contradiction of one: SOLO pairs
287
+ 12/16/20/24 with the CONTROL squares 22/26/32/40. This is not a
288
+ control square — it is a MEDIA slot that a glyph stands in for when
289
+ the consumer has no thumbnail, which is why an <img> fills it. */}
250
290
  {zones ? (
251
291
  <span
252
292
  className="kol-column-browser-thumb shrink-0 inline-flex items-center justify-center overflow-hidden"
253
- style={{ width: 44, height: 44, borderRadius: 5, background: thumb ? 'var(--kol-oq-04)' : 'transparent' }}
293
+ style={{ width: ZONE_BOX, height: ZONE_BOX, borderRadius: 5, background: thumb ? 'var(--kol-oq-04)' : 'transparent' }}
254
294
  >
255
- {thumb ?? <Icon name={icon} size={20} className="text-fg-48" />}
295
+ {thumb ?? <Icon name={icon} size={ZONE_BOX} className="text-fg-48" />}
256
296
  </span>
257
297
  ) : (
258
298
  <span className="w-5 shrink-0 flex items-center justify-center text-fg-48">
@@ -461,6 +501,7 @@ export default function ColumnBrowser({
461
501
  }
462
502
  const pickedByCollapse = useRef(false)
463
503
  useEffect(() => { if (pickedByCollapse.current) { pickedByCollapse.current = false; return } if (picked) pick(null) }, [prefix]) // eslint-disable-line react-hooks/exhaustive-deps
504
+ useEffect(() => { setExpanded((prev) => (prev.size ? new Set() : prev)) }, [prefix])
464
505
  const levelsOf = (pfx) => {
465
506
  const out = ['']
466
507
  if (pfx) {
@@ -495,6 +536,19 @@ export default function ColumnBrowser({
495
536
  * kol-r2b2 2026-08-27): at rest it sat on row 0 beside the open folder and
496
537
  * read as a second selection. Arrows arm it; a click seeds it. */
497
538
  const [cursorActive, setCursorActive] = useState(false)
539
+ /* STACK EXPANSION IS NOT NAVIGATION (StackModeChromeAndAncestors, kol-r2b2
540
+ * 2026-09-04). The chevron expands a folder where it stands; the ROW opens it
541
+ * and re-bases the list. Fusing both into `prefix` is what put the whole
542
+ * ancestor chain on screen as rows. Local because it is a VIEW state below a
543
+ * breakpoint — a consumer persisting it would be storing a phone gesture, and
544
+ * a desktop never reads it. Cleared whenever the level changes: expansions
545
+ * belong to the list they were made in. */
546
+ const [expanded, setExpanded] = useState(() => new Set())
547
+ const toggleExpanded = (path) => setExpanded((prev) => {
548
+ const next = new Set(prev)
549
+ if (!next.delete(path)) next.add(path)
550
+ return next
551
+ })
498
552
  const rootRef = useRef(null)
499
553
  /* `autoFocus` (ColumnBrowserSeams, kol-r2b2 2026-08-28): the arrow keys were dead until a row was
500
554
  * clicked — nothing focused the root on mount, and the consumer reached into the DOM for it. Re-run
@@ -603,29 +657,33 @@ export default function ColumnBrowser({
603
657
  const stack = useMediaQuery('(max-width: 767px)')
604
658
 
605
659
  if (stack) {
606
- /* Re-base so the deepest open level is at most INDENT_CAP below the base:
607
- * everything above scrolls out of the list and is reached by the back
608
- * control instead. */
609
- const baseIdx = Math.max(0, levels.length - 1 - INDENT_CAP)
610
- const base = levels[baseIdx]
611
- const parent = baseIdx > 0 ? levels[baseIdx - 1] : null
612
- const openPath = levels.slice(baseIdx)
660
+ /* THE CURRENT LEVEL IS THE LIST (StackModeChromeAndAncestors, kol-r2b2
661
+ * 2026-09-04). `base` is where the consumer navigated to and depth restarts
662
+ * there; nothing above it is ever a row. The back control is the only place
663
+ * an ancestor appears, and it NAMES the one it returns to. */
664
+ const base = prefix
665
+ const parent = levels.length > 1 ? levels[levels.length - 2] : null
613
666
 
614
- /* One flat row list, walked down the open path: each level's items, with
615
- * the open folder's children spliced in directly under it. */
616
- const rows = stackRows(objects, openPath, partition)
667
+ /* The grid is FLAT a tile has nowhere to put a child list, so expansion
668
+ * is not offered there and the list is one level, as both references draw
669
+ * it. */
670
+ const rows = stackRows(objects, base, stackView === 'grid' ? () => false : (path) => expanded.has(path), partition)
617
671
 
618
672
  const metaOf = (o) => [o.size != null && formatSize(o.size), formatDate(o.uploaded)].filter(Boolean).join(' · ')
619
673
 
620
674
  return (
621
675
  <div
622
- className={`kol-column-browser kol-column-browser--stack flex flex-col border rounded ${className}`.trim()}
676
+ /* NO FRAME BELOW `md` (StackModeChromeAndAncestors §2). On desktop this
677
+ is a PANE — a thing with edges sitting in a page — so it is bordered
678
+ and rounded. In stack mode it IS the page's content: rows run to the
679
+ page's own padding and the only line is the divider between them.
680
+ Neither reference frames it. */
681
+ className={`kol-column-browser kol-column-browser--stack flex flex-col ${className}`.trim()}
623
682
  /* THE VIEWPORT IS THE HEIGHT (item 4). `height` is a value someone
624
683
  * dragged on a desktop; applied literally to a phone it painted a
625
684
  * black void the length of the viewport under two near-empty columns.
626
685
  * A desktop drag is not a phone measurement, so below the breakpoint
627
686
  * the stored one is ignored outright rather than clamped. */
628
- style={{ borderColor: 'var(--kol-oq-08)' }}
629
687
  >
630
688
  {parent != null && (
631
689
  <button
@@ -693,7 +751,10 @@ export default function ColumnBrowser({
693
751
  * in place, the ROW opens the folder. One control doing both is
694
752
  * what left no way to peek without leaving where you are. */
695
753
  disclosed={r.open}
696
- onDisclose={() => onPrefix(r.open ? r.level : r.level + r.name)}
754
+ /* the chevron expands where it stands and does NOT move the
755
+ * level; past the indent cap it has nothing left to offer, so
756
+ * the row is the only way down */
757
+ onDisclose={r.depth < INDENT_CAP ? () => toggleExpanded(r.key) : undefined}
697
758
  onClick={() => onPrefix(r.level + r.name)}
698
759
  />
699
760
  ) : (
@@ -285,14 +285,31 @@ function MediaSettings({ bucketMeta, settings, onChange, onReset, onClose, profi
285
285
  )
286
286
  }
287
287
 
288
- /* ── the header — bucket Dropdown · lock / the app's actions · settings ──── */
288
+ /* ── the header — bucket Dropdown · lock / the app's actions · settings ────
289
+ *
290
+ * THE ROW HAS TO GIVE AT 390 (StackModeChromeAndAncestors §4, kol-r2b2
291
+ * 2026-09-04). The title was `white-space: normal` in a box the fixed `w-48`
292
+ * dropdown had squeezed to 48px, so `KOL-R2B2` wrapped MID-TOKEN into two
293
+ * 36px lines — 72px of header spent breaking a word in half. Neither reference
294
+ * wraps a title.
295
+ *
296
+ * The dropdown's 192px was the actual cost: 192 of 390 before the title, the
297
+ * lock and the gear have had any. It keeps that width from `md` up, where the
298
+ * room exists, and shrinks below it. The title never wraps — it takes the room
299
+ * left and ellipsises, which is honest where a mid-token break is not.
300
+ *
301
+ * `headerActions` is a REAL slot and it is narrow here: at 390 the row holds
302
+ * the dropdown, the gear, and about ONE consumer icon. kol-r2b2 added a second
303
+ * and pushed the gear off the right edge — they resolved it by SWAPPING a
304
+ * control below `md` rather than adding one, which is the pattern to copy. If a
305
+ * consumer needs more than one at this width, it belongs in a sheet, not here. */
289
306
  function LibraryHeader({ title, buckets, bucketId, appRoot, onBucket, bucketMeta, writable, headerActions, onSettings }) {
290
307
  const options = buckets.length ? [{ value: 'all', label: `${title} · all` }, ...buckets.map((b) => ({ value: b.id, label: b.label }))] : []
291
308
  return (
292
309
  <header className="flex items-baseline justify-between gap-4">
293
- <h1 className="kol-sans-display-03">{title}</h1>
294
- <div className="flex items-center gap-2">
295
- {options.length > 0 && <Dropdown className="w-48" value={appRoot ? 'all' : bucketId} onChange={onBucket} options={options} />}
310
+ <h1 className="kol-sans-display-03 min-w-0 truncate">{title}</h1>
311
+ <div className="flex items-center gap-2 min-w-0">
312
+ {options.length > 0 && <Dropdown className="min-w-0 max-w-[45vw] md:w-48 md:max-w-none" value={appRoot ? 'all' : bucketId} onChange={onBucket} options={options} />}
296
313
  {headerActions}
297
314
  {!writable && bucketMeta.id && (
298
315
  <IconFrame name="lock" variant="primary" size="sm" title={`${bucketMeta.label} is read-only here`} aria-label="Read-only" />