@kolkrabbi/kol-component 0.317.1 → 0.317.3

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.1",
3
+ "version": "0.317.3",
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.1"
41
+ "@kolkrabbi/kol-icons": "^0.53.3"
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'} [type='mono'] `underline` only: the tab's type
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,9 +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, a 20px glyph, a 48px row, 24 between tabs */
270
- 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'
271
278
  const listRef = useRef(null)
272
279
  const activeIdx = tabs.findIndex((t) => t.id === value)
273
280
 
@@ -281,7 +288,7 @@ function UnderlineTabs({ tabs, value, onChange, onClose, onMinimise, wrap, size
281
288
  }
282
289
 
283
290
  return (
284
- <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'}`}>
285
292
  {onClose && (
286
293
  /* the one close, at the rung whose glyph is the 12 this hand-rolled
287
294
  * (2026-09-03) — xs SOLO is 12, so the drawing does not move */
@@ -301,7 +308,7 @@ function UnderlineTabs({ tabs, value, onChange, onClose, onMinimise, wrap, size
301
308
  tabIndex={isActive || (activeIdx === -1 && i === 0) ? 0 : -1}
302
309
  onClick={() => onChange?.(t.id)}
303
310
  className={[
304
- `group ${md ? 'kol-sans-body-02 gap-2.5' : 'kol-mono-12 gap-2'} flex items-center cursor-pointer border-b-2`,
311
+ `group ${md ? 'kol-tab-sans kol-sans-body-02 font-light gap-2.5 border-b' : 'kol-mono-12 gap-2 border-b-2'} flex items-center cursor-pointer`,
305
312
  isActive
306
313
  ? 'text-emphasis border-fg'
307
314
  : 'text-meta hover:text-emphasis border-transparent',
@@ -74,9 +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
- <div className="w-14 shrink-0">
78
- <Input size="sm" type="number" 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
- </div>
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) }} />
80
80
  </div>
81
81
  )
82
82
  }
@@ -212,8 +212,8 @@ export default function ColorPickerPopover({ color, onChange, defaultValue, size
212
212
  return hsva && (
213
213
  <div className={`flex w-full flex-col gap-3 ${className}`.trim()}>
214
214
  <div className="flex items-center justify-between gap-3">
215
- <span className="kol-sans-body-02 text-emphasis">{title ?? label}</span>
216
- {modes && <Dropdown variant="nav" size="sm" value={mode} onChange={setMode} options={PICKER_MODES} ariaLabel={`${label} mode`} />}
215
+ <span className="kol-eyebrow text-fg-80">{title ?? label}</span>
216
+ {modes && <Dropdown size="sm" value={mode} onChange={setMode} options={PICKER_MODES} ariaLabel={`${label} mode`} />}
217
217
  </div>
218
218
  {hexField}
219
219
  <div className="w-full overflow-hidden rounded-[var(--kol-radius-xs)]" style={{ height: 200 }}>
@@ -262,23 +262,47 @@ 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
+ /* `,` TOGGLES THE SETTINGS DRAWER (MediaPreviewNativeRatio, kol-website 2026-10-30: the media app is
289
+ * not on AppHub, which binds its own). AppHub's guard — no modifiers, no repeat, not while typing.
290
+ * `on` is false when the gear hands over (`onOpenSettings`): the host's key owns it then. */
291
+ function useSettingsKey(key, on, toggle) {
292
+ useEffect(() => {
293
+ if (!key || !on) return undefined
294
+ const onKey = (e) => {
295
+ if (!(e.key === key || (key === ',' && e.code === 'Comma')) || e.metaKey || e.ctrlKey || e.altKey || e.repeat) return
296
+ const el = e.target
297
+ if (el?.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el?.tagName ?? '')) return
298
+ e.preventDefault()
299
+ toggle()
300
+ }
301
+ window.addEventListener('keydown', onKey)
302
+ return () => window.removeEventListener('keydown', onKey)
303
+ }, [key, on, toggle])
304
+ }
305
+
282
306
 
283
307
  /* TAGS IN THE PREVIEW PANE (the media D1 pass, 2026-09-25). The file's tags as removable chips and
284
308
  * one field that adds on Enter — comma-separated adds several. Tags are the CLIENT's (`setTags`);
@@ -1127,6 +1151,15 @@ export function MediaLibraryBrowse({
1127
1151
  searchPlaceholder = 'Search this bucket',
1128
1152
  /* `o.displayName` on a listed object is its LABEL in every view, Quick Look and search (kol-fxr
1129
1153
  * 2026-10-08: names not unique, so the key is an id). Absent, the label is the key, as before. */
1154
+ /* `previewFrame` — how the preview pane and the column / Quick Look frame an image
1155
+ * (MediaPreviewNativeRatio, kol-website 2026-10-30): `'native'` the file's own ratio, shown whole;
1156
+ * `'preset'` snapped to the nearest preset and filled. Grid tiles are not part of it. */
1157
+ previewFrame = 'native',
1158
+ /* `settingsKey` — the key that toggles the settings drawer (`','`, AppHub's; `null` for none).
1159
+ * `settingsOpen` / `onSettingsOpenChange` — the drawer, controlled, so a host can drive it. */
1160
+ settingsKey = ',',
1161
+ settingsOpen: settingsOpenProp,
1162
+ onSettingsOpenChange,
1130
1163
  }) {
1131
1164
  const [ownPrefix, setOwnPrefix] = useState('')
1132
1165
  const prefix = prefixProp ?? ownPrefix
@@ -1141,7 +1174,10 @@ export function MediaLibraryBrowse({
1141
1174
  * say it is writable. */
1142
1175
  const writable = lib.writable || (!!fileActions && !!bucketMeta.writable)
1143
1176
  const switchBucket = (id, pfx = '') => { if (bucket == null) setOwnBucket(id); onBucketChange?.(id); setPrefix(pfx) }
1144
- const [settingsOpen, setSettingsOpen] = useState(false)
1177
+ const [ownSettingsOpen, setOwnSettingsOpen] = useState(false)
1178
+ const settingsOpen = settingsOpenProp ?? ownSettingsOpen
1179
+ const setSettingsOpen = (v) => { if (settingsOpenProp == null) setOwnSettingsOpen(v); onSettingsOpenChange?.(v) }
1180
+ useSettingsKey(settingsKey, !onOpenSettings, () => setSettingsOpen(!settingsOpen))
1145
1181
  const [trashOpen, setTrashOpen] = useState(false)
1146
1182
  const [pickedFile, setPickedFile] = useState(null)
1147
1183
  useEffect(() => { onPickFile?.(pickedFile) }, [pickedFile]) // eslint-disable-line react-hooks/exhaustive-deps
@@ -1886,7 +1922,7 @@ export function MediaLibraryBrowse({
1886
1922
  formatSize={formatSize} formatDate={formatDate} width={previewWidth}
1887
1923
  renderDetails={detailsFor}
1888
1924
  renderPreview={(o) => {
1889
- if (isImage(o.contentType)) return <ImageFrame src={mediaUrl(o.key)} vector={o.contentType === 'image/svg+xml'} />
1925
+ if (isImage(o.contentType)) return <ImageFrame src={mediaUrl(o.key)} vector={o.contentType === 'image/svg+xml'} mode={previewFrame} />
1890
1926
  const poster = posterFor(o.key, keySet)
1891
1927
  return <KindPreview o={o} urlOf={(x) => mediaUrl(x.key)} poster={poster ? mediaUrl(poster) : undefined} kindOf={kindOf} kindLabel={KIND_LABEL} />
1892
1928
  }} />
@@ -2102,7 +2138,7 @@ export function MediaLibraryBrowse({
2102
2138
  renderDetails={(o) => detailsFor({ ...o, key: o.key.slice(VROOT.length) })}
2103
2139
  renderPreview={(o) => {
2104
2140
  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'} />
2141
+ if (isImage(real.contentType)) return <ImageFrame src={mediaUrl(real.key)} vector={real.contentType === 'image/svg+xml'} mode={previewFrame} />
2106
2142
  const poster = posterFor(real.key, keySet)
2107
2143
  return <KindPreview o={real} urlOf={(x) => mediaUrl(x.key)} poster={poster ? mediaUrl(poster) : undefined} kindOf={kindOf} kindLabel={KIND_LABEL} />
2108
2144
  }}
@@ -2808,7 +2844,7 @@ export function MediaLibraryBrowse({
2808
2844
  {tabs?.length > 0 && (
2809
2845
  <>
2810
2846
  <div className="md:hidden" aria-hidden="true" style={{ height: TABBAR_H }} />
2811
- <TabsRow look="stacked" placement="phone" tabs={tabs} value={activeTab} onChange={onTabChange} />
2847
+ <TabsRow variant="stacked" placement="phone" tabs={tabs} value={activeTab} onChange={onTabChange} />
2812
2848
  </>
2813
2849
  )}
2814
2850
  </div>
@@ -3014,6 +3050,11 @@ export function MediaLibraryLibrary({
3014
3050
  * concept only one page has stay put — `thumbnailFor`, `folderMeta` and
3015
3051
  * `stackView` are folder and stack concepts and are correctly absent here. */
3016
3052
  formatDate = defaultFormatDate,
3053
+ /* the settings drawer's key and its controlled state — the same seam as the browse page (the
3054
+ * drawer is on both; MediaPreviewNativeRatio, kol-website 2026-10-30) */
3055
+ settingsKey = ',',
3056
+ settingsOpen: settingsOpenProp,
3057
+ onSettingsOpenChange,
3017
3058
  }) {
3018
3059
  const [ownBucket, setOwnBucket] = useState(bucket)
3019
3060
  const bucketId = bucket ?? ownBucket
@@ -3021,7 +3062,10 @@ export function MediaLibraryLibrary({
3021
3062
  const { buckets, bucketMeta, settings, setSettings, objects, setObjects, error, mediaUrl, downloadUrl, writable } = lib
3022
3063
  /* the same one dialog idiom as the browse page — see its note on `useModal()` */
3023
3064
  const modal = useModal()
3024
- const [settingsOpen, setSettingsOpen] = useState(false)
3065
+ const [ownSettingsOpen, setOwnSettingsOpen] = useState(false)
3066
+ const settingsOpen = settingsOpenProp ?? ownSettingsOpen
3067
+ const setSettingsOpen = (v) => { if (settingsOpenProp == null) setOwnSettingsOpen(v); onSettingsOpenChange?.(v) }
3068
+ useSettingsKey(settingsKey, header, () => setSettingsOpen(!settingsOpen))
3025
3069
  const [editingKey, setEditingKey] = useState(null)
3026
3070
  const [editingValue, setEditingValue] = useState('')
3027
3071
  const [renaming, setRenaming] = useState(false)
@@ -3238,7 +3282,7 @@ export function MediaLibraryLibrary({
3238
3282
  {tabs?.length > 0 && (
3239
3283
  <>
3240
3284
  <div className="md:hidden" aria-hidden="true" style={{ height: TABBAR_H }} />
3241
- <TabsRow look="stacked" placement="phone" tabs={tabs} value={activeTab} onChange={onTabChange} />
3285
+ <TabsRow variant="stacked" placement="phone" tabs={tabs} value={activeTab} onChange={onTabChange} />
3242
3286
  </>
3243
3287
  )}
3244
3288
  </div>