@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.
- package/package.json +4 -3
- package/src/atoms/ActionButton.jsx +1 -1
- package/src/atoms/Button.jsx +8 -2
- package/src/atoms/IconFrame.jsx +1 -1
- package/src/atoms/Input.jsx +3 -2
- package/src/atoms/SizeOrDownload.jsx +1 -1
- package/src/atoms/Tag.jsx +3 -3
- package/src/hooks/colorMath.js +1 -1
- package/src/index.js +7 -6
- package/src/molecules/ColorInputRow.jsx +1 -1
- package/src/molecules/ContentCard.jsx +5 -5
- package/src/molecules/ContentMedia.jsx +1 -1
- package/src/molecules/ContentRow.jsx +2 -2
- package/src/{utilities → molecules}/ContextMenu.jsx +1 -1
- package/src/molecules/DocsToc.jsx +1 -1
- package/src/molecules/LabeledControl.jsx +7 -4
- package/src/molecules/MenuItem.jsx +9 -1
- package/src/molecules/Modal.jsx +1 -1
- package/src/molecules/PageHeader.jsx +14 -7
- package/src/molecules/PaletteHarmonyWheel.jsx +4 -4
- package/src/molecules/ProfileCard.jsx +1 -1
- package/src/molecules/QuickLookFrame.jsx +1 -1
- package/src/molecules/SearchInput.jsx +7 -4
- package/src/molecules/ShellDrawer.jsx +1 -1
- package/src/molecules/SwatchControls.jsx +3 -3
- package/src/molecules/TabsRow.jsx +1 -1
- package/src/organisms/Canvas.jsx +1 -1
- package/src/organisms/ContentFilters.jsx +4 -11
- package/src/organisms/DocumentEditor.jsx +8 -7
- package/src/organisms/MediaLibrary.jsx +3 -5
- package/src/organisms/MediaLibraryExplorer.jsx +5 -1
- package/src/organisms/MediaLibraryPages.jsx +40 -17
- package/src/organisms/SectionCards.jsx +1 -1
- package/src/organisms/SectionCta.jsx +1 -1
- package/src/organisms/SectionFaq.jsx +1 -1
- package/src/organisms/SectionNewsletter.jsx +1 -1
- package/src/organisms/SettingsPanel.jsx +5 -2
- package/src/organisms/ShortcutsOverlay.jsx +8 -4
- package/src/organisms/sectionBleed.js +1 -1
- package/src/utilities/ErrorBoundary.jsx +1 -1
- package/src/utilities/FullscreenOverlay.jsx +1 -1
- package/src/utilities/LoaderOverlay.jsx +2 -2
- package/src/utilities/Popover.jsx +10 -1
- package/src/utilities/masthead.js +39 -0
- package/src/utilities/mediaSearch.js +41 -0
- package/src/utilities/searchItems.js +30 -0
- package/src/utilities/tone.js +1 -1
- /package/src/{utilities → atoms}/CloseButton.jsx +0 -0
- /package/src/{atoms → utilities}/CropOverlay.jsx +0 -0
- /package/src/{atoms → utilities}/CurveOverlay.jsx +0 -0
- /package/src/{atoms → utilities}/PathNodeOverlay.jsx +0 -0
- /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-
|
|
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
|
|
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="
|
|
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
|
|
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
|
|
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 '../
|
|
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-
|
|
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=
|
|
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=
|
|
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-
|
|
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
|
|
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()
|
|
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(() => (
|
|
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.
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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=
|
|
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
|
|
50
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
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 '
|
|
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. `<
|
|
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
|
|
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
|
+
}
|
package/src/utilities/tone.js
CHANGED
|
@@ -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
|
|
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
|