@kolkrabbi/kol-component 0.227.0 → 0.229.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-component",
3
- "version": "0.227.0",
3
+ "version": "0.229.0",
4
4
  "description": "KOL design-system components — atoms through organisms, emitting canonical kol-* classes. Pairs with @kolkrabbi/kol-theme for styling.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -24,7 +24,9 @@
24
24
  "@floating-ui/react": "^0.27.19",
25
25
  "embla-carousel-react": "^8.6.0",
26
26
  "pdfjs-dist": "^6.3.289",
27
- "react-syntax-highlighter": "^16.1.1"
27
+ "react-syntax-highlighter": "^16.1.1",
28
+ "@kolkrabbi/kol-markdown": "^0.1.1",
29
+ "@kolkrabbi/kol-search": "^0.2.0"
28
30
  },
29
31
  "peerDependencies": {
30
32
  "@kolkrabbi/kol-icons": ">=0.22.0",
@@ -81,8 +81,14 @@ const Button = ({
81
81
  * grey) or its own bundle (nav · danger · accent); `nav` is ghost's chrome
82
82
  * rung, oq-80 ink + aria-current — it had lived in the theme with no
83
83
  * component able to emit it, the direct cause of the four-container header. */
84
+ /* An UNKNOWN variant is a typo, not a request for the inverted fill — it fell back to
85
+ * `kol-btn-secondary` (the text colour as fill) until 2026-09-29, so a misspelt variant
86
+ * shipped inverted. It now stamps nothing, like an unset variant: the wrapper's tone,
87
+ * else primary — and says so in dev. */
84
88
  const KNOWN = ['primary', 'secondary', 'accent', 'outline', 'ghost', 'nav', 'danger', 'grey']
85
- const variantClass = !resolvedVariant ? '' : KNOWN.includes(resolvedVariant) ? `kol-btn-${resolvedVariant}` : 'kol-btn-secondary'
89
+ const known = !resolvedVariant || KNOWN.includes(resolvedVariant)
90
+ if (!known && import.meta.env.DEV) console.warn(`Button: unknown variant "${resolvedVariant}" — rendering the inherited tone (primary)`)
91
+ const variantClass = resolvedVariant && known ? `kol-btn-${resolvedVariant}` : ''
86
92
 
87
93
  // Add size class — pairs the padding rule with its mono type class.
88
94
  const sizeClass = size === 'sm'
@@ -158,8 +158,9 @@ export default function Input({
158
158
  * or <label> at the same kol-mono-N — Chromium computes input height
159
159
  * from the font's ascender+descender (font-metric), not strictly from
160
160
  * CSS line-height. Result: kol-control-sm ends up 26.5px instead of 26.
161
- * h-4 / h-[18px] / h-[22px] match the kol-mono-12 / -14 / -16 line-heights. */
162
- const heightCls = size === 'xs' ? 'h-3' : size === 'sm' ? 'h-4' : size === 'md' ? 'h-[18px]' : 'h-[22px]'
161
+ * h-4 / h-[18px] / h-[22px] match the kol-mono-12 / -14 / -16 line-heights; on touch every size is
162
+ * the 16 / 22 rung (D5, 2026-09-29 — see SearchInput) */
163
+ const heightCls = `${size === 'xs' ? 'h-3' : size === 'sm' ? 'h-4' : size === 'md' ? 'h-[18px]' : 'h-[22px]'} pointer-coarse:h-[22px]`
163
164
 
164
165
  const inputCls = [
165
166
  'min-w-0 bg-transparent border-none outline-none text-auto',
package/src/index.js CHANGED
@@ -116,6 +116,8 @@ export { default as PropertyInput } from './molecules/PropertyInput.jsx'
116
116
  export { default as ShapeDropdown } from './molecules/ShapeDropdown.jsx'
117
117
  export { default as ShellDrawer } from './molecules/ShellDrawer.jsx'
118
118
  export { default as ShellSearchOverlay } from './organisms/ShellSearchOverlay.jsx'
119
+ /* the keymap sheet — moved in from kol-shell 2026-09-28 so the workshop shell and the apps open ONE sheet on S */
120
+ export { default as ShortcutsOverlay } from './organisms/ShortcutsOverlay.jsx'
119
121
  export { default as Slider } from './molecules/Slider.jsx'
120
122
  export { default as SpecList } from './molecules/SpecList.jsx'
121
123
  export { default as SpectrumControls, HueStrip, SBSquare, WheelTriangle } from './organisms/SpectrumControls.jsx'
@@ -150,6 +152,7 @@ export { default as EmblaNav } from './molecules/EmblaNav.jsx'
150
152
  export { default as MobileTabBar, TABBAR_H } from './molecules/MobileTabBar.jsx'
151
153
  export { default as ContentFilters } from './organisms/ContentFilters.jsx'
152
154
  export { default as PageHeader } from './molecules/PageHeader.jsx'
155
+ export { MastheadContext, MASTHEADS, useMasthead, mastheadTitleClass } from './utilities/masthead.js'
153
156
  export { default as ErrorBoundary } from './utilities/ErrorBoundary.jsx'
154
157
  export { default as FeatureSplit } from './organisms/FeatureSplit.jsx'
155
158
  export { default as FeaturedCarousel } from './organisms/FeaturedCarousel.jsx'
@@ -188,7 +191,7 @@ export { default as RowMenuButton } from './molecules/RowMenuButton.jsx'
188
191
  export { default as DocPage } from './molecules/DocPage.jsx'
189
192
  export { default as DocFrontmatter } from './molecules/DocFrontmatter.jsx'
190
193
  export { default as ProfileCard } from './molecules/ProfileCard.jsx'
191
- export { parseFrontmatter, splitFrontmatter, joinFrontmatter } from './utilities/frontmatter.js'
194
+ export { parseFrontmatter, splitFrontmatter, joinFrontmatter } from '@kolkrabbi/kol-markdown'
192
195
  export { readDraft, writeDraft, clearDraft, listDrafts, moveDrafts, DRAFTS_EVENT } from './utilities/localDrafts.js'
193
196
  export { readCover } from './utilities/id3.js'
194
197
  export { kindOf, extOf, isSystemFile, KINDS, KIND_LABEL, DEFAULT_KINDS, isSegment, groupSegments, groupVariants, posterFor, partition } from './utilities/mediaKinds.js'
@@ -7,7 +7,7 @@ import FileIcon from '../atoms/FileIcon.jsx'
7
7
  import PdfPage, { PdfDocument } from './PdfPage.jsx'
8
8
  import { kindOf as defaultKindOf, extOf as defaultExtOf, KIND_LABEL } from '../utilities/mediaKinds.js'
9
9
  import markdownToHtml from '../utilities/markdownToHtml.js'
10
- import { parseFrontmatter } from '../utilities/frontmatter.js'
10
+ import { parseFrontmatter } from '@kolkrabbi/kol-markdown'
11
11
  import DocPage from './DocPage.jsx'
12
12
 
13
13
  /* taxonomy-ok: molecule — nests the DS media atoms + CodeBlock (relative). */
@@ -48,19 +48,22 @@ export default function LabeledControl({
48
48
  /* `'auto'`: the label is the yielding cell — flex-1, truncating — and the
49
49
  * control hugs. The control still carries min-w-0 + overflow-hidden so a
50
50
  * combo wider than the whole column CLIPS at the column edge rather than
51
- * painting over the neighbour (the invariant: no cell outside its column). */
51
+ * painting over the neighbour (the invariant: no cell outside its column).
52
+ * BELOW `sm` IT STACKS (apps review, 2026-09-29): the yielding label yielded to
53
+ * nothing on a phone — a sentence-long combo took the whole row and the label
54
+ * showed as "C" or not at all. Label on its line, the value under it. */
52
55
  const labelAuto = labelWidth === 'auto'
53
56
  return (
54
- <div className={`flex items-center gap-3 ${className}`}>
57
+ <div className={`flex ${labelAuto ? 'flex-col items-start gap-1 sm:flex-row sm:items-center sm:gap-3' : 'items-center gap-3'} ${className}`}>
55
58
  {showLabel && (
56
59
  <span
57
- className={`kol-helper-10 tracking-widest text-meta ${labelAuto ? 'flex-1 min-w-0 truncate' : 'shrink-0'}`}
60
+ className={`kol-helper-10 tracking-widest text-meta ${labelAuto ? 'max-w-full min-w-0 truncate sm:flex-1' : 'shrink-0'}`}
58
61
  style={labelAuto ? undefined : { width: labelWidth }}
59
62
  >
60
63
  {labelInner}
61
64
  </span>
62
65
  )}
63
- <div className={labelAuto ? 'min-w-0 overflow-hidden' : 'flex-1 min-w-0'}>{children}</div>
66
+ <div className={labelAuto ? 'min-w-0 max-w-full sm:overflow-hidden' : 'flex-1 min-w-0'}>{children}</div>
64
67
  </div>
65
68
  )
66
69
  }
@@ -19,6 +19,9 @@ import { PopoverPanel, usePopover } from '../utilities/Popover.jsx'
19
19
  * `Dropdown` molecule instead — MenuItem is for action menus / popovers
20
20
  * that hold arbitrary children.
21
21
  */
22
+ /* the ladder's tokens, so the touch rung (D5, 2026-09-29) grows the trigger with its row */
23
+ const TRIGGER_H = { xs: 'var(--kol-ctl-xs)', sm: 'var(--kol-ctl-sm)', md: 'var(--kol-ctl-md)', lg: 'var(--kol-ctl-lg)' }
24
+
22
25
  export function MenuItem({
23
26
  label,
24
27
  children,
@@ -33,6 +36,10 @@ export function MenuItem({
33
36
  * an affordance; neither reference draws one (ColumnBrowserMobileViews item
34
37
  * 15, kol-r2b2 2026-09-04). Default keeps every existing call-site. */
35
38
  caret = true,
39
+ /* THE TRIGGER IS ON THE LADDER (apps review 2026-09-29). It was a fixed `h-8` — 32px whatever sat
40
+ * beside it, so media's phone `···` stood 32 next to a 26 `sm` search field. `size` pins it to the
41
+ * control heights (01-foundations/09-sizes: 22 · 26 · 32 · 40); `md` is the 32 it always was. */
42
+ size = 'md',
36
43
  }) {
37
44
  const [open, setOpen] = useState(defaultOpen)
38
45
  const popover = usePopover({
@@ -51,7 +58,8 @@ export function MenuItem({
51
58
  ref={popover.refs.setReference}
52
59
  {...popover.getReferenceProps()}
53
60
  type="button"
54
- className={`kol-menu-btn kol-helper-12 px-3 h-8 inline-flex items-center gap-2 rounded text-body hover:text-emphasis transition-colors ${buttonClassName}`}
61
+ className={`kol-menu-btn kol-helper-12 px-3 inline-flex items-center gap-2 rounded text-body hover:text-emphasis transition-colors ${buttonClassName}`}
62
+ style={{ height: TRIGGER_H[size] ?? TRIGGER_H.md }}
55
63
  >
56
64
  <span>{label}</span>
57
65
  {caret && (
@@ -119,7 +119,7 @@ function ModalView({ state, closeWith }) {
119
119
  />
120
120
  )}
121
121
  <div className="flex gap-2 justify-end">
122
- {state.kind !== 'alert' && <Button variant="secondary" size="sm" onClick={cancel}>{state.cancelLabel ?? 'Cancel'}</Button>}
122
+ {state.kind !== 'alert' && <Button size="sm" onClick={cancel}>{state.cancelLabel ?? 'Cancel'}</Button>}
123
123
  <Button variant="primary" size="sm" onClick={submit}>{state.okLabel ?? 'OK'}</Button>
124
124
  </div>
125
125
  </div>
@@ -1,4 +1,5 @@
1
1
  import SectionText from './SectionText.jsx'
2
+ import { TITLE_ROLES, useMasthead } from '../utilities/masthead.js'
2
3
  /**
3
4
  * PageHeader — the page's masthead: an optional eyebrow, the title, and a
4
5
  * sub-line.
@@ -62,19 +63,25 @@ import SectionText from './SectionText.jsx'
62
63
  * with an `!important` on the margin — a DS text role re-implemented outside.
63
64
  * @param {string} subtitleMaxWidth the lede's measure (e.g. '800px' or '60ch'), a prop
64
65
  * instead of a consumer selector reaching inside
66
+ * @param {'display'|'mono'} masthead the APP's masthead (utilities/masthead.js, 2026-09-29): inside
67
+ * a Shell it comes from `AppShell masthead` and WINS over `voice`/`size`, and
68
+ * `display` drops the subtitle — one app, one header. Outside a Shell, unset =
69
+ * the props as given, so no site page moves.
65
70
  * The bottom rhythm is `--kol-page-header-mb` (default 40px): inline, as before, but through a
66
71
  * variable a consumer can re-point where an inline literal could only be `!important`-ed.
67
72
  */
68
73
  /* the title role per size and voice — the one thing PageHeader knows that the
69
- * base does not, because the base's ladder is the SECTION scale */
70
- const TITLE = {
71
- sans: { sm: 'kol-sans-heading-03', md: 'kol-sans-display-03', lg: 'kol-sans-display-02' },
72
- mono: { sm: 'kol-mono-heading-03', md: 'kol-mono-display-03', lg: 'kol-mono-display-02' },
73
- }
74
+ * base does not, because the base's ladder is the SECTION scale. Lives in
75
+ * utilities/masthead.js since 2026-09-29 so a tool's own title reads the same table. */
76
+ const TITLE = TITLE_ROLES
74
77
  /* the ONE thing the registers disagree about */
75
78
  const LEDE = { app: 'kol-mono-14', site: 'kol-sans-body-01' }
76
79
 
77
- export default function PageHeader({ eyebrow, title, subtitle, actions, subtitleMaxWidth, size = 'md', voice = 'sans', register = 'app', titleClass, className = '' }) {
80
+ export default function PageHeader({ eyebrow, title, subtitle: ownSubtitle, actions, subtitleMaxWidth, size: ownSize = 'md', voice: ownVoice = 'sans', register = 'app', titleClass, masthead, className = '' }) {
81
+ const m = useMasthead(masthead)
82
+ const voice = m ? m.voice : ownVoice
83
+ const size = m ? m.size : ownSize
84
+ const subtitle = m && !m.subtitle ? undefined : ownSubtitle
78
85
  const roles = TITLE[voice] ?? TITLE.sans
79
86
  return (
80
87
  /* the block owns its own rhythm — margins inline, never in a shared type
@@ -83,7 +90,7 @@ export default function PageHeader({ eyebrow, title, subtitle, actions, subtitle
83
90
  eyebrow={eyebrow}
84
91
  headline={title}
85
92
  headlineAs="h1"
86
- headlineClass={`text-fg-96 ${titleClass ?? roles[size] ?? roles.md}`}
93
+ headlineClass={`text-fg-96 ${titleClass ?? roles[size] ?? roles.md}${m?.upper ? ' uppercase' : ''}`}
87
94
  body={subtitle}
88
95
  bodyClass={`text-oq-64 ${LEDE[register] ?? LEDE.app} min-w-0`}
89
96
  actions={actions}
@@ -103,8 +103,9 @@ export default function SearchInput({
103
103
  if (expanding && isOpen) inputRef.current?.focus()
104
104
  }, [expanding, isOpen])
105
105
 
106
- /* the pinned squares, 01-foundations/09-sizes.md — 22 · 26 · 32 · 40 */
107
- const square = { sm: 28, md: 32, lg: 36 }[size] ?? 32
106
+ /* the pinned squares, 01-foundations/09-sizes.md — 22 · 26 · 32 · 40, read from the theme's
107
+ * `--kol-ctl-*` so the touch rung (D5, 2026-09-29) grows them with every other control */
108
+ const square = `var(--kol-ctl-${['xs', 'sm', 'md', 'lg'].includes(size) ? size : 'md'})`
108
109
  /* THE OPEN FIELD'S HEIGHT IS ITS OWN KNOB, defaulting to the square.
109
110
  *
110
111
  * /work's pill is `h-9` open AND closed, sitting level with a 36px toggle —
@@ -209,8 +210,10 @@ export default function SearchInput({
209
210
 
210
211
  /* Same height pin as Input: Chromium sizes an <input> from font metrics,
211
212
  * not CSS line-height, so without this the shell lands ~0.5px tall.
212
- * h-4 / h-[18px] match the kol-mono-12 / -14 line-heights. */
213
- const heightCls = size === 'sm' ? 'h-4' : 'h-[18px]'
213
+ * h-4 / h-[18px] match the kol-mono-12 / -14 line-heights. On TOUCH the input types 16 / 22 (the
214
+ * touch rung, D5 2026-09-29), and this utility out-ranked the theme's coarse rule — 16px text in a
215
+ * 16px line in a 26px box; `pointer-coarse:` carries the 22 so the box grows with its row. */
216
+ const heightCls = `${size === 'sm' ? 'h-4' : 'h-[18px]'} pointer-coarse:h-[22px]`
214
217
 
215
218
  const inputCls = [
216
219
  'min-w-0 flex-1 bg-transparent border-none outline-none text-auto',
@@ -7,6 +7,7 @@ import { glyphSize } from '../hooks/glyphLadders.js'
7
7
  import SearchInput from '../molecules/SearchInput.jsx'
8
8
  import IconFrame from '../atoms/IconFrame.jsx'
9
9
  import { Tooltip } from '../utilities/Popover.jsx'
10
+ import { filterItems } from '../utilities/searchItems.js'
10
11
 
11
12
  /**
12
13
  * ContentFilters — universal filter component for content grids.
@@ -214,16 +215,8 @@ const ContentFilters = ({
214
215
  }
215
216
 
216
217
  const filteredItems = useMemo(() => {
217
- let result = items
218
- if (searchText) {
219
- const q = searchText.toLowerCase()
220
- result = result.filter((item) =>
221
- searchKeys.some((key) => {
222
- const val = item[key]
223
- return val && String(val).toLowerCase().includes(q)
224
- }),
225
- )
226
- }
218
+ /* the KOL engine, not a private substring match (apps review 2026-09-29) — utilities/searchItems */
219
+ let result = filterItems(items, searchText, { keys: searchKeys })
227
220
  if (activeFilters.size === 0) return result
228
221
  return result.filter((item) => {
229
222
  let matches = true
@@ -7,7 +7,7 @@ import ViewToggle from '../atoms/ViewToggle.jsx'
7
7
  import QuickLookFrame from '../molecules/QuickLookFrame.jsx'
8
8
  import KindPreview from '../molecules/KindPreview.jsx'
9
9
  import FullscreenOverlay from '../utilities/FullscreenOverlay.jsx'
10
- import { splitFrontmatter, joinFrontmatter } from '../utilities/frontmatter.js'
10
+ import { splitFrontmatter, joinFrontmatter } from '@kolkrabbi/kol-markdown'
11
11
  import { readDraft, writeDraft, clearDraft } from '../utilities/localDrafts.js'
12
12
 
13
13
  /**
@@ -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,6 +18,8 @@ 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
25
  import ContextMenu, { useContextMenu } from '../utilities/ContextMenu.jsx'
@@ -34,7 +36,7 @@ import { useModal } from '../molecules/Modal.jsx'
34
36
  import ContentFilters from './ContentFilters.jsx'
35
37
  import DocumentEditor from './DocumentEditor.jsx'
36
38
  import { listDrafts, moveDrafts, DRAFTS_EVENT } from '../utilities/localDrafts.js'
37
- import { parseFrontmatter } from '../utilities/frontmatter.js'
39
+ import { parseFrontmatter } from '@kolkrabbi/kol-markdown'
38
40
  import ShellSearchOverlay from './ShellSearchOverlay.jsx'
39
41
  import ColumnBrowser, { isFileDrag, Preview as ColumnPreview, SelectionPreview } from './ColumnBrowser.jsx'
40
42
  import SettingsPanel, { SettingsSwitch, SettingsChoice, SettingsMulti, SettingsFooter } from './SettingsPanel.jsx'
@@ -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)}
@@ -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
  /**
@@ -10,7 +10,21 @@ import Tag from '../atoms/Tag.jsx'
10
10
  * @param {string} label full row label
11
11
  * @param {string} query current query (empty / no match → plain label)
12
12
  */
13
- export function HighlightMatch({ label, query }) {
13
+ export function HighlightMatch({ label, query, ranges }) {
14
+ /* An engine that already knows where it matched (kol-search's `highlights`)
15
+ * passes `ranges` — every hit, not just the first slice of the raw query. */
16
+ if (Array.isArray(ranges)) {
17
+ if (!ranges.length) return <span>{label}</span>
18
+ const out = []
19
+ let at = 0
20
+ ranges.forEach(([s, e], i) => {
21
+ if (s > at) out.push(<span key={`t${i}`}>{label.slice(at, s)}</span>)
22
+ out.push(<span key={`m${i}`} className="text-fg underline decoration-2 underline-offset-[3px]">{label.slice(s, e)}</span>)
23
+ at = e
24
+ })
25
+ if (at < label.length) out.push(<span key="rest">{label.slice(at)}</span>)
26
+ return <>{out}</>
27
+ }
14
28
  const idx = query ? label.toLowerCase().indexOf(query.toLowerCase()) : -1
15
29
  if (idx === -1) return <span>{label}</span>
16
30
  return (
@@ -47,6 +61,10 @@ export function HighlightMatch({ label, query }) {
47
61
  * @param {Function} onQueryChange (string) => void — input change
48
62
  * @param {Function} onSelect (item) => void — row click / Enter; consumer navigates
49
63
  * @param {string} placeholder input placeholder
64
+ * @param {string} [enterLabel] what Enter does when no row is picked — shown as
65
+ * the panel's last line, so Enter is never a surprise
66
+ * (e.g. `All results for “q”`). Rows may carry
67
+ * `highlights` ([start, end] ranges) from the engine.
50
68
  */
51
69
  export default function ShellSearchOverlay({
52
70
  open,
@@ -65,6 +83,7 @@ export default function ShellSearchOverlay({
65
83
  onQueryChange,
66
84
  onSelect,
67
85
  placeholder = 'Search…',
86
+ enterLabel,
68
87
  }) {
69
88
  const panelRef = useRef(null)
70
89
  const listRef = useRef(null)
@@ -210,12 +229,12 @@ export default function ShellSearchOverlay({
210
229
  onClick={() => select(item)}
211
230
  onMouseEnter={() => { setActiveIndex(i); setNavigated(true) }}
212
231
  className={`flex items-center gap-2 px-4 py-1.5 cursor-pointer kol-mono-14 transition-colors ${
213
- i === active ? 'bg-fg-08 text-fg' : 'text-fg-64'
232
+ i === active ? 'bg-fg-08 text-fg' : 'text-fg-80'
214
233
  }`}
215
234
  >
216
235
  <span className="flex flex-col min-w-0">
217
236
  <span className="truncate">
218
- <HighlightMatch label={item.label} query={query} />
237
+ <HighlightMatch label={item.label} query={query} ranges={item.highlights} />
219
238
  </span>
220
239
  {item.hint && (
221
240
  <span className="kol-mono-12 text-fg-48 truncate">{item.hint}</span>
@@ -228,6 +247,12 @@ export default function ShellSearchOverlay({
228
247
  ))}
229
248
  </ul>
230
249
  )}
250
+ {!expanded && enterLabel && query && (
251
+ <p className="flex items-center gap-2 border-t border-fg-08 px-4 py-2 kol-helper-12 text-fg-64">
252
+ <kbd className="kol-helper-12 rounded-[var(--kol-radius-sm)] bg-fg-08 px-1.5 py-0.5 text-fg">↵</kbd>
253
+ {enterLabel}
254
+ </p>
255
+ )}
231
256
  </div>
232
257
  </div>
233
258
  )
@@ -0,0 +1,104 @@
1
+ import { useEffect } from 'react'
2
+ import SettingsSections from './SettingsSections.jsx'
3
+
4
+ /**
5
+ * ShortcutsOverlay — the keyboard-shortcut sheet: blurred scrim, centred
6
+ * panel, a 2-col grid (label · keys), Esc / backdrop-click close.
7
+ * Ported from the shared cut (mirror's, "copied from kol-monitor").
8
+ *
9
+ * `shortcuts` is a prop — feed the SAME array your settings page renders
10
+ * (both repos hand-maintained the list twice and both pairs drifted).
11
+ * Stacks at `--kol-z-modal`, above the rail's sticky tier.
12
+ *
13
+ * TWO FORMS, DETECTED ON SHAPE (ShortcutsOverlaySections, kol-fxr 2026-08-15):
14
+ *
15
+ * flat `[{ label, keys }]` — one grid, as shipped
16
+ * sectioned `[{ section, items: [{label,keys}] }]` — headings between groups
17
+ *
18
+ * The flat form renders EXACTLY as it did before, so no existing caller moves.
19
+ * The sectioned form exists because a real keymap is grouped — kol-fxr's is
20
+ * Edit · Selection · Layer · Tools · View, and its `shortcutsBySection()`
21
+ * already emits this shape — and flattening it to adopt this component would
22
+ * have lost the grouping. That consumer kept a 99-line local overlay instead,
23
+ * which is the duplication this component exists to end.
24
+ *
25
+ * ONE COMPONENT WITH THE SETTINGS PAGE AND THE DRAWER (user, 2026-09-03:
26
+ * *"align it more to the settings sidebar"* → *"lets normalise a component
27
+ * they can grab"*). This sheet and `SettingsShortcuts` render the SAME keymap
28
+ * array and had drifted into two anatomies — a bespoke two-column grid with a
29
+ * plain `text-fg-32` heading and hand-rolled `<span>` pairs here, against
30
+ * eyebrow sections over `SettingsRow`s there, down to the combo being `fg-96`
31
+ * in one and `fg-32` in the other. Both go through `SettingsSections` now, the
32
+ * same call the settings page and the settings drawer use, so a shortcut reads
33
+ * identically wherever it is shown and a change to the row lands in all of
34
+ * them. The FRAME stays each one's own: a scrim and a centred sheet here, a
35
+ * six-column grid on the page, a right-anchored drawer over the thing you are
36
+ * looking at.
37
+ *
38
+ * `keys` is a DISPLAY STRING and is never bound — this component shows a
39
+ * keymap, it does not own one. Formatting a combo (⌘⇧Z) is the consumer's,
40
+ * next to wherever the binding actually lives.
41
+ *
42
+ * @param {Array} props.shortcuts - `[{ label, combo }]` or `[{ section, items: [{ id, label, combo }] }]` — the same array `SettingsShortcuts` takes (ShellHomeSystemMonitorGaps, kol-monitor 2026-08-27: the overlay read `keys` while the settings block read `combo`; `combo` is the name, `keys` is tolerated for a release)
43
+ * @param {Function} props.onClose
44
+ */
45
+
46
+ const isSectioned = (list) => Array.isArray(list?.[0]?.items)
47
+
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
+ * 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). */
53
+ const toRow = ({ label, combo, keys }) => ({
54
+ label,
55
+ align: 'fill',
56
+ labelWidth: 'auto',
57
+ value: <span className="text-fg-32 kol-helper-12 sm:whitespace-nowrap">{combo ?? keys}</span>,
58
+ })
59
+
60
+ export default function ShortcutsOverlay({ shortcuts = [], onClose }) {
61
+ useEffect(() => {
62
+ const onKey = (e) => { if (e.key === 'Escape') onClose?.() }
63
+ window.addEventListener('keydown', onKey)
64
+ return () => window.removeEventListener('keydown', onKey)
65
+ }, [onClose])
66
+
67
+ const sectioned = isSectioned(shortcuts)
68
+
69
+ return (
70
+ <div
71
+ onClick={onClose}
72
+ /* THE scrim (overlay-scrim-outliers, kol-client-olina 2026-09-03; user: "we
73
+ removed the blur and put just color on the background … we missed this").
74
+ This drew its own — an 8 % inverse wash plus a 2px blur — after the
75
+ 2026-09-01 no-blur ruling and the one-tint-48 class. */
76
+ className="fixed inset-0 select-none kol-overlay-scrim"
77
+ style={{ display: 'grid', placeItems: 'center', zIndex: 'var(--kol-z-modal)' }}
78
+ >
79
+ {/* `bg-oq-04`, not `bg-surface-primary` (user, 2026-09-03: *"this is too
80
+ bright"*). Over a near-black editor canvas the raised page surface
81
+ reads as a lit slab, and an `fg-16` stroke on top of it was the
82
+ brightest edge on screen. The OPAQUE ramp is right here: the sheet
83
+ must not let the canvas through, and `oq-04` is a 4 % lift off the
84
+ ground rather than a surface from the page's own ladder — so the
85
+ panel separates from the scrimmed canvas without a stroke. */}
86
+ <div
87
+ /* THE SHEET FITS THE WINDOW (editor review #4, 2026-09-27): a long keymap — the design
88
+ * editor's is six sections — ran past both edges of the viewport with no way to reach the
89
+ * rest. Capped to the window less a margin, scrolling inside. */
90
+ className="kol-shortcuts-panel text-fg-64 kol-helper-12 bg-oq-04 flex flex-col gap-6 overflow-y-auto"
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)' }}
94
+ onClick={(e) => e.stopPropagation()}
95
+ >
96
+ <SettingsSections
97
+ sections={sectioned
98
+ ? shortcuts.map(({ section, items = [] }) => ({ label: section, rowGap: 1, rows: items.map(toRow) }))
99
+ : [{ rowGap: 1, rows: shortcuts.map(toRow) }]}
100
+ />
101
+ </div>
102
+ </div>
103
+ )
104
+ }
@@ -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>
@@ -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
+ }
@@ -1,76 +0,0 @@
1
- /**
2
- * parseFrontmatter — the workshop engine's handrolled YAML-subset parser,
3
- * verbatim (`packages/workshop/src/engine/frontmatter.js`; DocPageAndKindShowcase,
4
- * kol-r2b2 2026-08-27): `key: value`, block lists (` - item`), inline `[a, b]`
5
- * tags. Keys are lowercased. No gray-matter / js-yaml.
6
- */
7
- export function parseFrontmatter(raw) {
8
- const metadata = {}
9
- const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/)
10
- if (!match) return metadata
11
- const lines = match[1].split(/\r?\n/)
12
- for (let i = 0; i < lines.length; i++) {
13
- const kv = lines[i].match(/^([A-Za-z][A-Za-z0-9 -]*):\s*(.*)$/)
14
- if (!kv) continue
15
- const key = kv[1].toLowerCase()
16
- const value = kv[2].trim()
17
- if (!value) {
18
- const items = []
19
- while (i + 1 < lines.length && lines[i + 1].match(/^\s+-\s+/)) { i++; items.push(lines[i].replace(/^\s+-\s+/, '').trim()) }
20
- metadata[key] = items.length ? items : ''
21
- } else {
22
- metadata[key] = value
23
- }
24
- }
25
- if (typeof metadata.tags === 'string' && metadata.tags.startsWith('[')) {
26
- metadata.tags = metadata.tags.slice(1, -1).split(',').map((t) => t.trim()).filter(Boolean)
27
- }
28
- return metadata
29
- }
30
-
31
- /**
32
- * splitFrontmatter / joinFrontmatter — the EDITOR's pair (DocumentEditor, 2026-09-26). Where
33
- * `parseFrontmatter` reads for display (keys lowercased, lists flattened), these round-trip a file:
34
- * keys keep their case and their order, a list stays a list, and the body is untouched — so opening
35
- * and saving a document without touching the form changes nothing but what was edited.
36
- *
37
- * `fields` is `[[key, value]]`, value a string or an array of strings.
38
- */
39
- const unquote = (v) => {
40
- if (v.length > 1 && v.startsWith('"') && v.endsWith('"')) { try { return JSON.parse(v) } catch { return v.slice(1, -1) } }
41
- if (v.length > 1 && v.startsWith("'") && v.endsWith("'")) return v.slice(1, -1).replace(/''/g, "'")
42
- return v
43
- }
44
-
45
- export function splitFrontmatter(raw = '') {
46
- const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/)
47
- if (!match) return { has: false, fields: [], body: raw }
48
- const fields = []
49
- const lines = match[1].split(/\r?\n/)
50
- for (let i = 0; i < lines.length; i++) {
51
- const kv = lines[i].match(/^([A-Za-z_][\w -]*):\s*(.*)$/)
52
- if (!kv) continue
53
- let value = kv[2].trim()
54
- if (!value) {
55
- const items = []
56
- while (i + 1 < lines.length && /^\s+-\s+/.test(lines[i + 1])) { i++; items.push(lines[i].replace(/^\s+-\s+/, '').trim()) }
57
- value = items.length ? items : ''
58
- } else if (value.startsWith('[') && value.endsWith(']')) {
59
- value = value.slice(1, -1).split(',').map((t) => unquote(t.trim())).filter(Boolean)
60
- } else {
61
- value = unquote(value)
62
- }
63
- fields.push([kv[1], value])
64
- }
65
- return { has: true, fields, body: raw.slice(match[0].length) }
66
- }
67
-
68
- /* bare when YAML reads it back as the same string; quoted otherwise (a colon, a leading #, quotes) */
69
- const yamlScalar = (v) => (/^[\w./@+-][\w ./@+,-]*$/.test(v) || v === '' ? v : JSON.stringify(v))
70
-
71
- export function joinFrontmatter(fields, body = '') {
72
- const rows = fields.filter(([key]) => String(key).trim())
73
- if (!rows.length) return body
74
- const yaml = rows.map(([key, v]) => (Array.isArray(v) ? `${key}: [${v.map(yamlScalar).join(', ')}]` : `${key}: ${yamlScalar(String(v ?? ''))}`)).join('\n')
75
- return `---\n${yaml}\n---\n${body.startsWith('\n') ? body.slice(1) : body}`
76
- }