@kolkrabbi/kol-component 0.317.2 → 0.317.4

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.317.2",
3
+ "version": "0.317.4",
4
4
  "description": "The core component library",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -38,7 +38,7 @@
38
38
  "react-dom": "^18.3.0 || ^19.0.0"
39
39
  },
40
40
  "devDependencies": {
41
- "@kolkrabbi/kol-icons": "^0.53.2"
41
+ "@kolkrabbi/kol-icons": "^0.53.5"
42
42
  },
43
43
  "files": [
44
44
  "src",
package/src/index.js CHANGED
@@ -150,7 +150,7 @@ export { default as ToolPalette } from './organisms/ToolPalette.jsx'
150
150
  export { default as DocumentEditor } from './organisms/DocumentEditor.jsx'
151
151
  export { default as SwatchControls, SwatchStack, EyedropPick } from './molecules/SwatchControls.jsx'
152
152
  export { default as TabsRow, TABBAR_H, TABBAR_PILL_INSET, TABBAR_PILL_TAB, tabbarPillWidth } from './molecules/TabsRow.jsx'
153
- /** @deprecated 2026-10-17 — alias of `TabsRow look="chip"` */
153
+ /** @deprecated 2026-10-17 — alias of `TabsRow variant="chip"` */
154
154
  export { default as TabChips } from './molecules/TabChips.jsx'
155
155
 
156
156
  // organisms
@@ -180,7 +180,7 @@ export { default as Carousel } from './molecules/Carousel.jsx'
180
180
  * stage reaches for it instead of re-typing the button markup, which is how the
181
181
  * three in-package copies (and kol-website's CarouselNavigation) happened. */
182
182
  export { default as EmblaNav } from './molecules/EmblaNav.jsx'
183
- /** @deprecated 2026-10-17 — alias of `TabsRow look="stacked" placement="phone"` */
183
+ /** @deprecated 2026-10-17 — alias of `TabsRow variant="stacked" placement="phone"` */
184
184
  export { default as MobileTabBar } from './molecules/MobileTabBar.jsx'
185
185
  export { default as ContentFilters } from './organisms/ContentFilters.jsx'
186
186
  export { default as PageHeader, PAGE_HEADER_ROLES } from './molecules/PageHeader.jsx'
@@ -1,10 +1,10 @@
1
1
  import TabsRow, { TABBAR_H } from './TabsRow.jsx'
2
2
 
3
3
  /**
4
- * @deprecated 2026-10-17 — alias of `TabsRow look="stacked" placement="phone"` (TabsStackedLayout). Drops when nobody imports it (04-retirements.md).
4
+ * @deprecated 2026-10-17 — alias of `TabsRow variant="stacked" placement="phone"` (TabsStackedLayout). Drops when nobody imports it (04-retirements.md).
5
5
  * MobileTabBar — Old name for the stacked phone bar. Same props: `tabs` (`{ value, label, icon }`) · `value` · `onChange` · `className`.
6
6
  */
7
7
  export { TABBAR_H }
8
8
  export default function MobileTabBar(props) {
9
- return <TabsRow look="stacked" placement="phone" {...props} />
9
+ return <TabsRow variant="stacked" placement="phone" {...props} />
10
10
  }
@@ -13,7 +13,7 @@ import TabsRow from './TabsRow.jsx'
13
13
  *
14
14
  * The slots, in the order they are drawn — an empty slot is not there:
15
15
  * row 1 toggles · views · count the series chips (Tag), the view switches (SegmentedToggle), the readout
16
- * row 2 jump the section tabs (TabsRow look="chip"), scrolling sideways when long
16
+ * row 2 jump the section tabs (TabsRow variant="chip"), scrolling sideways when long
17
17
  * row 3 legend a dot and a name per series, as many columns as series
18
18
  *
19
19
  * STICKY by default: `sticky top-0` on the page's z-sticky rung, a hairline under it, the surface painted so the
@@ -51,7 +51,7 @@ export default function PageControls({ toggles, views = [], count, jump, legend,
51
51
  )}
52
52
  {jump && (
53
53
  <div className="overflow-x-auto">
54
- <TabsRow look="chip" size="sm" ariaLabel={jump.label} value={jump.value} onChange={jump.onChange} tabs={jump.tabs} />
54
+ <TabsRow variant="chip" size="sm" ariaLabel={jump.label} value={jump.value} onChange={jump.onChange} tabs={jump.tabs} />
55
55
  </div>
56
56
  )}
57
57
  {legend && legend.length > 0 && (
@@ -1,9 +1,9 @@
1
1
  import TabsRow from './TabsRow.jsx'
2
2
 
3
3
  /**
4
- * @deprecated 2026-10-17 — alias of `TabsRow look="chip"` (TabsStackedLayout: one tab component). Drops when nobody imports it (04-retirements.md).
5
- * TabChips — Old name for TabsRow's chip look. Same props: `tabs` · `value` · `onChange` · `size` · `ariaLabel` · `className`.
4
+ * @deprecated 2026-10-17 — alias of `TabsRow variant="chip"` (TabsStackedLayout: one tab component). Drops when nobody imports it (04-retirements.md).
5
+ * TabChips — Old name for TabsRow's chip variant. Same props: `tabs` · `value` · `onChange` · `size` · `ariaLabel` · `className`.
6
6
  */
7
7
  export default function TabChips(props) {
8
- return <TabsRow look="chip" {...props} />
8
+ return <TabsRow variant="chip" {...props} />
9
9
  }
@@ -24,16 +24,21 @@ import { RAIL_ROW } from '../hooks/railRow.js'
24
24
  * @param {Function} onClose () => void — show + wire the leading close button
25
25
  * @param {Function} onMinimise () => void — show + wire the trailing minimise chevron
26
26
  *
27
- * ONE TAB COMPONENT, FOUR LOOKS (TabsStackedLayout, kol-monitor 2026-10-17 — four components drew one control):
27
+ * ONE TAB COMPONENT, FOUR VARIANTS (TabsStackedLayout, kol-monitor 2026-10-17 — four components drew one control). The prop
28
+ * is `variant`, the one word the DS uses for a look (control-chrome: "variant is the look"); `look` was an agent's coinage
29
+ * on the merge and is read as a silent alias until no published consumer passes it (the user, 2026-10-30: *"USE VARIANT"*).
28
30
  * @param {'scroll'|'wrap'} [overflow='scroll'] A ROW WIDER THAN ITS COLUMN (TabsRowOverflow, kol-website 2026-10-29 — six
29
31
  * underline tabs are 394px, so at 390 the page scrolled sideways and record wrapped the row in its own div): `scroll`
30
32
  * keeps one line that scrolls sideways with no scrollbar, `.kol-scroll-fade--x` fading the clipped edge(s), and the
31
33
  * active tab scrolled into view when it changes — arrow keys included; `wrap` breaks the tabs onto a second line.
32
- * Every look but `stacked`, which has its phone bar
33
- * @param {'underline'|'chip'|'text'|'stacked'} [look='underline']
34
+ * Every variant but `stacked`, which has its phone bar
35
+ * @param {'underline'|'chip'|'text'|'stacked'} [variant='underline']
36
+ * @param {string} [look] @deprecated 2026-10-30 — the old spelling of `variant`; `variant` wins when both are passed
37
+ * @param {'mono'|'sans'|'mono-light'} [type='mono'] `underline` only: the tab's type — `mono-light` is `sans`'s row on JetBrains Mono at 300 (his word, 2026-10-30)
34
38
  * @param {boolean} [filled=false] - `chip` only: every chip shows at rest (Button `tab-filled`), so the row reads as choices
35
- * underline — this row, as it always was; a tab's `icon` sits before its label. `size="md"` is the sans body
36
- * face with a 20px glyph in a 48px row (the user's ref, Adobe's tab strip, 2026-10-29)
39
+ * underline — this row, as it always was; a tab's `icon` sits before its label. `type` picks its type: `mono`
40
+ * (default, the mono-12 row) or `sans` (the neutral sans at 300, a 20px glyph in a 40px row — the
41
+ * user's ref, Adobe's tab strip, 2026-10-29; named `type` by him, 2026-10-30)
37
42
  * chip — the active tab a filled chip, the rest quiet (was `TabChips`); `size` is the control rung
38
43
  * text — flat helper-type tabs, the active one bright (was kol-shell's `TabStrip`); `size` is the
39
44
  * helper size (default 14), `tracked` adds 1px tracking
@@ -71,13 +76,14 @@ export const tabbarPillWidth = (count) => count * TABBAR_PILL_TAB + 14
71
76
  /* the collapse's motion — one duration for the width and the glyph swap (300, user 2026-10-23: 180 read as abrupt) */
72
77
  const COLLAPSE_MS = 300
73
78
 
74
- export default function TabsRow({ tabs: raw = [], value, onChange, onClose, onMinimise, look = 'underline', filled = false, placement = 'inline', shape = 'bar', collapsed = false, onExpand, edge, border, shadow, dock, active, foldable, folded, onFoldChange, foldSide, size, tracked = false, overflow = 'scroll', ariaLabel, className, style }) {
79
+ export default function TabsRow({ tabs: raw = [], value, onChange, onClose, onMinimise, variant, look: lookLegacy, type = 'mono', filled = false, placement = 'inline', shape = 'bar', collapsed = false, onExpand, edge, border, shadow, dock, active, foldable, folded, onFoldChange, foldSide, size, tracked = false, overflow = 'scroll', ariaLabel, className, style }) {
75
80
  const tabs = raw.map((t) => ({ ...t, id: t.id ?? t.value }))
81
+ const look = variant ?? lookLegacy ?? 'underline'
76
82
  const wrap = overflow === 'wrap'
77
83
  if (look === 'chip') return <Overflow mode={overflow} value={value}><ChipTabs tabs={tabs} value={value} onChange={onChange} size={size ?? 'sm'} filled={filled} ariaLabel={ariaLabel} className={`${wrap ? 'flex-wrap ' : ''}${className ?? ''}`} /></Overflow>
78
84
  if (look === 'text') return <Overflow mode={overflow} value={value}><TextTabs tabs={tabs} value={value} onChange={onChange} size={size ?? 14} tracked={tracked} className={`${wrap ? 'flex-wrap gap-y-2 ' : ''}${className ?? 'gap-6'}`} style={style} /></Overflow>
79
85
  if (look === 'stacked') return <StackedTabs tabs={tabs} value={value} onChange={onChange} placement={placement} shape={shape} collapsed={collapsed} onExpand={onExpand} edge={edge} border={border} shadow={shadow} dock={dock} active={active} foldable={foldable} folded={folded} onFoldChange={onFoldChange} foldSide={foldSide} ariaLabel={ariaLabel} className={className ?? ''} />
80
- return <Overflow mode={overflow} value={value}><UnderlineTabs tabs={tabs} value={value} onChange={onChange} onClose={onClose} onMinimise={onMinimise} wrap={wrap} size={size} /></Overflow>
86
+ return <Overflow mode={overflow} value={value}><UnderlineTabs tabs={tabs} value={value} onChange={onChange} onClose={onClose} onMinimise={onMinimise} wrap={wrap} type={type} /></Overflow>
81
87
  }
82
88
 
83
89
  /* `overflow="scroll"`: the row in a sideways scroller that fades its clipped edges and keeps the active tab (`data-active`)
@@ -265,10 +271,10 @@ function StackedTabs({ tabs, value, onChange, placement, shape = 'bar', collapse
265
271
  )
266
272
  }
267
273
 
268
- function UnderlineTabs({ tabs, value, onChange, onClose, onMinimise, wrap, size }) {
269
- /* `size="md"` (2026-10-29, the user's ref — Adobe's tab strip): the sans body face at medium weight, a 20px glyph at a
270
- * 2 stroke (`.kol-tab-md`, the theme), a 48px row, 24 between tabs — review 8: *"the problem … was thickness and stroke"* */
271
- const md = size === 'md'
274
+ function UnderlineTabs({ tabs, value, onChange, onClose, onMinimise, wrap, type }) {
275
+ /* `type="sans"` (2026-10-29, the user's ref — Adobe's tab strip): the neutral sans at 300 (his word, 2026-10-30), a 20px glyph at the
276
+ * set's 1.5 stroke and a 1px underline (his word, 2026-10-30), a 40px row (his word, 2026-10-30), 24 between tabs — review 8: *"the problem … was thickness and stroke"* */
277
+ const md = type === 'sans' || type === 'mono-light'
272
278
  const listRef = useRef(null)
273
279
  const activeIdx = tabs.findIndex((t) => t.id === value)
274
280
 
@@ -282,7 +288,7 @@ function UnderlineTabs({ tabs, value, onChange, onClose, onMinimise, wrap, size
282
288
  }
283
289
 
284
290
  return (
285
- <div className={`flex items-stretch gap-4 ${wrap ? (md ? 'min-h-12' : 'min-h-10') : md ? 'h-12' : 'h-10'}`}>
291
+ <div className={`flex items-stretch gap-4 ${wrap ? 'min-h-10' : 'h-10'}`}>
286
292
  {onClose && (
287
293
  /* the one close, at the rung whose glyph is the 12 this hand-rolled
288
294
  * (2026-09-03) — xs SOLO is 12, so the drawing does not move */
@@ -302,15 +308,15 @@ function UnderlineTabs({ tabs, value, onChange, onClose, onMinimise, wrap, size
302
308
  tabIndex={isActive || (activeIdx === -1 && i === 0) ? 0 : -1}
303
309
  onClick={() => onChange?.(t.id)}
304
310
  className={[
305
- `group ${md ? 'kol-tab-md kol-sans-body-02 font-medium gap-2.5' : 'kol-mono-12 gap-2'} flex items-center cursor-pointer border-b-2`,
311
+ `group ${md ? `${type === 'sans' ? 'kol-tab-sans' : 'kol-tab-mono-light'} kol-sans-body-02 font-light gap-2.5 border-b` : 'kol-mono-12 gap-2 border-b-2'} flex items-center cursor-pointer`,
306
312
  isActive
307
- ? 'text-emphasis border-fg'
308
- : 'text-meta hover:text-emphasis border-transparent',
313
+ ? 'text-oq-96 border-fg'
314
+ : 'text-oq-48 hover:text-oq-80 border-transparent',
309
315
  ].join(' ')}
310
316
  >
311
317
  {/* a tab may carry its glyph before the label (2026-10-29, Adobe's Base color · Image · Color wheel) */}
312
318
  {/* the glyph in OPAQUE ink (icons take oq, never fg) — full when active, meta otherwise, full under the pointer */}
313
- {t.icon && <Icon name={t.icon} size={md ? 20 : 16} className={isActive ? 'text-oq-96' : 'text-oq-48 group-hover:text-oq-96'} />}
319
+ {t.icon && <Icon name={t.icon} size={md ? 20 : 16} className={isActive ? 'text-oq-96' : 'text-oq-48 group-hover:text-oq-80'} />}
314
320
  {t.label}
315
321
  </button>
316
322
  )
@@ -74,8 +74,9 @@ function ChannelStrip({ ch, hex, hsva, onHsva }) {
74
74
  onKeyDown={(e) => { const d = { ArrowRight: 1, ArrowUp: 1, ArrowLeft: -1, ArrowDown: -1 }[e.key]; if (d) { e.preventDefault(); set(value + d * (e.shiftKey ? 10 : 1)) } }}>
75
75
  <span className="absolute top-1/2 size-4 -translate-x-1/2 -translate-y-1/2 rounded-full" style={{ left: `${pct}%`, boxShadow: '0 0 0 2px #fff, 0 0 0 3px rgba(0,0,0,0.35)', background: hex.slice(0, 7) }} />
76
76
  </div>
77
- {/* the box hugs three characters by the Input's own `chars` — a utility width is not guaranteed in every consumer (review 8, ref 81) */}
78
- <Input size="sm" type="number" chars={3} value={Math.round(value)} aria-label={`${ch.label ?? ch.key} value`} onChange={(e) => { const v = Number(e.target.value); if (!Number.isNaN(v)) set(v) }} />
77
+ {/* the box is 56 wide by the Input's own `width` — a utility width was not honoured in a consumer (ref 81), and `chars`
78
+ * alone did not hold against the row's flex (0.317.2, seen in the review-8 pass) */}
79
+ <Input size="sm" type="number" chars={3} width="56px" value={Math.round(value)} aria-label={`${ch.label ?? ch.key} value`} onChange={(e) => { const v = Number(e.target.value); if (!Number.isNaN(v)) set(v) }} />
79
80
  </div>
80
81
  )
81
82
  }
@@ -212,7 +213,7 @@ export default function ColorPickerPopover({ color, onChange, defaultValue, size
212
213
  <div className={`flex w-full flex-col gap-3 ${className}`.trim()}>
213
214
  <div className="flex items-center justify-between gap-3">
214
215
  <span className="kol-eyebrow text-fg-80">{title ?? label}</span>
215
- {modes && <Dropdown variant="nav" size="sm" value={mode} onChange={setMode} options={PICKER_MODES} ariaLabel={`${label} mode`} />}
216
+ {modes && <Dropdown size="sm" value={mode} onChange={setMode} options={PICKER_MODES} ariaLabel={`${label} mode`} />}
216
217
  </div>
217
218
  {hexField}
218
219
  <div className="w-full overflow-hidden rounded-[var(--kol-radius-xs)]" style={{ height: 200 }}>
@@ -521,6 +521,10 @@ export default function ColumnBrowser({
521
521
  /* `renderDetails(file)` — extra rows under the preview column's facts (tags, an Edit action);
522
522
  * absent, the column is exactly what it was */
523
523
  renderDetails,
524
+ /* `renderFolderPreview(level, width)` — the preview column when NO file is shown: the open
525
+ * folder's own pane (MediaLibraryInfoKey, kol-website 2026-10-30 — the media page's folder facts,
526
+ * as its rows draw them). Null for a level with nothing to say; absent, the column is empty. */
527
+ renderFolderPreview,
524
528
  height,
525
529
  defaultHeight = 528,
526
530
  onHeightChange,
@@ -798,6 +802,8 @@ export default function ColumnBrowser({
798
802
  const levels = levelsOf(prefix) // '' → ['']; 'a/b/' → ['', 'a/', 'a/b/']
799
803
  // While Quick Look is open the overlay's index is the truth for the highlight.
800
804
  const shown = quickLook ? quickLook.files[quickLook.index] : picked
805
+ /* no file shown: the open folder's pane, when the caller has one for this level */
806
+ const folderPane = !shown && renderFolderPreview ? renderFolderPreview(prefix, widthOf('preview')) : null
801
807
 
802
808
  /* ── THE STACK MODE (ColumnBrowserStackMode, kol-r2b2 2026-09-03) ─────────
803
809
  *
@@ -1093,9 +1099,11 @@ export default function ColumnBrowser({
1093
1099
  <SelectionPreview keys={[...selectedKeys]} objects={objects} urlOf={urlOf} kindOf={kindOf} formatSize={formatSize} formatDate={formatDate} width={widthOf('preview')} />
1094
1100
  <ResizeHandle axis="x" onDrag={resizeCol('preview')} onEnd={endDrag} />
1095
1101
  </>
1096
- ) : shown && (
1102
+ ) : (shown || folderPane) && (
1097
1103
  <>
1098
- <Preview key={shown.key} o={shown} urlOf={urlOf} kindOf={kindOf} kindLabel={kindLabel} formatSize={formatSize} formatDate={formatDate} renderPreview={renderPreview} renderDetails={renderDetails} width={widthOf('preview')} />
1104
+ {shown
1105
+ ? <Preview key={shown.key} o={shown} urlOf={urlOf} kindOf={kindOf} kindLabel={kindLabel} formatSize={formatSize} formatDate={formatDate} renderPreview={renderPreview} renderDetails={renderDetails} width={widthOf('preview')} />
1106
+ : folderPane}
1099
1107
  <ResizeHandle axis="x" onDrag={resizeCol('preview')} onEnd={endDrag} />
1100
1108
  </>
1101
1109
  )}
@@ -262,23 +262,53 @@ function useBucketLibrary({ client, bucket, defaults, settings: controlled, onSe
262
262
 
263
263
  /* ── shared pieces, kol-r2b2's ─────────────────────────────────────────────── */
264
264
 
265
- /* The frame is square until the file reports its size, then snaps to its nearest preset. A photo
266
- * near a preset fills it (`cover`, a few percent cropped); a vector or anything past the ladder's
267
- * ends — a 2.8:1 wordmark, an 11:1 nav logo — shows whole (`contain`, kol-client-olina 2026-09-25). */
268
- function ImageFrame({ src, vector }) {
265
+ /* The frame is square until the file reports its size. `mode="native"` (the default since
266
+ * MediaPreviewNativeRatio, kol-website 2026-10-30 — a certificate lost its top and bottom) then takes
267
+ * the file's own ratio and shows it whole, a very tall one capped at the media box's height (the pane
268
+ * shrinks that box to what is left under the facts) so it never runs off. `mode="preset"` is the older look: snap to
269
+ * the nearest preset; a photo near one fills it (`cover`, a few percent cropped); a vector or anything
270
+ * past the ladder's ends — a 2.8:1 wordmark, an 11:1 nav logo — shows whole (`contain`,
271
+ * kol-client-olina 2026-09-25). */
272
+ function ImageFrame({ src, vector, mode = 'native' }) {
269
273
  const [frame, setFrame] = useState({ ratio: '1 / 1', contain: !!vector })
274
+ const native = mode !== 'preset'
270
275
  const onLoad = (e) => {
271
276
  const { naturalWidth: w, naturalHeight: h } = e.target
272
277
  const r = w && h ? w / h : 1
273
- setFrame({ ratio: nearestRatio(w, h), contain: vector || r > 16 / 9 || r < 9 / 16 })
278
+ if (native) setFrame({ ratio: w && h ? `${w} / ${h}` : '1 / 1', contain: true })
279
+ else setFrame({ ratio: nearestRatio(w, h), contain: vector || r > 16 / 9 || r < 9 / 16 })
274
280
  }
275
281
  return (
276
- <div className="w-full rounded overflow-hidden flex items-center justify-center" style={{ aspectRatio: frame.ratio }}>
282
+ <div className={`w-full rounded overflow-hidden flex items-center justify-center${native ? ' max-h-full' : ''}`} style={{ aspectRatio: frame.ratio }}>
277
283
  <img src={src} alt="" className={`w-full h-full ${frame.contain ? 'object-contain' : 'object-cover'}`} loading="lazy" onLoad={onLoad} />
278
284
  </div>
279
285
  )
280
286
  }
281
287
 
288
+ /* THE PAGE'S OWN KEYS, ONE GUARD. `,` toggles the settings drawer (MediaPreviewNativeRatio,
289
+ * kol-website 2026-10-30: the media app is not on AppHub, which binds its own); `mod+i` opens Get
290
+ * Info (MediaLibraryInfoKey, kol-website 2026-10-30). AppHub's guard — no other modifiers, no
291
+ * repeat, not while typing. A bare key takes no modifier at all; `mod` is ⌘ or Ctrl. `on` false
292
+ * hands the key to the host (the gear's `onOpenSettings`), or holds it while an overlay is up. */
293
+ function useHotkey(combo, on, run) {
294
+ useEffect(() => {
295
+ if (!combo || !on) return undefined
296
+ const parts = combo.toLowerCase().split('+')
297
+ const key = parts.pop()
298
+ const mod = parts.includes('mod')
299
+ const onKey = (e) => {
300
+ if (!(e.key?.toLowerCase() === key || (key === ',' && e.code === 'Comma')) || e.altKey || e.repeat) return
301
+ if (mod ? !(e.metaKey || e.ctrlKey) : (e.metaKey || e.ctrlKey)) return
302
+ const el = e.target
303
+ if (el?.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el?.tagName ?? '')) return
304
+ e.preventDefault()
305
+ run()
306
+ }
307
+ window.addEventListener('keydown', onKey)
308
+ return () => window.removeEventListener('keydown', onKey)
309
+ }, [combo, on, run])
310
+ }
311
+
282
312
 
283
313
  /* TAGS IN THE PREVIEW PANE (the media D1 pass, 2026-09-25). The file's tags as removable chips and
284
314
  * one field that adds on Enter — comma-separated adds several. Tags are the CLIENT's (`setTags`);
@@ -1127,6 +1157,21 @@ export function MediaLibraryBrowse({
1127
1157
  searchPlaceholder = 'Search this bucket',
1128
1158
  /* `o.displayName` on a listed object is its LABEL in every view, Quick Look and search (kol-fxr
1129
1159
  * 2026-10-08: names not unique, so the key is an id). Absent, the label is the key, as before. */
1160
+ /* `previewFrame` — how the preview pane and the column / Quick Look frame an image
1161
+ * (MediaPreviewNativeRatio, kol-website 2026-10-30): `'native'` the file's own ratio, shown whole;
1162
+ * `'preset'` snapped to the nearest preset and filled. Grid tiles are not part of it. */
1163
+ previewFrame = 'native',
1164
+ /* `settingsKey` — the key that toggles the settings drawer (`','`, AppHub's; `null` for none).
1165
+ * `settingsOpen` / `onSettingsOpenChange` — the drawer, controlled, so a host can drive it. */
1166
+ settingsKey = ',',
1167
+ settingsOpen: settingsOpenProp,
1168
+ onSettingsOpenChange,
1169
+ /* `infoKey` — Get Info on the pick, file or folder, in every view (MediaLibraryInfoKey, kol-website
1170
+ * 2026-10-30; Finder's ⌘I): the same overlay Space opens, Esc closes. `'mod+i'`; `false` for none.
1171
+ * `columnFolderInfo` — Columns: the open folder's facts pane in the preview column, as the rows
1172
+ * draw a picked folder's; `false` leaves the column empty, as before. */
1173
+ infoKey = 'mod+i',
1174
+ columnFolderInfo = true,
1130
1175
  }) {
1131
1176
  const [ownPrefix, setOwnPrefix] = useState('')
1132
1177
  const prefix = prefixProp ?? ownPrefix
@@ -1141,7 +1186,10 @@ export function MediaLibraryBrowse({
1141
1186
  * say it is writable. */
1142
1187
  const writable = lib.writable || (!!fileActions && !!bucketMeta.writable)
1143
1188
  const switchBucket = (id, pfx = '') => { if (bucket == null) setOwnBucket(id); onBucketChange?.(id); setPrefix(pfx) }
1144
- const [settingsOpen, setSettingsOpen] = useState(false)
1189
+ const [ownSettingsOpen, setOwnSettingsOpen] = useState(false)
1190
+ const settingsOpen = settingsOpenProp ?? ownSettingsOpen
1191
+ const setSettingsOpen = (v) => { if (settingsOpenProp == null) setOwnSettingsOpen(v); onSettingsOpenChange?.(v) }
1192
+ useHotkey(settingsKey, !onOpenSettings, () => setSettingsOpen(!settingsOpen))
1145
1193
  const [trashOpen, setTrashOpen] = useState(false)
1146
1194
  const [pickedFile, setPickedFile] = useState(null)
1147
1195
  useEffect(() => { onPickFile?.(pickedFile) }, [pickedFile]) // eslint-disable-line react-hooks/exhaustive-deps
@@ -1644,7 +1692,9 @@ export function MediaLibraryBrowse({
1644
1692
  }
1645
1693
  }
1646
1694
  const spaceQuickLook = () => {
1647
- const key = pickedFile?.key ?? pickedFolder ?? (rowSelection.size === 1 ? [...rowSelection][0] : null)
1695
+ /* in the columns a folder is picked by being OPEN — the deepest level (MediaLibraryInfoKey: ⌘I
1696
+ * is the only caller there; the columns keep their own Space) */
1697
+ const key = pickedFile?.key ?? (view === 'columns' ? (prefix || null) : pickedFolder ?? (rowSelection.size === 1 ? [...rowSelection][0] : null))
1648
1698
  /* a picked bucket or the title Quick-Looks like any folder (user 2026-09-25) */
1649
1699
  if (!key && previewRoot) { setQuickLookFolder(ROOT_KEY); return }
1650
1700
  if (!key) return
@@ -1807,6 +1857,9 @@ export function MediaLibraryBrowse({
1807
1857
 
1808
1858
  const [quickLook, setQuickLook] = useState(null)
1809
1859
  const [quickLookFolder, setQuickLookFolder] = useState(null)
1860
+ /* ⌘I — GET INFO (MediaLibraryInfoKey): what Space opens, from every view; inert over an open
1861
+ * overlay (Esc closes it) and over the search modal */
1862
+ useHotkey(infoKey, !quickLook && !quickLookFolder && !searchOpen, spaceQuickLook)
1810
1863
  /* …and the open window takes the new selection as its set, on the file the cursor reached */
1811
1864
  useEffect(() => {
1812
1865
  if (!quickLook || rowSelection.size < 2) return
@@ -1886,7 +1939,7 @@ export function MediaLibraryBrowse({
1886
1939
  formatSize={formatSize} formatDate={formatDate} width={previewWidth}
1887
1940
  renderDetails={detailsFor}
1888
1941
  renderPreview={(o) => {
1889
- if (isImage(o.contentType)) return <ImageFrame src={mediaUrl(o.key)} vector={o.contentType === 'image/svg+xml'} />
1942
+ if (isImage(o.contentType)) return <ImageFrame src={mediaUrl(o.key)} vector={o.contentType === 'image/svg+xml'} mode={previewFrame} />
1890
1943
  const poster = posterFor(o.key, keySet)
1891
1944
  return <KindPreview o={o} urlOf={(x) => mediaUrl(x.key)} poster={poster ? mediaUrl(poster) : undefined} kindOf={kindOf} kindLabel={KIND_LABEL} />
1892
1945
  }} />
@@ -2100,9 +2153,18 @@ export function MediaLibraryBrowse({
2100
2153
  owns it through settings (item 15) */
2101
2154
  stackView={stackView ?? settings.stackView ?? 'list'}
2102
2155
  renderDetails={(o) => detailsFor({ ...o, key: o.key.slice(VROOT.length) })}
2156
+ /* THE OPEN FOLDER'S FACTS IN THE PREVIEW COLUMN (MediaLibraryInfoKey, kol-website
2157
+ * 2026-10-30: picking a folder showed nothing beside the list) — the rows' pane, drawn
2158
+ * where the columns put a file's. A level above the bucket (the title, a bucket) has
2159
+ * no pane here, as before. */
2160
+ renderFolderPreview={columnFolderInfo ? (level, width) => {
2161
+ const real = unroot(level)
2162
+ if (!real || (VROOT && real === level)) return null
2163
+ return <FolderPreview key={real} path={real} objects={objects} width={width} formatDate={formatDate} details={detailsFor(real)} />
2164
+ } : undefined}
2103
2165
  renderPreview={(o) => {
2104
2166
  const real = { ...o, key: o.key.slice(VROOT.length) }
2105
- if (isImage(real.contentType)) return <ImageFrame src={mediaUrl(real.key)} vector={real.contentType === 'image/svg+xml'} />
2167
+ if (isImage(real.contentType)) return <ImageFrame src={mediaUrl(real.key)} vector={real.contentType === 'image/svg+xml'} mode={previewFrame} />
2106
2168
  const poster = posterFor(real.key, keySet)
2107
2169
  return <KindPreview o={real} urlOf={(x) => mediaUrl(x.key)} poster={poster ? mediaUrl(poster) : undefined} kindOf={kindOf} kindLabel={KIND_LABEL} />
2108
2170
  }}
@@ -2808,7 +2870,7 @@ export function MediaLibraryBrowse({
2808
2870
  {tabs?.length > 0 && (
2809
2871
  <>
2810
2872
  <div className="md:hidden" aria-hidden="true" style={{ height: TABBAR_H }} />
2811
- <TabsRow look="stacked" placement="phone" tabs={tabs} value={activeTab} onChange={onTabChange} />
2873
+ <TabsRow variant="stacked" placement="phone" tabs={tabs} value={activeTab} onChange={onTabChange} />
2812
2874
  </>
2813
2875
  )}
2814
2876
  </div>
@@ -3014,6 +3076,11 @@ export function MediaLibraryLibrary({
3014
3076
  * concept only one page has stay put — `thumbnailFor`, `folderMeta` and
3015
3077
  * `stackView` are folder and stack concepts and are correctly absent here. */
3016
3078
  formatDate = defaultFormatDate,
3079
+ /* the settings drawer's key and its controlled state — the same seam as the browse page (the
3080
+ * drawer is on both; MediaPreviewNativeRatio, kol-website 2026-10-30) */
3081
+ settingsKey = ',',
3082
+ settingsOpen: settingsOpenProp,
3083
+ onSettingsOpenChange,
3017
3084
  }) {
3018
3085
  const [ownBucket, setOwnBucket] = useState(bucket)
3019
3086
  const bucketId = bucket ?? ownBucket
@@ -3021,7 +3088,10 @@ export function MediaLibraryLibrary({
3021
3088
  const { buckets, bucketMeta, settings, setSettings, objects, setObjects, error, mediaUrl, downloadUrl, writable } = lib
3022
3089
  /* the same one dialog idiom as the browse page — see its note on `useModal()` */
3023
3090
  const modal = useModal()
3024
- const [settingsOpen, setSettingsOpen] = useState(false)
3091
+ const [ownSettingsOpen, setOwnSettingsOpen] = useState(false)
3092
+ const settingsOpen = settingsOpenProp ?? ownSettingsOpen
3093
+ const setSettingsOpen = (v) => { if (settingsOpenProp == null) setOwnSettingsOpen(v); onSettingsOpenChange?.(v) }
3094
+ useHotkey(settingsKey, header, () => setSettingsOpen(!settingsOpen))
3025
3095
  const [editingKey, setEditingKey] = useState(null)
3026
3096
  const [editingValue, setEditingValue] = useState('')
3027
3097
  const [renaming, setRenaming] = useState(false)
@@ -3238,7 +3308,7 @@ export function MediaLibraryLibrary({
3238
3308
  {tabs?.length > 0 && (
3239
3309
  <>
3240
3310
  <div className="md:hidden" aria-hidden="true" style={{ height: TABBAR_H }} />
3241
- <TabsRow look="stacked" placement="phone" tabs={tabs} value={activeTab} onChange={onTabChange} />
3311
+ <TabsRow variant="stacked" placement="phone" tabs={tabs} value={activeTab} onChange={onTabChange} />
3242
3312
  </>
3243
3313
  )}
3244
3314
  </div>