@kolkrabbi/kol-component 0.203.0 → 0.205.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.203.0",
3
+ "version": "0.205.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",
@@ -1,27 +1,28 @@
1
- import Button from '../atoms/Button.jsx'
1
+ import { Icon } from '@kolkrabbi/kol-icons'
2
+ import SegmentedToggle from '../atoms/SegmentedToggle.jsx'
3
+ import { glyphSize } from '../hooks/glyphLadders.js'
2
4
 
3
5
  /**
4
- * AlignmentGrid — a six-cell alignment row: horizontal start/center/end then
5
- * vertical start/center/end, each a quiet icon Button that emits an
6
- * `(axis, mode)` alignment intent. Driven off a static config array (the
7
- * default 6, overridable via `items`). Presentation only — the consumer wires
8
- * `onAlign` to whatever "align these" means in its context (align a box to the
9
- * canvas bounds, align a multi-selection's common bbox, …).
6
+ * AlignmentGrid — the align control: TWO three-way strips, X and Y.
10
7
  *
11
- * Ported from the brand editor's AlignmentPanel with the store coupling
12
- * dropped (per lobby spec): `useComposeState().alignSelected` an `onAlign`
13
- * prop; the hand-rolled `kol-btn-quiet` buttons DS `Button` (quiet +
14
- * iconOnly) so the DS owns the button atom; `EditorIcon` the DS Icon
15
- * (through Button).
8
+ * Rebuilt on `SegmentedToggle` 2026-09-03 (`editor-chrome-review`, the user's
9
+ * own pass over the running editor: *"alignment isnt using segmentedtoggle?"*).
10
+ * It was six bare quiet icon Buttons in one `grid-cols-6` six loose controls
11
+ * where the thing itself is two three-way choices, which is what a segmented
12
+ * strip is for. The shape had been ruled on `editor-set-is-behind-its-source`
13
+ * and the PRESS TREATMENT was the open question; his reaction answered it.
16
14
  *
17
- * [icon-gap] The source's `align-h-{start,center,end}` / `align-v-{start,
18
- * center,end}` glyph names are NOT in the loader. Remapped to the closest
19
- * existing loader glyphs (stroke/layout): `align-horizontal-{left,center,
20
- * right}` and `align-vertical-{top,center,bottom}`. Same six align marks,
21
- * different names no visual gap.
15
+ * MOMENTARY, NOT A SELECTION. `value={null}` puts the strip in its stateless
16
+ * mode: no cell is ever lit, because "align left" is an action you fire, not a
17
+ * state the object is in — the object's alignment is not a property this reads
18
+ * back. The press is the cell's own `:active`, drawn in the theme
19
+ * (`.kol-seg-cell:active`), which is why no `tone` or `pressed` prop was minted
20
+ * for it: `tone` is the GROUND axis and says nothing about a momentary press.
22
21
  *
23
- * @param {(axis:'h'|'v', mode:'start'|'center'|'end') => void} onAlign fired on cell click
24
- * @param {Array} items [{ axis, mode, icon, title }] cells (default the standard 6)
22
+ * @param {(axis:'h'|'v', mode:'start'|'center'|'end') => void} onAlign - Fired on press
23
+ * @param {Array} items - `[{ axis, mode, icon, title }]` cells (default the standard 6, three per axis)
24
+ * @param {'xs'|'sm'|'md'|'lg'} size - The strips' rung (default 'sm')
25
+ * @param {string} className - Extra classes on the wrapper
25
26
  */
