@kolkrabbi/kol-component 0.210.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.210.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",
@@ -103,38 +103,58 @@ const defaultFormatSize = (bytes) => {
103
103
  return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`
104
104
  }
105
105
 
106
- /* THE STACK'S ROW ORDERwalked, not iterated, and lifted out of the component
107
- * so it is reachable by a check (the defect below is invisible to every
108
- * structural assertion: the rows are all present and each carries the right
109
- * `depth`, they are merely in the wrong ORDER).
106
+ /* THE STACK'S ROW LISTthe CURRENT LEVEL'S CONTENTS, and nothing above it.
110
107
  *
111
- * The open folder's children have to land DIRECTLY under its own row. That
112
- * adjacency IS inline expand, and inline expand is the whole ruling. A flat
113
- * loop over `openPath` appends each level after the one before it, so an open
114
- * folder's children surfaced below its last SIBLING and below that level's
115
- * files (D1, kol-r2b2 2026-09-04) — which read as an indent bug rather than a
116
- * 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.
117
111
  *
118
- * @param {object[]} objects the flat key space
119
- * @param {string[]} openPath the open levels, shallowest first
120
- * @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)
121
142
  * @returns {object[]} rows — `folder` | `file` | `empty`, each with its `depth`
122
143
  */
123
- export function stackRows(objects, openPath, partition) {
144
+ export function stackRows(objects, base, isExpanded, partition, cap = INDENT_CAP) {
124
145
  const rows = []
125
- const walk = (depth) => {
126
- const level = openPath[depth]
146
+ const walk = (level, depth) => {
127
147
  const { folders, files } = partition(objects.filter((o) => o.key.startsWith(level)), level)
128
- const openFolder = openPath[depth + 1]?.slice(level.length) ?? null
129
148
  folders.forEach((f) => {
130
- const isOpen = f === openFolder
131
- rows.push({ kind: 'folder', key: level + f, level, name: f, depth, open: isOpen })
132
- 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)
133
153
  })
134
154
  files.forEach((o) => rows.push({ kind: 'file', key: o.key, o, depth, level }))
135
155
  if (!folders.length && !files.length) rows.push({ kind: 'empty', key: level + '·empty', depth })
136
156
  }
137
- walk(0)
157
+ walk(base, 0)
138
158
  return rows
139
159
  }
140
160
 
@@ -186,11 +206,17 @@ const COL_ICON = { image: 'image', video: 'video', audio: 'file', playlist: 'vid
186
206
  * the deepest column that holds one is full strength, every column on the way there is the trail. */
187
207
  /* `indent` and `meta` serve the STACK mode and are inert without it: the
188
208
  * columns pass neither, so a desktop row is byte-identical to what it was. */
189
- /* Three levels of indent, then the list re-bases and the back control takes
190
- * over — the user's cap (ColumnBrowserStackMode, 2026-09-03): a deep bucket
191
- * 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. */
192
213
  const INDENT_CAP = 3
193
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
+
194
220
  /* THE FOUR-ZONE ROW (ColumnBrowserMobileViews §1, kol-r2b2 2026-09-03) is the
195
221
  * `zones` form. The first stack build inherited the DS Table's `12px 16px` with
196
222
  * a 14px glyph in a 20px slot, and the user's read was *"kinda underwhelming…
@@ -250,13 +276,23 @@ function Row({
250
276
  <span aria-hidden="true" className="shrink-0" style={{ width: 14 }} />
251
277
  ))}
252
278
 
253
- {/* 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. */}
254
290
  {zones ? (
255
291
  <span
256
292
  className="kol-column-browser-thumb shrink-0 inline-flex items-center justify-center overflow-hidden"
257
- 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' }}
258
294
  >
259
- {thumb ?? <Icon name={icon} size={20} className="text-fg-48" />}
295
+ {thumb ?? <Icon name={icon} size={ZONE_BOX} className="text-fg-48" />}
260
296
  </span>
261
297
  ) : (
262
298
  <span className="w-5 shrink-0 flex items-center justify-center text-fg-48">
@@ -465,6 +501,7 @@ export default function ColumnBrowser({
465
501
  }
466
502
  const pickedByCollapse = useRef(false)
467
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])
468
505
  const levelsOf = (pfx) => {
469
506
  const out = ['']
470
507
  if (pfx) {
@@ -499,6 +536,19 @@ export default function ColumnBrowser({
499
536
  * kol-r2b2 2026-08-27): at rest it sat on row 0 beside the open folder and
500
537
  * read as a second selection. Arrows arm it; a click seeds it. */
501
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
+ })
502
552
  const rootRef = useRef(null)
503
553
  /* `autoFocus` (ColumnBrowserSeams, kol-r2b2 2026-08-28): the arrow keys were dead until a row was
504
554
  * clicked — nothing focused the root on mount, and the consumer reached into the DOM for it. Re-run
@@ -607,29 +657,33 @@ export default function ColumnBrowser({
607
657
  const stack = useMediaQuery('(max-width: 767px)')
608
658
 
609
659
  if (stack) {
610
- /* Re-base so the deepest open level is at most INDENT_CAP below the base:
611
- * everything above scrolls out of the list and is reached by the back
612
- * control instead. */
613
- const baseIdx = Math.max(0, levels.length - 1 - INDENT_CAP)
614
- const base = levels[baseIdx]
615
- const parent = baseIdx > 0 ? levels[baseIdx - 1] : null
616
- 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
617
666
 
618
- /* One flat row list, walked down the open path: each level's items, with
619
- * the open folder's children spliced in directly under it. */
620
- 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)
621
671
 
622
672
  const metaOf = (o) => [o.size != null && formatSize(o.size), formatDate(o.uploaded)].filter(Boolean).join(' · ')
623
673
 
624
674
  return (
625
675
  <div
626
- 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()}
627
682
  /* THE VIEWPORT IS THE HEIGHT (item 4). `height` is a value someone
628
683
  * dragged on a desktop; applied literally to a phone it painted a
629
684
  * black void the length of the viewport under two near-empty columns.
630
685
  * A desktop drag is not a phone measurement, so below the breakpoint
631
686
  * the stored one is ignored outright rather than clamped. */
632
- style={{ borderColor: 'var(--kol-oq-08)' }}
633
687
  >
634
688
  {parent != null && (
635
689
  <button
@@ -697,7 +751,10 @@ export default function ColumnBrowser({
697
751
  * in place, the ROW opens the folder. One control doing both is
698
752
  * what left no way to peek without leaving where you are. */
699
753
  disclosed={r.open}
700
- 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}
701
758
  onClick={() => onPrefix(r.level + r.name)}
702
759
  />
703
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" />