@kolkrabbi/kol-component 0.228.0 → 0.230.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.
Files changed (52) hide show
  1. package/package.json +4 -3
  2. package/src/atoms/ActionButton.jsx +1 -1
  3. package/src/atoms/Button.jsx +8 -2
  4. package/src/atoms/IconFrame.jsx +1 -1
  5. package/src/atoms/Input.jsx +3 -2
  6. package/src/atoms/SizeOrDownload.jsx +1 -1
  7. package/src/atoms/Tag.jsx +3 -3
  8. package/src/hooks/colorMath.js +1 -1
  9. package/src/index.js +7 -6
  10. package/src/molecules/ColorInputRow.jsx +1 -1
  11. package/src/molecules/ContentCard.jsx +5 -5
  12. package/src/molecules/ContentMedia.jsx +1 -1
  13. package/src/molecules/ContentRow.jsx +2 -2
  14. package/src/{utilities → molecules}/ContextMenu.jsx +1 -1
  15. package/src/molecules/DocsToc.jsx +1 -1
  16. package/src/molecules/LabeledControl.jsx +7 -4
  17. package/src/molecules/MenuItem.jsx +9 -1
  18. package/src/molecules/Modal.jsx +1 -1
  19. package/src/molecules/PageHeader.jsx +14 -7
  20. package/src/molecules/PaletteHarmonyWheel.jsx +4 -4
  21. package/src/molecules/ProfileCard.jsx +1 -1
  22. package/src/molecules/QuickLookFrame.jsx +1 -1
  23. package/src/molecules/SearchInput.jsx +7 -4
  24. package/src/molecules/ShellDrawer.jsx +1 -1
  25. package/src/molecules/SwatchControls.jsx +3 -3
  26. package/src/molecules/TabsRow.jsx +1 -1
  27. package/src/organisms/Canvas.jsx +1 -1
  28. package/src/organisms/ContentFilters.jsx +4 -11
  29. package/src/organisms/DocumentEditor.jsx +8 -7
  30. package/src/organisms/MediaLibrary.jsx +3 -5
  31. package/src/organisms/MediaLibraryExplorer.jsx +5 -1
  32. package/src/organisms/MediaLibraryPages.jsx +40 -17
  33. package/src/organisms/SectionCards.jsx +1 -1
  34. package/src/organisms/SectionCta.jsx +1 -1
  35. package/src/organisms/SectionFaq.jsx +1 -1
  36. package/src/organisms/SectionNewsletter.jsx +1 -1
  37. package/src/organisms/SettingsPanel.jsx +5 -2
  38. package/src/organisms/ShortcutsOverlay.jsx +8 -4
  39. package/src/organisms/sectionBleed.js +1 -1
  40. package/src/utilities/ErrorBoundary.jsx +1 -1
  41. package/src/utilities/FullscreenOverlay.jsx +1 -1
  42. package/src/utilities/LoaderOverlay.jsx +2 -2
  43. package/src/utilities/Popover.jsx +10 -1
  44. package/src/utilities/masthead.js +39 -0
  45. package/src/utilities/mediaSearch.js +41 -0
  46. package/src/utilities/searchItems.js +30 -0
  47. package/src/utilities/tone.js +1 -1
  48. /package/src/{utilities → atoms}/CloseButton.jsx +0 -0
  49. /package/src/{atoms → utilities}/CropOverlay.jsx +0 -0
  50. /package/src/{atoms → utilities}/CurveOverlay.jsx +0 -0
  51. /package/src/{atoms → utilities}/PathNodeOverlay.jsx +0 -0
  52. /package/src/{atoms → utilities}/SelectionOverlay.jsx +0 -0
@@ -32,7 +32,8 @@ import { readDraft, writeDraft, clearDraft } from '../utilities/localDrafts.js'
32
32
  * @param {string} name the file's name (edit) or a starting name (new)
33
33
  * @param {string} kind markdown · text · json · yaml · code · svg (edit); new picks it
34
34
  * @param {string} text the saved text; `null` while it loads
35
- * @param {number} savedAt epoch ms of the saved file — a draft older than this is ignored
35
+ * @param {number} savedAt epoch ms of the saved file — a draft older than this is ignored; `null` = never
36
+ * saved (a blank note, 2026-09-29) and the status says so instead of "Saved"
36
37
  * @param {Object} draft `{ bucket, key }` — where the draft lives; omit for no drafts
37
38
  * @param {Function} onSave `(text) => Promise` — edit mode
38
39
  * @param {Function} onCreate `({ name, text }) => Promise` — new mode; the name carries the extension
@@ -42,7 +43,7 @@ import { readDraft, writeDraft, clearDraft } from '../utilities/localDrafts.js'
42
43
  * @param {Function} onPickAsset `() => Promise<{ name, url, contentType } | null>` — a consumer's own picker instead
43
44
  * @param {Function} onClose
44
45
  * @param {boolean} inline render IN the page, filling its container, instead of over it (the notes
45
- * page, media-shell 2026-09-26: the editor is the page's content there,
46
+ * page, media-hub 2026-09-26: the editor is the page's content there,
46
47
  * not a window over a list). No scrim, no Escape-to-close; the X still closes
47
48
  */
48
49
  const TYPES = [
@@ -94,10 +95,10 @@ export default function DocumentEditor({
94
95
  }
95
96
  if (text == null) return
96
97
  const d = draft ? readDraft(draft.bucket, draft.key) : null
97
- const useDraft = d && d.at > savedAt && d.text !== text
98
+ const useDraft = d && d.at > (savedAt ?? 0) && d.text !== text
98
99
  setBase(text)
99
100
  setValue(useDraft ? d.text : text); latest.current = useDraft ? d.text : text
100
- setStatus(useDraft ? 'Draft restored — not saved to the file' : 'Saved')
101
+ setStatus(useDraft ? 'Draft restored — not saved to the file' : savedAt === null ? 'Not saved yet' : 'Saved')
101
102
  if (d && !useDraft && draft) clearDraft(draft.bucket, draft.key)
102
103
  }, [text]) // eslint-disable-line react-hooks/exhaustive-deps