26
27
  const ALIGN_BUTTONS = [
27
28
  { axis: 'h', mode: 'start', icon: 'align-horizontal-left', title: 'Align left' },
@@ -32,22 +33,37 @@ const ALIGN_BUTTONS = [
32
33
  { axis: 'v', mode: 'end', icon: 'align-vertical-bottom', title: 'Align bottom' },
33
34
  ]
34
35
 
35
- export default function AlignmentGrid({ onAlign, items = ALIGN_BUTTONS }) {
36
+ export default function AlignmentGrid({ onAlign, items = ALIGN_BUTTONS, size = 'sm', className = '' }) {
37
+ const strip = (axis) => items.filter((b) => b.axis === axis)
38
+
36
39
  return (
37
- <div className="grid grid-cols-6 gap-1">
38
- {items.map((b) => (
39
- <Button
40
- key={`${b.axis}-${b.mode}`}
41
- quiet
42
- iconOnly={b.icon}
43
- iconSize={16}
44
- onClick={() => onAlign?.(b.axis, b.mode)}
45
- title={b.title}
46
- aria-label={b.title}
47
- className="w-full"
48
- style={{ height: 28, padding: 6 }}
49
- />
50
- ))}
40
+ <div className={`kol-alignment-grid flex flex-col gap-1 ${className}`.trim()}>
41
+ {['h', 'v'].map((axis) => {
42
+ const cells = strip(axis)
43
+ if (!cells.length) return null
44
+ return (
45
+ <SegmentedToggle
46
+ key={axis}
47
+ variant="filled"
48
+ size={size}
49
+ /* stateless: never lit, the press IS the feedback */
50
+ value={null}
51
+ ariaLabel={axis === 'h' ? 'Align horizontally' : 'Align vertically'}
52
+ /* the cell's `label` takes a NODE, so the glyph needs no new prop
53
+ * on SegmentedToggle — and the glyph size comes from the ADJACENT
54
+ * ladder, never a number typed here */
55
+ options={cells.map((b) => ({
56
+ value: `${b.axis}-${b.mode}`,
57
+ label: <Icon name={b.icon} size={glyphSize(size)} />,
58
+ ariaLabel: b.title,
59
+ }))}
60
+ onChange={(v) => {
61
+ const cell = cells.find((b) => `${b.axis}-${b.mode}` === v)
62
+ if (cell) onAlign?.(cell.axis, cell.mode)
63
+ }}
64
+ />
65
+ )
66
+ })}
51
67
  </div>
52
68
  )
53
69
  }
@@ -71,6 +71,10 @@ import { GRAB_COLUMN } from '../utilities/motion.js'
71
71
  * @param {Function} onColumnResize (index, px) => void — the column's index, or `'preview'`
72
72
  * @param {string} className extra classes on the browser
73
73
  *
74
+ * @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
75
+ * @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
76
+ * @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
77
+ *
74
78
  * BELOW `md` (768) THIS IS A DIFFERENT TREE — one full-width inline-expanding
75
79
  * list, not columns (`ColumnBrowserStackMode`, kol-r2b2 2026-09-03, user-ruled
76
80
  * inline expand over push). `height` / `defaultHeight` / `onHeightChange`,
@@ -134,13 +138,39 @@ const COL_ICON = { image: 'image', video: 'video', audio: 'file', playlist: 'vid
134
138
  * path indents until the name has no room. */
135
139
  const INDENT_CAP = 3
136
140
 
137
- function Row({ icon, label, active, cursor = false, trailing, onClick, muted = false, indent = 0, meta }) {
141
+ /* THE FOUR-ZONE ROW (ColumnBrowserMobileViews §1, kol-r2b2 2026-09-03) is the
142
+ * `zones` form. The first stack build inherited the DS Table's `12px 16px` with
143
+ * a 14px glyph in a 20px slot, and the user's read was *"kinda underwhelming…
144
+ * did you even look at the refs?"* — correctly, and the fault was the spec's.
145
+ * iOS Files and Dropbox both draw four:
146
+ *
147
+ * 1 · disclosure 14px, FOLDERS ONLY, its own tap target — tapping it
148
+ * expands in place, tapping the row opens. Without the split
149
+ * there is no way to peek into a folder without leaving the
150
+ * one you are in
151
+ * 2 · icon 44×44 at radius 5 — a folder glyph, or a real THUMBNAIL
152
+ * 3 · text name, then meta beneath
153
+ * 4 · trailing 20px, the row's own affordance
154
+ *
155
+ * Row height follows zone 2: 60px, not the table padding. The divider starts at
156
+ * zone 3's left edge, not the row's — both references.
157
+ *
158
+ * `zones`, `thumb`, `onDisclose`, `disclosed`, `indent` and `meta` are all inert
159
+ * without the stack: the columns pass none, so a desktop row is byte-identical
160
+ * to what it was. */
161
+ function Row({
162
+ icon, label, active, cursor = false, trailing, onClick, muted = false,
163
+ indent = 0, meta, zones = false, thumb, onDisclose, disclosed,
164
+ }) {
138
165
  return (
139
166
  <li
140
167
  /* Row metrics are the DS Table's (kol-components-organisms.css .kol-table-cell-*):
141
- * 12px 16px padding, mono 12, an oq-08 hairline between rows, none after the last. */
168
+ * 12px 16px padding, mono 12, an oq-08 hairline between rows, none after the last.
169
+ * The `zones` row sets its own 60px instead — a 44px thumbnail in a
170
+ * 12px-padded row is three times too airy. */
142
171
  /* `cursor` = the keyboard row, drawn with the hover fill so ↑/↓ always shows where you are. */
143
- className={`kol-column-browser-row flex items-center gap-2 px-4 py-3 cursor-pointer transition-colors${
172
+ className={`kol-column-browser-row flex items-center gap-2 cursor-pointer transition-colors${
173
+ zones ? ' kol-column-browser-row--zones px-4' : ' px-4 py-3'}${
144
174
  active ? ' is-selected' : ''}${cursor ? ' is-cursor' : ''} ${
145
175
  active || cursor || !muted ? 'text-fg-default' : 'text-fg-48'
146
176
  }`}
@@ -150,12 +180,39 @@ function Row({ icon, label, active, cursor = false, trailing, onClick, muted = f
150
180
  * scrollers does not */
151
181
  style={indent ? { paddingLeft: `calc(var(--kol-spacing-4) + ${indent} * var(--kol-spacing-5))` } : undefined}
152
182
  >
153
- <span className="w-5 shrink-0 flex items-center justify-center text-fg-48">
154
- <Icon name={icon} size={14} />
155
- </span>
183
+ {/* ZONE 1 disclosure, its own tap target. A bare span holds the column
184
+ for files so every icon in the list lands on one x. */}
185
+ {zones && (onDisclose ? (
186
+ <button
187
+ type="button"
188
+ onClick={(e) => { e.stopPropagation(); onDisclose() }}
189
+ aria-expanded={!!disclosed}
190
+ aria-label={disclosed ? 'Collapse folder' : 'Expand folder'}
191
+ className="kol-column-browser-disclose shrink-0 inline-flex items-center justify-center"
192
+ style={{ width: 14, alignSelf: 'stretch', color: 'var(--kol-accent-primary)' }}
193
+ >
194
+ <Icon name={disclosed ? 'chevron-down' : 'chevron-right'} size={12} />
195
+ </button>
196
+ ) : (
197
+ <span aria-hidden="true" className="shrink-0" style={{ width: 14 }} />
198
+ ))}
199
+
200
+ {/* ZONE 2 — the icon box, or the thumbnail the consumer supplies */}
201
+ {zones ? (
202
+ <span
203
+ className="kol-column-browser-thumb shrink-0 inline-flex items-center justify-center overflow-hidden"
204
+ style={{ width: 44, height: 44, borderRadius: 5, background: thumb ? 'var(--kol-oq-04)' : 'transparent' }}
205
+ >
206
+ {thumb ?? <Icon name={icon} size={20} className="text-fg-48" />}
207
+ </span>
208
+ ) : (
209
+ <span className="w-5 shrink-0 flex items-center justify-center text-fg-48">
210
+ <Icon name={icon} size={14} />
211
+ </span>
212
+ )}
213
+
214
+ {/* ZONE 3 — name over meta */}
156
215
  {meta ? (
157
- /* size · date under the name — there is no preview column at this
158
- * width to carry the facts (ColumnBrowserStackMode item 5) */
159
216
  <span className="flex-1 min-w-0 flex flex-col gap-0.5">
160
217
  <span className="kol-mono-12 truncate">{label}</span>
161
218
  <span className="kol-helper-10 text-fg-48 truncate">{meta}</span>
@@ -163,7 +220,9 @@ function Row({ icon, label, active, cursor = false, trailing, onClick, muted = f
163
220
  ) : (
164
221
  <span className="kol-mono-12 flex-1 truncate">{label}</span>
165
222
  )}
166
- {trailing}
223
+
224
+ {/* ZONE 4 */}
225
+ {zones ? <span className="shrink-0 inline-flex justify-end" style={{ width: 20 }}>{trailing}</span> : trailing}
167
226
  </li>
168
227
  )
169
228
  }
@@ -292,6 +351,27 @@ export default function ColumnBrowser({
292
351
  columnWidth = 260,
293
352
  columnWidths,
294
353
  onColumnResize,
354
+ /* THE TWO SEAMS THE MOBILE TICKET'S RULINGS POINT AT (ColumnBrowserMobileViews,
355
+ * kol-r2b2 2026-09-03). Both are questions the DS must not answer for a
356
+ * consumer, so neither is computed here:
357
+ *
358
+ * `thumbnailFor(o)` — a node for the 44px box, or null for the kind glyph.
359
+ * WHERE a thumbnail comes from is the consumer's and it is not free: R2 and
360
+ * B2 serve originals, so a 44px tile can mean downloading a 2 MB JPEG. That
361
+ * repo's own mitigations (lazy originals, a resolution-set that picks the
362
+ * smallest variant) and whether to buy image resizing are its calls.
363
+ *
364
+ * `folderMeta(prefix)` — the folder's own meta line, `date · N items` in the
365
+ * references. Counting `objects` by prefix is O(n) per folder against a
366
+ * 3443-object bucket, and that repo already passes a baked folder tree
367
+ * carrying files and bytes. A seam, not a computation — the ticket says so
368
+ * and it is right. */
369
+ thumbnailFor,
370
+ folderMeta,
371
+ /* `list` (default) or `grid` below the breakpoint — item 14. 3-up, and for a
372
+ * media bucket the thumbnail IS the tile, so it carries counts and sizes
373
+ * rather than dates (§2). Above the breakpoint the columns are unaffected. */
374
+ stackView = 'list',
295
375
  autoFocus = false,
296
376
  className = '',
297
377
  }) {
@@ -525,6 +605,41 @@ export default function ColumnBrowser({
525
605
  </span>
526
606
  </button>
527
607
  )}
608
+ {stackView === 'grid' ? (
609
+ /* THE GRID (item 14). Flat — one level at a time, no inline expand:
610
+ a grid of tiles has nowhere to put a child list, which is why both
611
+ references drop disclosure in this view and navigate by tap. */
612
+ <div className="kol-column-browser-grid flex-1 overflow-y-auto">
613
+ {rows.filter((r) => r.kind !== 'empty').map((r) => {
614
+ const isFolder = r.kind === 'folder'
615
+ const o = r.o
616
+ return (
617
+ <button
618
+ key={r.key}
619
+ type="button"
620
+ className={`kol-column-browser-tile${!isFolder && shown?.key === o.key ? ' is-selected' : ''}`}
621
+ onClick={() => {
622
+ if (isFolder) { onPrefix(r.level + r.name); return }
623
+ const files = itemsAt(r.level ?? '').filter((it) => it.type === 'file').map((it) => it.o)
624
+ pick(o)
625
+ onQuickLook?.({ files: files.length ? files : [o], index: Math.max(0, files.findIndex((f) => f.key === o.key)) })
626
+ }}
627
+ >
628
+ <span className="kol-column-browser-tile-box">
629
+ {isFolder
630
+ ? <Icon name="folder" size={28} className="text-fg-48" />
631
+ : (thumbnailFor?.(o) ?? <Icon name={COL_ICON[kindOf(o)] || 'file'} size={28} className="text-fg-48" />)}
632
+ </span>
633
+ <span className="kol-mono-12 truncate">{isFolder ? r.name.replace(/\/$/, '') : (o.displayKey ?? o.key)}</span>
634
+ {/* counts for a folder, size for a file — never dates (§2) */}
635
+ <span className="kol-helper-10 text-fg-48 truncate">
636
+ {isFolder ? folderMeta?.(r.level + r.name, 'grid') : (o.size != null ? formatSize(o.size) : '')}
637
+ </span>
638
+ </button>
639
+ )
640
+ })}
641
+ </div>
642
+ ) : (
528
643
  <ul className="kol-column-browser-column flex-1 overflow-y-auto">
529
644
  {rows.map((r) =>
530
645
  r.kind === 'empty' ? (
@@ -532,20 +647,25 @@ export default function ColumnBrowser({
532
647
  ) : r.kind === 'folder' ? (
533
648
  <Row
534
649
  key={r.key}
650
+ zones
535
651
  icon="folder"
536
652
  label={r.name.replace(/\/$/, '')}
537
653
  indent={r.depth}
538
654
  active={r.open}
539
- trailing={<Icon name={r.open ? 'chevron-down' : 'chevron-right'} size={12} className="text-fg-32" />}
540
- /* the same chevron opens and collapses tapping the open
541
- * folder returns to its parent level, which is the whole
542
- * inline-expand gesture */
543
- onClick={() => onPrefix(r.open ? r.level : r.level + r.name)}
655
+ meta={folderMeta?.(r.level + r.name)}
656
+ /* DISCLOSURE IS ITS OWN TARGET (item 11): the chevron expands
657
+ * in place, the ROW opens the folder. One control doing both is
658
+ * what left no way to peek without leaving where you are. */
659
+ disclosed={r.open}
660
+ onDisclose={() => onPrefix(r.open ? r.level : r.level + r.name)}
661
+ onClick={() => onPrefix(r.level + r.name)}
544
662
  />
545
663
  ) : (
546
664
  <Row
547
665
  key={r.key}
666
+ zones
548
667
  icon={COL_ICON[kindOf(r.o)] || 'file'}
668
+ thumb={thumbnailFor?.(r.o)}
549
669
  label={r.o.displayKey ?? r.o.key}
550
670
  indent={r.depth}
551
671
  meta={metaOf(r.o)}
@@ -563,6 +683,7 @@ export default function ColumnBrowser({
563
683
  ),
564
684
  )}
565
685
  </ul>
686
+ )}
566
687
  </div>
567
688
  )
568
689
  }
@@ -1,4 +1,5 @@
1
1
  import { useState } from 'react'
2
+ import { toneClass } from './tone.js'
2
3
  import {
3
4
  useFloating,
4
5
  autoUpdate,
@@ -189,6 +190,14 @@ export function PopoverPanel({
189
190
  panel = true,
190
191
  modal = false,
191
192
  focus = true,
193
+ /* THE FLOATING SURFACE'S GROUND (editor-chrome-review, kol-fxr 2026-09-03 —
194
+ * the user on the Editor / Labs / Randomiser list: *"can we change it border
195
+ * oq-04 and background as tone prop?"*). The panel painted
196
+ * `surface-secondary` unconditionally, which in dark is LIGHTER than the bar
197
+ * it drops from, so the layering read inverted. Unset it still does exactly
198
+ * that, so nothing existing moves; a tone paints that tone's ground instead,
199
+ * through the same `toneClass` every other control uses. */
200
+ tone,
192
201
  className = '',
193
202
  style: extraStyle,
194
203
  }) {
@@ -199,7 +208,7 @@ export function PopoverPanel({
199
208
  * carries the stacking guarantee — floats must top overlay chrome
200
209
  * (.kol-overlay z-100), or a dropdown inside a FullscreenOverlay renders
201
210
  * under the sheet and its options can't be clicked. */
202
- const cls = ['kol-popover-float', panel && 'kol-popover', className].filter(Boolean).join(' ')
211
+ const cls = ['kol-popover-float', panel && 'kol-popover', tone && toneClass(tone), className].filter(Boolean).join(' ')
203
212
 
204
213
  /* `data-editor-keep-selection` mirrors the marker on the EditorShell
205
214
  * root. Popovers render via FloatingPortal (mounted on <body>, outside