103
104
 
@@ -110,7 +111,7 @@ export default function DocumentEditor({
110
111
  useEffect(() => () => flush(), []) // eslint-disable-line react-hooks/exhaustive-deps
111
112
  const change = (next) => {
112
113
  setValue(next); latest.current = next
113
- setStatus(isNew ? 'Not created yet' : next === base ? 'Saved' : 'Edited — not saved')
114
+ setStatus(isNew ? 'Not created yet' : next === base ? (savedAt === null ? 'Not saved yet' : 'Saved') : 'Edited — not saved')
114
115
  clearTimeout(timer.current)
115
116
  timer.current = setTimeout(flush, DRAFT_PAUSE)
116
117
  }
@@ -197,7 +198,7 @@ export default function DocumentEditor({
197
198
  <ViewToggle variant="text" viewMode={view} onViewChange={setView} options={views} />
198
199
  {md && (onPickAsset || assets?.length > 0) && <Button variant="nav" size="sm" iconLeft="image" onClick={attach} aria-pressed={picking}>Attach</Button>}
199
200
  {!isNew && value !== base && value != null && <Button variant="nav" size="sm" onClick={revert}>Revert</Button>}
200
- <Button variant="secondary" size="sm" onClick={save} disabled={!dirty}>{isNew ? 'Create' : 'Save'}</Button>
201
+ <Button size="sm" onClick={save} disabled={!dirty}>{isNew ? 'Create' : 'Save'}</Button>
201
202
  </span>
202
203
  )}>
203
204
  {/* the window's own caps, inline — Tailwind does not generate arbitrary values from package source */}
@@ -229,7 +230,7 @@ export default function DocumentEditor({
229
230
  )}
230
231
  {value == null
231
232
  ? <p className="kol-mono-12 text-fg-48">Loading…</p>
232
- : <Textarea variant="filled" size="md" rows={18} axis="vertical" className="w-full flex-1 kol-mono-12"
233
+ : <Textarea variant="filled" size="sm" rows={18} axis="vertical" className="w-full flex-1"
233
234
  value={md ? parts.body : value} onChange={(e) => (md ? setBody(e.target.value) : change(e.target.value))}
234
235
  spellCheck={kind === 'markdown' || kind === 'text'} aria-label="Document text"
235
236
  placeholder={md ? 'Write in markdown — # heading, **bold**, - list' : ''} />}
@@ -16,6 +16,7 @@ import MediaViewer from './MediaViewer.jsx'
16
16
  import { MediaLibraryBrowse, MediaLibraryLibrary } from './MediaLibraryPages.jsx'
17
17
  import MediaLibraryExplorer from './MediaLibraryExplorer.jsx'
18
18
  import { SettingsChipRow, chipCls } from './SettingsPanel.jsx'
19
+ import { filterMedia } from '../utilities/mediaSearch.js'
19
20
 
20
21
  /**
21
22
  * MediaLibrary — a browser over an object bucket, in two views over one
@@ -343,10 +344,7 @@ export function MediaLibraryProvider({
343
344
  const kindCounts = files.reduce((acc, o) => { acc[o.kind] = (acc[o.kind] || 0) + 1; return acc }, {})
344
345
  const kindsPresent = Object.keys(kindCounts).sort().map((k) => ({ value: k, label: KIND_LABEL[k] ?? k, count: kindCounts[k] }))
345
346
 
346
- const q = search.trim().toLowerCase()
347
- const filtered = files.filter(
348
- (o) => (kinds.size === 0 || kinds.has(o.kind)) && (!q || o.displayKey.toLowerCase().includes(q)),
349
- )
347
+ const filtered = filterMedia(files.filter((o) => kinds.size === 0 || kinds.has(o.kind)), search, { nameOf: (o) => o.displayKey })
350
348
  const sorted = sortFiles(filtered, sort)
351
349
 
352
350
  const page = pageSize || Infinity
@@ -783,7 +781,7 @@ function LibraryViewer({ index, onIndexChange, onClose, onPick }) {
783
781
  actions={(item, i) => (
784
782
  <>
785
783
  {onPick && <Button size="sm" onClick={() => onPick(viewable[i])}>Use</Button>}
786
- <Button variant="secondary" size="sm" onClick={() => copy(item.url)}>
784
+ <Button size="sm" onClick={() => copy(item.url)}>
787
785
  {copied === item.url ? 'Copied' : 'Copy URL'}
788
786
  </Button>
789
787
  </>
@@ -29,7 +29,10 @@ import useMediaQuery from '../hooks/useMediaQuery.js'
29
29
  * Ignored while typing and with ⌘ / Ctrl / ⌥ held
30
30
  * @param {boolean} phoneTabs below 768 the surface is three tabs — Browse · Files · Kinds (Kinds only
31
31
  * with `onKinds`); a tab picks the view, Kinds opens the overview and leaves
32
- * the surface where it was
32
+ * the surface where it was. OFF in the apps since 2026-09-29 (the `···` menu
33
+ * carries view, sort and File formats, as the Files app does); KEPT for a
34
+ * phone-first consumer whose browse surfaces are destinations of their own —
35
+ * a repo that routes Browse and Files as pages wants them one tap apart
33
36
  * @param {Function} onKinds opens the app's file-formats overview (K, the Kinds tab)
34
37
  */
35
38
  const TABS = [
@@ -88,6 +91,7 @@ export default function MediaLibraryExplorer({ view, onViewChange, defaultView,
88
91
  <MediaLibraryBrowse
89
92
  {...pageProps}
90
93
  {...tabProps}
94
+ onKinds={onKinds}
91
95
  view={current}
92
96
  onViewChange={setView}
93
97
  />
@@ -18,9 +18,11 @@ import ContentRow from '../molecules/ContentRow.jsx'
18
18
  import SortControls from '../molecules/SortControls.jsx'
19
19
  import SearchInput from '../molecules/SearchInput.jsx'
20
20
  import MobileTabBar, { TABBAR_H } from '../molecules/MobileTabBar.jsx'
21
+ import { filterMedia, rankMedia } from '../utilities/mediaSearch.js'
22
+ import { useMasthead, mastheadTitleClass } from '../utilities/masthead.js'
21
23
  import { MenuItem, MenuDropdownItem, MenuDropdownDivider } from '../molecules/MenuItem.jsx'
22
24
  import { Tooltip } from '../utilities/Popover.jsx'
23
- import ContextMenu, { useContextMenu } from '../utilities/ContextMenu.jsx'
25
+ import ContextMenu, { useContextMenu } from '../molecules/ContextMenu.jsx'
24
26
  import useMarquee from '../hooks/useMarquee.js'
25
27
  import useLongPress from '../hooks/useLongPress.js'
26
28
  import useMediaQuery from '../hooks/useMediaQuery.js'
@@ -116,6 +118,7 @@ export const SETTINGS_BASE = {
116
118
  filters: false, // the filter bar above the body; off until asked for
117
119
  rowColumns: false, // row view's Date/Size columns + their header; off, a row is just its name
118
120
  rowColumnWidths: { ...ROW_COL_DEFAULTS }, // px, dragged on the header's own dividers
121
+ countLine: 'auto', // 'auto' | 'on' | 'off' — the `N folders · N files · size` line under the body; auto = on at a desk, off on a phone or a touch device (apps review 2026-09-29)
119
122
  }
120
123
 
121
124
  /* THE FOUR VIEWS (the merge, user 2026-09-22: "they both just display files"). Columns and rows
@@ -684,7 +687,7 @@ export function MediaInspector({ files, index, onClose, onPrev, onNext, mediaUrl
684
687
  )
685
688
  }
686
689
 
687
- /* THE DISPLAY SETTINGS AS DATA (media-shell, user 2026-09-26: "missing settings sidebar? … or are
690
+ /* THE DISPLAY SETTINGS AS DATA (media-hub, user 2026-09-26: "missing settings sidebar? … or are
688
691
  * we saying the shell has settings different from media?"). The rows were hard-wired JSX inside this
689
692
  * drawer, so an app page could not show them and a second settings system grew beside it. Now ONE
690
693
  * builder: this drawer renders it, and an app renders the same rows on its settings page and its own
@@ -730,6 +733,8 @@ export function mediaSettingsSections({ settings, onChange, profile }) {
730
733
  render: () => <SettingsChoice options={SORT_OPTIONS} value={settings.sortBy} onChange={(v) => set({ sortBy: v })} /> },
731
734
  { label: 'Direction', align: 'fill',
732
735
  render: () => <SettingsChoice options={[{ value: 'asc', label: '↓ Asc' }, { value: 'desc', label: '↑ Desc' }]} value={settings.sortDir} onChange={(v) => set({ sortDir: v })} /> },
736
+ { label: 'Count line', align: 'fill',
737
+ render: () => <SettingsChoice options={[{ value: 'auto', label: 'Auto' }, { value: 'on', label: 'On' }, { value: 'off', label: 'Off' }]} value={settings.countLine ?? 'auto'} onChange={(v) => set({ countLine: v })} /> },
733
738
  ] },
734
739
  ]
735
740
  }
@@ -873,6 +878,8 @@ function TrashPanel({ trash, onClose, run, confirm }) {
873
878
  }
874
879
 
875
880
  function LibraryHeader({ title, buckets, bucketId, appRoot, onBucket, bucketMeta, writable, headerActions, onSettings, onTrash, onHome, headerTrailing }) {
881
+ /* the app's masthead voice when a Shell sets one (2026-09-29), else the display voice as before */
882
+ const titleCls = mastheadTitleClass(useMasthead(), 'kol-sans-display-03')
876
883
  /* ONE BUCKET, NO DROPDOWN (user 2026-09-22) — a picker with one thing in it chooses nothing. */
877
884
  const options = buckets.length > 1 ? [{ value: 'all', label: `${title} · all` }, ...buckets.map((b) => ({ value: b.id, label: b.label }))] : []
878
885
  /* THE WORDMARK IS THE HOME BUTTON when `onHome` is given (user 2026-09-21: "it should reload,
@@ -883,10 +890,10 @@ function LibraryHeader({ title, buckets, bucketId, appRoot, onBucket, bucketMeta
883
890
  <header className="flex items-baseline justify-between gap-4">
884
891
  {onHome ? (
885
892
  <button type="button" onClick={onHome}
886
- className="kol-sans-display-03 min-w-0 truncate text-left cursor-pointer transition-opacity hover:opacity-64"
893
+ className={`${titleCls} min-w-0 truncate text-left cursor-pointer transition-opacity hover:opacity-64`}
887
894
  aria-label={`${title} — back to the top`}>{title}</button>
888
895
  ) : (
889
- <h1 className="kol-sans-display-03 min-w-0 truncate">{title}</h1>
896
+ <h1 className={`${titleCls} min-w-0 truncate`}>{title}</h1>
890
897
  )}
891
898
  <div className="flex items-center gap-2 min-w-0">
892
899
  {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} />}
@@ -918,7 +925,7 @@ function LibraryHeader({ title, buckets, bucketId, appRoot, onBucket, bucketMeta
918
925
  )
919
926
  }
920
927
 
921
- /* `columnHeight: 'fill'` — THE BROWSER TAKES WHAT IS LEFT OF THE WINDOW (media-shell, user 2026-09-26:
928
+ /* `columnHeight: 'fill'` — THE BROWSER TAKES WHAT IS LEFT OF THE WINDOW (media-hub, user 2026-09-26:
922
929
  * "why isnt the browser in media shell using available height like media does?"). apps/media had it
923
930
  * as a hand-measured `calc(100dvh - 212px)` — its header, gaps and padding, counted once — so the same
924
931
  * tool one shell over got the 528 base. Measured instead: the window, less the view's own top, less
@@ -1043,6 +1050,14 @@ export function MediaLibraryBrowse({
1043
1050
  * the consumer's — a repo whose surfaces are routes wires its router here.
1044
1051
  * No tabs, no pill, and the page is exactly what it was. */
1045
1052
  tabs, activeTab, onTabChange,
1053
+ /* `onKinds` — opens the app's file-formats overview. Given, the phone's `···` menu carries it
1054
+ * under view and sort (apps review 2026-09-29: the Files app keeps everything in its `…`, and
1055
+ * the Kinds tab was the only reason the phone had a tab bar). */
1056
+ onKinds,
1057
+ /* `onOpenSettings` — the gear HANDS OVER to the host (apps review 2026-09-29: three settings in
1058
+ * media-hub, "two too many"). Given, the gear calls it and this page opens no drawer of its own —
1059
+ * inside a Hub the Hub's Settings is the one place. Absent, the gear opens the drawer as always. */
1060
+ onOpenSettings,
1046
1061
  /* `bucketLevel` — keep title → bucket → folders with ONE bucket (kol-client-olina 2026-09-23).
1047
1062
  * Absent, a one-bucket consumer collapses the level as ruled 2026-09-03. */
1048
1063
  bucketLevel = false,
@@ -1230,7 +1245,7 @@ export function MediaLibraryBrowse({
1230
1245
  const tooBig = (o.size ?? 0) > EDIT_CAP
1231
1246
  return (
1232
1247
  <div className="flex items-center gap-3">
1233
- <Button variant="secondary" size="sm" iconLeft="edit" disabled={tooBig} onClick={() => openEditor(o)}>Edit</Button>
1248
+ <Button size="sm" iconLeft="edit" disabled={tooBig} onClick={() => openEditor(o)}>Edit</Button>
1234
1249
  {tooBig && <span className="kol-mono-12 text-fg-48">Over 1 MB — too large to edit here</span>}
1235
1250
  {!tooBig && draftKeys.has(o.key) && <span className="kol-mono-12 text-fg-48">Unsaved draft</span>}
1236
1251
  </div>
@@ -1755,6 +1770,10 @@ export function MediaLibraryBrowse({
1755
1770
  * stack there; the row view had no phone form and drew the desktop list and its preview pane
1756
1771
  * squeezed into 390 — 35px of rows with no names. Same query, same breakpoint as the columns. */
1757
1772
  const phone = useMediaQuery('(max-width: 767px)')
1773
+ /* THE COUNT LINE'S AUTO (apps review 2026-09-29): a desk reads it, a phone spends a line on it */
1774
+ const coarse = useMediaQuery('(pointer: coarse)')
1775
+ const countLine = settings.countLine ?? 'auto'
1776
+ const showCount = countLine === 'on' || (countLine === 'auto' && !phone && !coarse)
1758
1777
  const setView = (v) => {
1759
1778
  onViewChange?.(v)
1760
1779
  setSettings({
@@ -1816,13 +1835,13 @@ export function MediaLibraryBrowse({
1816
1835
  * when a file under it matches and ColumnBrowser's own partition does the
1817
1836
  * rest. One view, rather than a flat results list nobody asked for. Sort
1818
1837
  * orders the files the way the wall already sorts its own. */
1819
- const q = query.trim().toLowerCase()
1838
+ const q = query.trim()
1820
1839
  /* SEGMENTS HIDE WHEN FOLDED, in every view (user 2026-09-23: *"we can just by default hide the
1821
1840
  * segments, if they serve no practical purpose"*). A `.ts` chunk cannot play on its own — its
1822
1841
  * stream's `.m3u8` beside it is the thing you open. The fold used to reach only the grid, so the
1823
1842
  * columns and rows listed every chunk. */
1824
1843
  const listed = useMemo(() => (settings.foldSegments ? objects.filter((o) => !isSegment(o.key)) : objects), [objects, settings.foldSegments])
1825
- const searched = useMemo(() => (q ? listed.filter((o) => o.key.toLowerCase().includes(q)) : listed), [listed, q])
1844
+ const searched = useMemo(() => filterMedia(listed, q), [listed, q])
1826
1845
  const sortedObjects = useMemo(
1827
1846
  () => sortObjects(searched, settings.sortBy, settings.sortDir),
1828
1847
  [searched, settings.sortBy, settings.sortDir],
@@ -2205,10 +2224,7 @@ export function MediaLibraryBrowse({
2205
2224
  /* The palette's own ruling (2026-08-01) is that Enter COMMITS the query rather than navigating
2206
2225
  * to whatever happened to be first — so `onExpand` is where a committed query lands, and here
2207
2226
  * that means the top hit. Arrow-then-Enter and a click go through `onSelect`, same jump. */
2208
- const searchResults = (searchQuery.trim()
2209
- ? objects.filter((o) => o.key.toLowerCase().includes(searchQuery.trim().toLowerCase()))
2210
- : objects
2211
- ).slice(0, 50).map((o) => ({
2227
+ const searchResults = rankMedia(objects, searchQuery, { limit: 50 }).map((o) => ({
2212
2228
  id: o.key,
2213
2229
  label: o.key.split('/').pop(),
2214
2230
  hint: o.key.slice(0, o.key.lastIndexOf('/') + 1) || 'the bucket root',
@@ -2230,7 +2246,7 @@ export function MediaLibraryBrowse({
2230
2246
  if (isSystemFile(o.key)) return false
2231
2247
  if ((q.tags ?? []).some((t) => !(o.tags ?? []).includes(t))) return false
2232
2248
  if ((q.kinds ?? []).length && !q.kinds.includes(kindOf(o))) return false
2233
- if (q.text && !o.key.toLowerCase().includes(q.text.toLowerCase())) return false
2249
+ if (q.text && !filterMedia([o], q.text).length) return false
2234
2250
  return true
2235
2251
  }).map((o) => ({ ...o, kind: kindOf(o), poster: posterFor(o.key, keySet), displayKey: o.key })) : null
2236
2252
  const body = (filtered) => (smartMatches
@@ -2244,7 +2260,7 @@ export function MediaLibraryBrowse({
2244
2260
  <LibraryHeader title={title} buckets={buckets} bucketId={bucketMeta.id} appRoot={atTitleRoot} bucketMeta={bucketMeta} writable={writable} headerActions={headerActions} headerTrailing={headerTrailing} onTrash={trash ? () => setTrashOpen(true) : undefined}
2245
2261
  onHome={goTitleRoot}
2246
2262
  onBucket={(v) => { if (v === 'all') goTitleRoot(); else { setAppRoot(false); switchBucket(v) } }}
2247
- onSettings={() => setSettingsOpen(true)} />
2263
+ onSettings={onOpenSettings ?? (() => setSettingsOpen(true))} />
2248
2264
 
2249
2265
  {/* THE BANNER SLOT — anything that must sit directly under the header and above the body.
2250
2266
  * The upload drop zone is why it exists: a consumer rendering it AFTER the page put it
@@ -2368,6 +2384,7 @@ export function MediaLibraryBrowse({
2368
2384
  label={<Icon name="more" size={16} />}
2369
2385
  caret={false}
2370
2386
  align="end"
2387
+ size="sm"
2371
2388
  buttonClassName="shrink-0 px-2"
2372
2389
  >
2373
2390
  {({ close }) => (
@@ -2403,6 +2420,12 @@ export function MediaLibraryBrowse({
2403
2420
  {opt.label}
2404
2421
  </MenuDropdownItem>
2405
2422
  ))}
2423
+ {onKinds && (
2424
+ <>
2425
+ <MenuDropdownDivider />
2426
+ <MenuDropdownItem onClick={() => { onKinds(); close() }} shortcut="K">File formats</MenuDropdownItem>
2427
+ </>
2428
+ )}
2406
2429
  </div>
2407
2430
  )}
2408
2431
  </MenuItem>
@@ -2486,7 +2509,7 @@ export function MediaLibraryBrowse({
2486
2509
  * slider at its right end, bare: no glyph, no readout (Finder's). */}
2487
2510
  {atTitleRoot && !single ? (
2488
2511
  <div className="flex items-center justify-between gap-4 h-4">
2489
- <p className="kol-mono-12 text-fg-48">
2512
+ <p className="kol-mono-12 text-fg-48" hidden={!showCount}>
2490
2513
  {atTop ? '1 folder' : `${bucketList.length} ${bucketList.length === 1 ? 'bucket' : 'buckets'}`}
2491
2514
  {folderTree && ` · ${Object.values(folderTree).reduce((n, t) => n + (t.files ?? 0), 0)} files · ${formatSize(Object.values(folderTree).reduce((n, t) => n + (t.bytes ?? 0), 0))}`}
2492
2515
  </p>
@@ -2496,12 +2519,12 @@ export function MediaLibraryBrowse({
2496
2519
  /* ONE LINE'S HEIGHT, like the other views' count line — the slider is 24px tall and made
2497
2520
  * this row 8px taller than the fill budget, so the page scrolled by that much */
2498
2521
  <div className="flex items-center justify-between gap-4 h-4">
2499
- <p className="kol-mono-12 text-fg-48">
2522
+ <p className="kol-mono-12 text-fg-48" hidden={!showCount}>
2500
2523
  {!settings.flat && folders.length > 0 && `${folders.length} folder${folders.length > 1 ? 's' : ''} · `}{wallFiles.length} {wallFiles.length === 1 ? 'file' : 'files'} · {formatSize(wallFiles.reduce((n, o) => n + (o.size ?? 0), 0))}
2501
2524
  </p>
2502
2525
  {sizeSlider}
2503
2526
  </div>
2504
- ) : (
2527
+ ) : showCount && (
2505
2528
  <p className="kol-mono-12 text-fg-48">
2506
2529
  {folders.length > 0 && `${folders.length} folder${folders.length > 1 ? 's' : ''} · `}
2507
2530
  {rawFiles.length} {rawFiles.length === 1 ? 'file' : 'files'} · {formatSize(totalBytes)}
@@ -36,7 +36,7 @@ import { minHeightClass } from './sectionHeights.js'
36
36
  * @param {boolean} [fullBleed=false] the FILL breaks the page gutter while the content keeps it —
37
37
  * the family's shared breakout (`sectionBleed.js`, SectionFamilyFullBleed, kol-website 2026-08-31).
38
38
  * Any member of this family can be a filled surface, and a filled surface inside `.kol-page` has its
39
- * colour clipped by the gutter on mobile. Viewport-relative, so unlike `.kol-full-bleed` it does not
39
+ * color clipped by the gutter on mobile. Viewport-relative, so unlike `.kol-full-bleed` it does not
40
40
  * over-bleed in a parent with no gutter of its own. The section's horizontal padding re-insets the
41
41
  * CONTENT, so only the fill moves. Default false — nothing renders differently until it is passed.
42
42
  * @param {string} sectionClassName · wrapperClassName · cardsWrapperClassName · actionsClassName · headerClassName · headerTextWidthClass layout seams
@@ -30,7 +30,7 @@ import { minHeightClass } from './sectionHeights.js'
30
30
  * @param {boolean} [fullBleed=false] the FILL breaks the page gutter while the content keeps it —
31
31
  * the family's shared breakout (`sectionBleed.js`, SectionFamilyFullBleed, kol-website 2026-08-31).
32
32
  * Any member of this family can be a filled surface, and a filled surface inside `.kol-page` has its
33
- * colour clipped by the gutter on mobile. Viewport-relative, so unlike `.kol-full-bleed` it does not
33
+ * color clipped by the gutter on mobile. Viewport-relative, so unlike `.kol-full-bleed` it does not
34
34
  * over-bleed in a parent with no gutter of its own. The section's horizontal padding re-insets the
35
35
  * CONTENT, so only the fill moves. Default false — nothing renders differently until it is passed.
36
36
  * @param {string} className extra classes on the section
@@ -21,7 +21,7 @@ import { minHeightClass } from './sectionHeights.js'
21
21
  * @param {boolean} [fullBleed=false] the FILL breaks the page gutter while the content keeps it —
22
22
  * the family's shared breakout (`sectionBleed.js`, SectionFamilyFullBleed, kol-website 2026-08-31).
23
23
  * Any member of this family can be a filled surface, and a filled surface inside `.kol-page` has its
24
- * colour clipped by the gutter on mobile. Viewport-relative, so unlike `.kol-full-bleed` it does not
24
+ * color clipped by the gutter on mobile. Viewport-relative, so unlike `.kol-full-bleed` it does not
25
25
  * over-bleed in a parent with no gutter of its own. The section's horizontal padding re-insets the
26
26
  * CONTENT, so only the fill moves. Default false — nothing renders differently until it is passed.
27
27
  * @param {string} className · innerClassName layout seams
@@ -60,7 +60,7 @@ import { minHeightClass } from './sectionHeights.js'
60
60
  * @param {boolean} [fullBleed=false] the FILL breaks the page gutter while the content keeps it
61
61
  * (SectionNewsletterFullBleed, kol-website 2026-08-31). This card is a filled surface inside
62
62
  * `.kol-page`, so the gutter clipped its background and left strips of page down both sides of the
63
- * colour. Fill and content padding are the same box, so a consumer could not bleed one without
63
+ * color. Fill and content padding are the same box, so a consumer could not bleed one without
64
64
  * dragging the other out with it. The breakout literal is SectionHero's, character for character —
65
65
  * two organisms in one family must not invent two ways to leave a gutter. The section's own
66
66
  * `px-5 sm:px-8` then re-insets the content, so only the fill moves.
@@ -196,14 +196,17 @@ export const chipCls = (on) => `${CHIP_CLS} ${on ? 'kol-control--filled' : 'text
196
196
  * @param {Array|Set} selected
197
197
  * @param {Function} onToggle (value) => void
198
198
  * @param {string} noun the trigger's noun (default 'kinds')
199
+ * @param {string} variant passed to the Dropdown like `SettingsChoice`'s — unset inherits the page's
200
+ * tone. It was hard-coded `primary` until 2026-09-29, so on a sunken settings
201
+ * page it was the one lighter dropdown in the section.
199
202
  */
200
- export function SettingsMulti({ options = [], selected = [], onToggle, noun = 'kinds', tone = 'default', size = 'sm', className = '' }) {
203
+ export function SettingsMulti({ options = [], selected = [], onToggle, noun = 'kinds', tone = 'default', size = 'sm', variant, className = '' }) {
201
204
  const on = selected instanceof Set ? selected : new Set(selected)
202
205
  const opts = [
203
206
  { value: '__summary', label: `${on.size} of ${options.length} ${noun}` },
204
207
  ...options.map((o) => ({ value: o.value, label: `${on.has(o.value) ? '✓ ' : ''}${o.label ?? String(o.value)}` })),
205
208
  ]
206
- return <Dropdown size={size} variant="primary" tone={tone} value="__summary" options={opts} onChange={(v) => (v === '__summary' ? null : onToggle?.(v))} className={`w-full ${className}`.trim()} />
209
+ return <Dropdown size={size} variant={variant} tone={tone} value="__summary" options={opts} onChange={(v) => (v === '__summary' ? null : onToggle?.(v))} className={`w-full ${className}`.trim()} />
207
210
  }
208
211
 
209
212
  /**
@@ -46,13 +46,15 @@ import SettingsSections from './SettingsSections.jsx'
46
46
  const isSectioned = (list) => Array.isArray(list?.[0]?.items)
47
47
 
48
48
  /* A shortcut as a settings ROW: the label on the left, the combo as the row's
49
- * value. `labelWidth="auto"` makes the label yield and the combo hug — a combo
50
- * is one token by nature and must not wrap. */
49
+ * value. `labelWidth="auto"` makes the label yield and the combo hug. A combo
50
+ * holds on one line from `sm` up; below it, it wraps under its label (LabeledControl stacks) — media's
51
+ * combos are sentences ("Drag from empty space (rows: …"), and held on one line
52
+ * they pushed the sheet past both edges of a phone (apps review, 2026-09-29). */
51
53
  const toRow = ({ label, combo, keys }) => ({
52
54
  label,
53
55
  align: 'fill',
54
56
  labelWidth: 'auto',
55
- value: <span className="text-fg-32 kol-helper-12 whitespace-nowrap">{combo ?? keys}</span>,
57
+ value: <span className="text-fg-32 kol-helper-12 sm:whitespace-nowrap">{combo ?? keys}</span>,
56
58
  })
57
59
 
58
60
  export default function ShortcutsOverlay({ shortcuts = [], onClose }) {
@@ -86,7 +88,9 @@ export default function ShortcutsOverlay({ shortcuts = [], onClose }) {
86
88
  * editor's is six sections — ran past both edges of the viewport with no way to reach the
87
89
  * rest. Capped to the window less a margin, scrolling inside. */
88
90
  className="kol-shortcuts-panel text-fg-64 kol-helper-12 bg-oq-04 flex flex-col gap-6 overflow-y-auto"
89
- style={{ padding: 24, borderRadius: 4, maxHeight: 'calc(100dvh - 48px)' }}
91
+ /* and the window's WIDTH too (apps review, 2026-09-29): nothing capped it, so the widest
92
+ * row set the sheet's width and a phone showed its middle third */
93
+ style={{ padding: 24, borderRadius: 4, maxHeight: 'calc(100dvh - 48px)', maxWidth: 'calc(100vw - 32px)' }}
90
94
  onClick={(e) => e.stopPropagation()}
91
95
  >
92
96
  <SettingsSections
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * WHY IT IS A FILLED-SECTION PROBLEM, not a newsletter one. Any member of the
9
9
  * family can be a filled surface, and a filled surface inside `.kol-page` has its
10
- * colour clipped by the page gutter on mobile — strips of page down both sides of
10
+ * color clipped by the page gutter on mobile — strips of page down both sides of
11
11
  * the fill. Reported once per organism until the prop is shared.
12
12
  *
13
13
  * NOT `.kol-full-bleed`: that escape is CONTAINER-relative, so on an organism
@@ -61,7 +61,7 @@ export default class ErrorBoundary extends Component {
61
61
 
62
62
  <div className="flex gap-4 justify-center flex-wrap">
63
63
  <Button variant="primary" onClick={this.handleReset}>Try again</Button>
64
- <Button variant="secondary" href={this.props.homeHref ?? '/'}>Go home</Button>
64
+ <Button href={this.props.homeHref ?? '/'}>Go home</Button>
65
65
  </div>
66
66
  </div>
67
67
  </div>
@@ -1,6 +1,6 @@
1
1
  import { useEffect, useRef } from 'react'
2
2
  import { pushLayer, popLayer, isTopLayer } from './layerStack.js'
3
- import CloseButton from './CloseButton.jsx'
3
+ import CloseButton from '../atoms/CloseButton.jsx'
4
4
 
5
5
  /**
6
6
  * FullscreenOverlay — the scrim + centred sheet every overlay in the repo
@@ -8,7 +8,7 @@ import FullscreenOverlay from './FullscreenOverlay.jsx'
8
8
  * `loader` slot — as the overlay content. Being mounted IS being visible;
9
9
  * the parent removes this element to dismiss.
10
10
  *
11
- * The loader is a SLOT, not a built-in: inject a curtain (e.g. `<ColorLoader/>`
11
+ * The loader is a SLOT, not a built-in: inject a curtain (e.g. `<IntroLoader/>`
12
12
  * from `@kolkrabbi/kol-foundry`) via `loader` and wire its completion callback
13
13
  * yourself. The slot is mounted in a `fixed inset-0` box that escapes
14
14
  * FullscreenOverlay's centered, `--kol-container-max`-width sheet so the loader
@@ -16,7 +16,7 @@ import FullscreenOverlay from './FullscreenOverlay.jsx'
16
16
  * With neither, the loader slot renders nothing (the overlay still works).
17
17
  *
18
18
  * @param {ReactNode} children overlay content, centered (takes precedence)
19
- * @param {ReactNode} loader full-screen loading curtain, e.g. foundry's ColorLoader
19
+ * @param {ReactNode} loader full-screen loading curtain, e.g. foundry's IntroLoader
20
20
  */
21
21
  export default function LoaderOverlay({ children, loader }) {
22
22
  return (
@@ -7,6 +7,7 @@ import {
7
7
  flip as flipMw,
8
8
  shift as shiftMw,
9
9
  size as sizeMw,
10
+ hide as hideMw,
10
11
  FloatingPortal,
11
12
  FloatingFocusManager,
12
13
  useClick,
@@ -101,6 +102,7 @@ export function usePopover({
101
102
  * external DOM node (e.g. a parent container ref) instead of wiring
102
103
  * `setReference` onto the trigger. Used by TypeBlockToolbar to anchor
103
104
  * to its TypeFrame parent. */
105
+ middleware.push(hideMw({ strategy: 'referenceHidden' })) // last — it reads the final position
104
106
  const data = useFloating({
105
107
  open,
106
108
  onOpenChange,
@@ -110,6 +112,13 @@ export function usePopover({
110
112
  elements: referenceElement ? { reference: referenceElement } : undefined,
111
113
  })
112
114
 
115
+ /* A PANEL FOLLOWS ITS TRIGGER OUT OF SIGHT (2026-09-30 — docs/menus: an open menu painted over
116
+ * the sticky header when its trigger scrolled under it, and floated at the page's foot when it
117
+ * scrolled away). The panel is portalled to <body>, so no scroll container can clip it; `hide`
118
+ * reports when the trigger is clipped, and the panel hides with it. */
119
+ const hidden = data.middlewareData.hide?.referenceHidden
120
+ const floatingStyles = hidden ? { ...data.floatingStyles, visibility: 'hidden' } : data.floatingStyles
121
+
113
122
  const interactions = useInteractions([
114
123
  useClick(data.context, { enabled: click }),
115
124
  useHover(data.context, { enabled: hover, delay: hoverDelay, move: false }),
@@ -118,7 +127,7 @@ export function usePopover({
118
127
  useRole(data.context, { role }),
119
128
  ])
120
129
 
121
- return { ...data, ...interactions, open }
130
+ return { ...data, floatingStyles, ...interactions, open }
122
131
  }
123
132
 
124
133
  /**
@@ -0,0 +1,39 @@
1
+ import { createContext, useContext } from 'react'
2
+
3
+ /**
4
+ * masthead — ONE APP, ONE MASTHEAD (apps review 2026-09-29, ruling D3). media-hub wore MEDIA in the
5
+ * display voice on Browse and a mono title + description on its Hub pages: two headers in one app,
6
+ * because each page picked its own. The voice is now the app's, set ONCE on the Shell
7
+ * (`AppShell masthead`) and read by every page inside it — `PageHeader` (and so `CatalogPage`,
8
+ * `HubHome`, `SettingsScaffold`) and the media tool's own title.
9
+ *
10
+ * display the default — "uppercase sans like media" (the user, 2026-09-29): the display voice,
11
+ * UPPERCASE as a role (03-typography § Casing — case rides the call site, not the class),
12
+ * no description line
13
+ * mono the Hub's older look — a mono title with its description under it
14
+ *
15
+ * Outside a Shell there is no context and every header renders exactly as its props say, so a
16
+ * site page is untouched. A page may still pass `masthead` itself (a Catalog with no Shell).
17
+ */
18
+ export const MASTHEADS = {
19
+ display: { voice: 'sans', size: 'md', subtitle: false, upper: true },
20
+ mono: { voice: 'mono', size: 'sm', subtitle: true, upper: false },
21
+ }
22
+
23
+ /* the title role per voice and size — PageHeader's ladder, here so a tool's own title reads it too */
24
+ export const TITLE_ROLES = {
25
+ sans: { sm: 'kol-sans-heading-03', md: 'kol-sans-display-03', lg: 'kol-sans-display-02' },
26
+ mono: { sm: 'kol-mono-heading-03', md: 'kol-mono-display-03', lg: 'kol-mono-display-02' },
27
+ }
28
+
29
+ export const MastheadContext = createContext(null)
30
+
31
+ /** the masthead in effect — the page's own `masthead` prop, else the Shell's, else null */
32
+ export function useMasthead(own) {
33
+ const shell = useContext(MastheadContext)
34
+ const name = own ?? shell
35
+ return name && MASTHEADS[name] ? { name, ...MASTHEADS[name] } : null
36
+ }
37
+
38
+ /** the title class a masthead gives — for a tool that draws its own title (media's LibraryHeader) */
39
+ export const mastheadTitleClass = (m, fallback) => (m ? `${TITLE_ROLES[m.voice][m.size]}${m.upper ? ' uppercase' : ''}` : fallback)
@@ -0,0 +1,41 @@
1
+ /**
2
+ * mediaSearch — the media library's search, on the KOL engine (`@kolkrabbi/kol-search`).
3
+ *
4
+ * Four places in the media pages each ran their own `key.toLowerCase().includes(q)` — the browse
5
+ * filter, the ⌘K palette, the smart-folder text match, the picker's filter (apps review,
6
+ * 2026-09-29). One helper now, so the media search speaks the same language as every other KOL
7
+ * search: several words must ALL match, `-word` must not, `"a phrase"`, `tag:x` / `#x`,
8
+ * `kind:image` / `is:video`, `after:2026-09-01`. A plain substring still finds what it found.
9
+ *
10
+ * An object becomes an engine item: its file name is the title (ranked first), the whole key is a
11
+ * keyword (so a folder name in the path still matches), its tags and kind are facets, its upload
12
+ * date the date.
13
+ */
14
+ import { createIndex, search } from '@kolkrabbi/kol-search'
15
+ import { kindOf } from './mediaKinds.js'
16
+
17
+ const toItem = (o, nameOf) => ({
18
+ id: o.key,
19
+ title: nameOf ? nameOf(o) : o.key.slice(o.key.lastIndexOf('/') + 1) || o.key,
20
+ keywords: [o.key],
21
+ tags: o.tags ?? [],
22
+ kind: o.kind ?? kindOf(o),
23
+ date: o.uploaded ? String(o.uploaded).slice(0, 10) : undefined,
24
+ o,
25
+ })
26
+
27
+ /** ranked hits, best first — the palette's order. `nameOf(o)` overrides the title (a display key). */
28
+ export function rankMedia(objects, query, { nameOf, limit } = {}) {
29
+ const q = String(query ?? '').trim()
30
+ if (!q) return limit ? objects.slice(0, limit) : objects
31
+ const index = createIndex(objects.map((o) => toItem(o, nameOf)), { facets: ['kind', 'tags'], smart: ['kind'] })
32
+ return search(index, q, { limit }).results.map((r) => r.item.o)
33
+ }
34
+
35
+ /** the matches in their ORIGINAL order — for views that sort on their own afterwards. */
36
+ export function filterMedia(objects, query, opts) {
37
+ const q = String(query ?? '').trim()
38
+ if (!q) return objects
39
+ const hit = new Set(rankMedia(objects, q, opts))
40
+ return objects.filter((o) => hit.has(o))
41
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * searchItems — any list of objects, searched by the KOL engine (`@kolkrabbi/kol-search`).
3
+ *
4
+ * `ContentFilters` ran `String(item[key]).toLowerCase().includes(q)` over its `searchKeys` — one more
5
+ * private engine (apps review 2026-09-29: *"one engine with tiers not endless individual engines each
6
+ * slightly different"*). Through this, every catalog searches the same way: several words must ALL
7
+ * match, `-word` must not, `"a phrase"`, `tag:x` / `#x` against the item's `tags`. A single word still
8
+ * finds everything the substring found.
9
+ *
10
+ * The FIRST search key is the title (ranked highest); the rest are keywords. The items come back in
11
+ * THEIR order — a catalog sorts itself.
12
+ */
13
+ import { createIndex, search } from '@kolkrabbi/kol-search'
14
+
15
+ const list = (v) => (Array.isArray(v) ? v : v == null || v === '' ? [] : [v])
16
+
17
+ export function filterItems(items, query, { keys = ['title'] } = {}) {
18
+ const q = String(query ?? '').trim()
19
+ if (!q) return items
20
+ const [first, ...rest] = keys
21
+ const index = createIndex(items.map((item, i) => ({
22
+ id: i,
23
+ title: String(item[first] ?? ''),
24
+ keywords: rest.flatMap((k) => list(item[k])).map(String),
25
+ tags: list(item.tags).map(String),
26
+ item,
27
+ })), { facets: ['tags'], smart: [] })
28
+ const hit = new Set(search(index, q).results.map((r) => r.item.item))
29
+ return items.filter((it) => hit.has(it))
30
+ }
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * primary · secondary · inverted · outline · ghost · grey · sunken
6
6
  *
7
- * `secondary` paints the PAGE SURFACE and `inverted` the text colour as fill
7
+ * `secondary` paints the PAGE SURFACE and `inverted` the text color as fill
8
8
  * (tone-secondary-is-inverse, 2026-09-03; user: "that tone should be called
9
9
  * secondary. What is currently secondary should be called inverted") — 0.134.0
10
10
  * had lifted `secondary` from Button's variant, which was already an inverse.
File without changes
File without changes
File without changes
File without changes
File without changes