@kolkrabbi/kol-component 0.239.1 → 0.241.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 +1 -1
- package/src/organisms/ColumnBrowser.jsx +5 -2
- package/src/organisms/MediaLibrary.jsx +64 -15
- package/src/organisms/MediaLibraryPages.jsx +40 -13
- package/src/organisms/MediaTileGallery.jsx +5 -2
- package/src/organisms/MediaViewer.jsx +14 -2
- package/src/organisms/SectionSplit.jsx +14 -4
- package/src/utilities/Popover.jsx +4 -1
package/package.json
CHANGED
|
@@ -681,8 +681,11 @@ export default function ColumnBrowser({
|
|
|
681
681
|
const rootRef = useRef(null)
|
|
682
682
|
/* `autoFocus` (ColumnBrowserSeams, kol-r2b2 2026-08-28): the arrow keys were dead until a row was
|
|
683
683
|
* clicked — nothing focused the root on mount, and the consumer reached into the DOM for it. Re-run
|
|
684
|
-
* on `prefix`, so a bucket switch from the header (which takes focus) hands the keyboard back.
|
|
685
|
-
|
|
684
|
+
* on `prefix`, so a bucket switch from the header (which takes focus) hands the keyboard back.
|
|
685
|
+
* `preventScroll` (explorer-autofocus-scrolls-the-fixed-page, kol-website 2026-10-06): inside
|
|
686
|
+
* `PageShell mode="fixed"` the focus scrolled the page's scroller 102px to bring the browser into
|
|
687
|
+
* view, and the header opened off-screen. The keyboard still lands here; the page stays put. */
|
|
688
|
+
useEffect(() => { if (autoFocus) rootRef.current?.focus({ preventScroll: true }) }, [autoFocus, prefix])
|
|
686
689
|
|
|
687
690
|
/* THE KEYBOARD SELECTS TOO (user 2026-09-23: *"the highlight focus is on the correct item, this
|
|
688
691
|
* has been a problem for awhile"*). Clicks reported through `onSelectClick`; the arrows moved
|
|
@@ -3,8 +3,8 @@ import { Icon } from '@kolkrabbi/kol-icons'
|
|
|
3
3
|
import ActionButton from '../molecules/ActionButton.jsx'
|
|
4
4
|
import Button from '../atoms/Button.jsx'
|
|
5
5
|
import Divider from '../atoms/Divider.jsx'
|
|
6
|
+
import Dropdown from '../molecules/Dropdown.jsx'
|
|
6
7
|
import Input from '../atoms/Input.jsx'
|
|
7
|
-
import SegmentedToggle from '../atoms/SegmentedToggle.jsx'
|
|
8
8
|
import SizeOrDownload from '../atoms/SizeOrDownload.jsx'
|
|
9
9
|
import ViewToggle from '../atoms/ViewToggle.jsx'
|
|
10
10
|
import FullscreenOverlay from '../utilities/FullscreenOverlay.jsx'
|
|
@@ -12,6 +12,7 @@ import { Tooltip } from '../utilities/Popover.jsx'
|
|
|
12
12
|
import ContentCard from '../molecules/ContentCard.jsx'
|
|
13
13
|
import ContentRow from '../molecules/ContentRow.jsx'
|
|
14
14
|
import ContentFilters from './ContentFilters.jsx'
|
|
15
|
+
import { MenuItem, MenuDropdownItem, MenuDropdownDivider } from '../molecules/MenuItem.jsx'
|
|
15
16
|
import MediaViewer from './MediaViewer.jsx'
|
|
16
17
|
import { MediaLibraryBrowse, MediaLibraryLibrary } from './MediaLibraryPages.jsx'
|
|
17
18
|
import MediaLibraryExplorer from './MediaLibraryExplorer.jsx'
|
|
@@ -136,6 +137,8 @@ function kindOf(o) {
|
|
|
136
137
|
* every stream it held. A picker asks for what it can pick: `['image','video']`. */
|
|
137
138
|
function acceptsKind(accept) {
|
|
138
139
|
if (!accept || accept === 'all') return () => true
|
|
140
|
+
/* a function reads the object itself — a vector-only picker wants `.svg`, which is `image` by kind */
|
|
141
|
+
if (typeof accept === 'function') return accept
|
|
139
142
|
const wanted = new Set(Array.isArray(accept) ? accept : [accept])
|
|
140
143
|
return (o) => wanted.has(o.kind)
|
|
141
144
|
}
|
|
@@ -298,6 +301,14 @@ export function MediaLibraryProvider({
|
|
|
298
301
|
* from the flat view you just left. Derived, not an effect. */
|
|
299
302
|
const [paging, setPaging] = useState({ id: null, visible: 0 })
|
|
300
303
|
|
|
304
|
+
/* THE STORE (2026-10-07, design-editor's picker folded into this one): a client with
|
|
305
|
+
* `buckets()` lists one at a time and the modal's header switches it — the browse page's
|
|
306
|
+
* rule, "a client with none is one bucket and no dropdown". Switching lands at the root:
|
|
307
|
+
* a prefix is one bucket's path. */
|
|
308
|
+
const buckets = useMemo(() => client?.buckets?.() ?? [], [client])
|
|
309
|
+
const [bucket, setBucketState] = useState(() => buckets[0]?.id ?? null)
|
|
310
|
+
const setBucket = (id) => { setBucketState(id); setPrefix('') }
|
|
311
|
+
|
|
301
312
|
useEffect(() => {
|
|
302
313
|
if (!client) return undefined
|
|
303
314
|
let cancelled = false
|
|
@@ -305,12 +316,12 @@ export function MediaLibraryProvider({
|
|
|
305
316
|
setLoading(true)
|
|
306
317
|
setError(null)
|
|
307
318
|
client
|
|
308
|
-
.listMedia('', { signal: controller.signal })
|
|
319
|
+
.listMedia('', { signal: controller.signal, bucket: bucket ?? undefined })
|
|
309
320
|
.then((objs) => { if (!cancelled) setObjects(objs) })
|
|
310
321
|
.catch((e) => { if (!cancelled && e.name !== 'AbortError') setError(e.message) })
|
|
311
322
|
.finally(() => { if (!cancelled) setLoading(false) })
|
|
312
323
|
return () => { cancelled = true; controller.abort() }
|
|
313
|
-
}, [client])
|
|
324
|
+
}, [client, bucket])
|
|
314
325
|
|
|
315
326
|
/* click an inactive field → ascending; click the active one → flip */
|
|
316
327
|
const sortBy = (by) =>
|
|
@@ -344,6 +355,10 @@ export function MediaLibraryProvider({
|
|
|
344
355
|
const kindCounts = files.reduce((acc, o) => { acc[o.kind] = (acc[o.kind] || 0) + 1; return acc }, {})
|
|
345
356
|
const kindsPresent = Object.keys(kindCounts).sort().map((k) => ({ value: k, label: KIND_LABEL[k] ?? k, count: kindCounts[k] }))
|
|
346
357
|
|
|
358
|
+
/* `q` is the paging key's and the stats' view of the search — the engine swap (kol-search,
|
|
359
|
+
* 2026-09-29) dropped its definition and the modal threw on open; found 2026-10-07 when the
|
|
360
|
+
* editor's doors moved onto it, since no consumer had opened this variant in between. */
|
|
361
|
+
const q = search.trim().toLowerCase()
|
|
347
362
|
const filtered = filterMedia(files.filter((o) => kinds.size === 0 || kinds.has(o.kind)), search, { nameOf: (o) => o.displayKey })
|
|
348
363
|
const sorted = sortFiles(filtered, sort)
|
|
349
364
|
|
|
@@ -396,10 +411,13 @@ export function MediaLibraryProvider({
|
|
|
396
411
|
viewable,
|
|
397
412
|
loading,
|
|
398
413
|
error,
|
|
399
|
-
|
|
414
|
+
bucket,
|
|
415
|
+
buckets,
|
|
416
|
+
setBucket,
|
|
417
|
+
mediaUrl: client?.mediaUrl ? (key) => client.mediaUrl(key, bucket ?? undefined) : (key) => key,
|
|
400
418
|
proxied: client?.proxied ?? ((url) => url),
|
|
401
419
|
}
|
|
402
|
-
}, [objects, prefix, flat, sort, search, kinds, paging, pageSize, loading, error, accept, client])
|
|
420
|
+
}, [objects, prefix, flat, sort, search, kinds, paging, pageSize, loading, error, accept, client, bucket, buckets])
|
|
403
421
|
|
|
404
422
|
return <MediaLibraryContext.Provider value={value}>{children}</MediaLibraryContext.Provider>
|
|
405
423
|
}
|
|
@@ -880,7 +898,7 @@ function PickerBody({ items, viewMode, onOpen, onPick }) {
|
|
|
880
898
|
/* The picker's chrome — ContentFilters owns the animated search, the kind
|
|
881
899
|
* filter, the view toggle and the N-of-M count; sort rides its header slot. */
|
|
882
900
|
function LibraryChrome({ onOpen, onPick }) {
|
|
883
|
-
const { folders, sorted, sort, sortBy } = useMediaLibrary()
|
|
901
|
+
const { folders, sorted, sort, sortBy, bucket, buckets, setBucket } = useMediaLibrary()
|
|
884
902
|
const [viewMode, setViewMode] = useState('grid')
|
|
885
903
|
|
|
886
904
|
const items = useMemo(
|
|
@@ -891,6 +909,11 @@ function LibraryChrome({ onOpen, onPick }) {
|
|
|
891
909
|
[folders, sorted],
|
|
892
910
|
)
|
|
893
911
|
const kinds = useMemo(() => [...new Set(sorted.map((o) => o.kind))].sort(), [sorted])
|
|
912
|
+
/* THE STORE — the header at `md+`, a row above the list on a phone. `cls` on the control rather
|
|
913
|
+
* than a wrapper: a `display: contents` wrapper lost to the phone hide in one host. */
|
|
914
|
+
const store = (cls = '') => buckets.length > 1 && (
|
|
915
|
+
<Dropdown size="sm" className={`min-w-0 max-w-[45vw] md:w-48 md:max-w-none ${cls}`.trim()} options={buckets.map((b) => ({ value: b.id, label: b.label }))} value={bucket} onChange={setBucket} aria-label="Store" />
|
|
916
|
+
)
|
|
894
917
|
|
|
895
918
|
return (
|
|
896
919
|
<>
|
|
@@ -900,16 +923,40 @@ function LibraryChrome({ onOpen, onPick }) {
|
|
|
900
923
|
titleIcon="folder"
|
|
901
924
|
totalCount={items.length}
|
|
902
925
|
searchKeys={['name']}
|
|
903
|
-
viewMode={viewMode}
|
|
904
|
-
onViewModeChange={setViewMode}
|
|
905
|
-
viewModeOptions={VIEW_OPTIONS}
|
|
906
926
|
mutuallyExclusiveFilters={['kind']}
|
|
907
927
|
filterGroups={[{ label: 'Kind', key: 'kind', values: ['folder', ...kinds] }]}
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
928
|
+
/* THE WALL'S ROW (2026-10-08, one header for the family): the view pair as icons on the row's
|
|
929
|
+
* trailing slot, the sort as the row below at `md+` — what `MediaLibraryLibrary` draws — and
|
|
930
|
+
* below `md` both fold into one `···`, the browse page's fold. It wore GRID · LIST as text and
|
|
931
|
+
* four sort cells in the header. The store (when the client has more than one) rides the
|
|
932
|
+
* header at `md+` and is a row above the list on a phone. Decided on the recommendation. */
|
|
933
|
+
headerActions={store('max-md:hidden')}
|
|
934
|
+
trailingActions={(
|
|
935
|
+
<>
|
|
936
|
+
<div className="hidden md:flex items-center gap-6"><ViewToggle viewMode={viewMode} onViewChange={setViewMode} variant="icon" options={VIEW_OPTIONS} /></div>
|
|
937
|
+
<div className="md:hidden">
|
|
938
|
+
<MenuItem label={<Icon name="more" size={16} />} caret={false} align="end" size="sm" buttonClassName="shrink-0 px-2">
|
|
939
|
+
{({ close }) => (
|
|
940
|
+
<div className="py-1 w-[200px]">
|
|
941
|
+
{VIEW_OPTIONS.map((opt) => (
|
|
942
|
+
<MenuDropdownItem key={opt.value} onClick={() => { setViewMode(opt.value); close() }} shortcut={viewMode === opt.value ? <Icon name="check" size={11} /> : undefined}>{opt.label}</MenuDropdownItem>
|
|
943
|
+
))}
|
|
944
|
+
<MenuDropdownDivider />
|
|
945
|
+
{SORT_OPTIONS.map((opt) => (
|
|
946
|
+
<MenuDropdownItem key={opt.value} onClick={() => { sortBy(opt.value); close() }} shortcut={sort.by === opt.value ? <Icon name={sort.dir === 'desc' ? 'arrow-down' : 'arrow-up'} size={11} /> : undefined}>{opt.label}</MenuDropdownItem>
|
|
947
|
+
))}
|
|
948
|
+
</div>
|
|
949
|
+
)}
|
|
950
|
+
</MenuItem>
|
|
951
|
+
</div>
|
|
952
|
+
</>
|
|
953
|
+
)}
|
|
954
|
+
belowActions={<div className="h-8 hidden md:flex items-center"><SortControls /></div>}
|
|
955
|
+
renderItem={(filtered) => (
|
|
956
|
+
<>
|
|
957
|
+
{buckets.length > 1 && <div className="md:hidden flex items-center gap-3 flex-wrap" style={{ marginBottom: 'var(--kol-spacing-3)' }}>{store()}</div>}
|
|
958
|
+
<PickerBody items={filtered} viewMode={viewMode} onOpen={onOpen} onPick={onPick} />
|
|
959
|
+
</>
|
|
913
960
|
)}
|
|
914
961
|
/>
|
|
915
962
|
<PathBar />
|
|
@@ -931,7 +978,9 @@ function PickerShell({ onClose, onPick }) {
|
|
|
931
978
|
{/* While the viewer is up it owns Escape — handing the picker a no-op
|
|
932
979
|
* close means one keypress steps back one level instead of exiting the
|
|
933
980
|
* whole picker, which is the behaviour the fxr fork hand-rolled. */}
|
|
934
|
-
|
|
981
|
+
{/* `scrim` — a modal OVER the page it was opened from, not an opaque surface in its place
|
|
982
|
+
* (user, 2026-10-07, on labs: "its not overlay, its complete full screen") */}
|
|
983
|
+
<FullscreenOverlay open scrim onClose={viewerIndex === null ? onClose : () => {}}>
|
|
935
984
|
<div className="kol-media-picker">
|
|
936
985
|
<LibraryChrome onOpen={setViewerIndex} onPick={pick} />
|
|
937
986
|
</div>
|
|
@@ -2600,8 +2600,12 @@ export function MediaLibraryBrowse({
|
|
|
2600
2600
|
|
|
2601
2601
|
{/* ONE MENU FOR EVERY ROW. `useContextMenu` carries the payload from whichever row opened
|
|
2602
2602
|
* it, so this is a single instance rather than one per row, and the entries are built from
|
|
2603
|
-
* what `fileActions` actually supplies
|
|
2604
|
-
|
|
2603
|
+
* what `fileActions` actually supplies. IT MOUNTS ON A READ-ONLY BUCKET TOO (kol-website,
|
|
2604
|
+
* 2026-10-08: on B2 "I cant right click to get context modal … cant download assets"): Copy URL
|
|
2605
|
+
* and Download are read verbs — the client's URLs — and gating the whole menu on `canWrite`
|
|
2606
|
+
* took them away with the write verbs. `fa` is the write seam: empty when the bucket cannot
|
|
2607
|
+
* be written, so every write verb below folds away and the read verbs stay. */}
|
|
2608
|
+
{(() => { const fa = canWrite ? fileActions : {}; return (
|
|
2605
2609
|
<ContextMenu menu={menu}>
|
|
2606
2610
|
{(target) => {
|
|
2607
2611
|
if (!target) return null
|
|
@@ -2611,7 +2615,7 @@ export function MediaLibraryBrowse({
|
|
|
2611
2615
|
* not one of them — renaming five things to one name is not a thing — so it stays
|
|
2612
2616
|
* single and disappears from a multi-selection rather than lying about what it does. */
|
|
2613
2617
|
const many = (target.targets ?? []).length > 1 ? target.targets : null
|
|
2614
|
-
const custom = (
|
|
2618
|
+
const custom = (fa.items ?? []).filter((it) => !it.when || it.when(target))
|
|
2615
2619
|
const customItems = custom.length > 0 && (
|
|
2616
2620
|
<>
|
|
2617
2621
|
<MenuDropdownDivider />
|
|
@@ -2625,7 +2629,7 @@ export function MediaLibraryBrowse({
|
|
|
2625
2629
|
<>
|
|
2626
2630
|
<MenuDropdownItem disabled>{many.length} selected</MenuDropdownItem>
|
|
2627
2631
|
<MenuDropdownDivider />
|
|
2628
|
-
{
|
|
2632
|
+
{fa.move && (
|
|
2629
2633
|
<MenuDropdownItem iconLeft={<Icon name="arrow-right" size={14} />} onClick={() => doBatch(many, 'move', 'Move')}>Move {many.length} to…</MenuDropdownItem>
|
|
2630
2634
|
)}
|
|
2631
2635
|
{many.every((p) => !p.endsWith('/')) && (
|
|
@@ -2635,7 +2639,7 @@ export function MediaLibraryBrowse({
|
|
|
2635
2639
|
{canTag && <MenuDropdownItem iconLeft={<Icon name="hash-01" size={14} />} onClick={() => doTags(many)}>Add tags to {many.length}…</MenuDropdownItem>}
|
|
2636
2640
|
</>
|
|
2637
2641
|
)}
|
|
2638
|
-
{
|
|
2642
|
+
{fa.remove && (
|
|
2639
2643
|
<MenuDropdownItem iconLeft={<Icon name="trash" size={14} />} onClick={() => doBatch(many, 'remove', 'Delete')}>Delete {many.length}</MenuDropdownItem>
|
|
2640
2644
|
)}
|
|
2641
2645
|
{customItems}
|
|
@@ -2644,7 +2648,7 @@ export function MediaLibraryBrowse({
|
|
|
2644
2648
|
}
|
|
2645
2649
|
return (
|
|
2646
2650
|
<>
|
|
2647
|
-
{
|
|
2651
|
+
{fa.createFolder && (
|
|
2648
2652
|
<MenuDropdownItem iconLeft={<Icon name="folder" size={14} />} onClick={() => doCreateFolder(isFolder ? target.path : (isLevel ? target.path : prefix))}>
|
|
2649
2653
|
New folder{isFolder ? ` in ${target.path.replace(/\/$/, '').split('/').pop()}` : ''}
|
|
2650
2654
|
</MenuDropdownItem>
|
|
@@ -2655,7 +2659,7 @@ export function MediaLibraryBrowse({
|
|
|
2655
2659
|
<MenuDropdownItem iconLeft={<Icon name="file" size={14} />} onClick={() => newDocument(isFolder ? target.path : (isLevel ? target.path : prefix))}>
|
|
2656
2660
|
New document…
|
|
2657
2661
|
</MenuDropdownItem>
|
|
2658
|
-
) :
|
|
2662
|
+
) : fa.createFile && (
|
|
2659
2663
|
<MenuDropdownItem iconLeft={<Icon name="file" size={14} />} onClick={() => doCreateFile(isFolder ? target.path : (isLevel ? target.path : prefix))}>
|
|
2660
2664
|
New file
|
|
2661
2665
|
</MenuDropdownItem>
|
|
@@ -2666,10 +2670,10 @@ export function MediaLibraryBrowse({
|
|
|
2666
2670
|
</MenuDropdownItem>
|
|
2667
2671
|
)}
|
|
2668
2672
|
{!isLevel && <MenuDropdownDivider />}
|
|
2669
|
-
{!isLevel &&
|
|
2673
|
+
{!isLevel && fa.rename && (
|
|
2670
2674
|
<MenuDropdownItem iconLeft={<Icon name="edit" size={14} />} onClick={() => doRename(target.path, isFolder)}>Rename</MenuDropdownItem>
|
|
2671
2675
|
)}
|
|
2672
|
-
{!isLevel &&
|
|
2676
|
+
{!isLevel && fa.move && (
|
|
2673
2677
|
<MenuDropdownItem iconLeft={<Icon name="arrow-right" size={14} />} onClick={() => doMove(target.path)}>Move to…</MenuDropdownItem>
|
|
2674
2678
|
)}
|
|
2675
2679
|
{/* A FOLDER GETS COPY URL TOO (user 2026-09-23) — through the SAME `mediaUrl`
|
|
@@ -2693,7 +2697,7 @@ export function MediaLibraryBrowse({
|
|
|
2693
2697
|
{target.type === 'file' && canEdit && editKindOf(target.o ?? { key: target.path }) && (
|
|
2694
2698
|
<MenuDropdownItem iconLeft={<Icon name="edit" size={14} />} onClick={() => openEditor(objects.find((o) => o.key === target.path) ?? { key: target.path })}>Edit</MenuDropdownItem>
|
|
2695
2699
|
)}
|
|
2696
|
-
{!isLevel &&
|
|
2700
|
+
{!isLevel && fa.remove && (
|
|
2697
2701
|
<>
|
|
2698
2702
|
<MenuDropdownDivider />
|
|
2699
2703
|
<MenuDropdownItem iconLeft={<Icon name="trash" size={14} />} onClick={() => doDelete(target.path, isFolder)}>Delete</MenuDropdownItem>
|
|
@@ -2704,7 +2708,7 @@ export function MediaLibraryBrowse({
|
|
|
2704
2708
|
)
|
|
2705
2709
|
}}
|
|
2706
2710
|
</ContextMenu>
|
|
2707
|
-
)}
|
|
2711
|
+
) })()}
|
|
2708
2712
|
{busyAction && <span className="sr-only" role="status">Working…</span>}
|
|
2709
2713
|
|
|
2710
2714
|
{/* THE TAB PILL (item 16) — floats over the list, so the list owes it
|
|
@@ -3112,8 +3116,31 @@ export function MediaLibraryLibrary({
|
|
|
3112
3116
|
layoutOptions={layoutOptions}
|
|
3113
3117
|
layout={layout}
|
|
3114
3118
|
onLayoutChange={setLayout}
|
|
3115
|
-
|
|
3116
|
-
|
|
3119
|
+
/* BELOW `md` THE VIEWS AND THE SORT FOLD INTO ONE `···` (2026-10-08, apps/media/library at 390:
|
|
3120
|
+
* the FILES row, the three view icons and NAME · DATE · SIZE · KIND stacked three rows over the
|
|
3121
|
+
* list) — the browse page's fold, item 15, so the two pages fold the same way. Decided on the
|
|
3122
|
+
* recommendation, for review. */
|
|
3123
|
+
trailingActions={(
|
|
3124
|
+
<>
|
|
3125
|
+
<div className="hidden md:flex items-center gap-6"><ViewToggle viewMode={layout} onViewChange={setLayout} variant="icon" options={LAYOUT_OPTIONS} /></div>
|
|
3126
|
+
<div className="md:hidden">
|
|
3127
|
+
<MenuItem label={<Icon name="more" size={16} />} caret={false} align="end" size="sm" buttonClassName="shrink-0 px-2">
|
|
3128
|
+
{({ close }) => (
|
|
3129
|
+
<div className="py-1 w-[200px]">
|
|
3130
|
+
{LAYOUT_OPTIONS.map((opt) => (
|
|
3131
|
+
<MenuDropdownItem key={opt.value} onClick={() => { setLayout(opt.value); close() }} shortcut={layout === opt.value ? <Icon name="check" size={11} /> : undefined}>{opt.label}</MenuDropdownItem>
|
|
3132
|
+
))}
|
|
3133
|
+
<MenuDropdownDivider />
|
|
3134
|
+
{SORT_OPTIONS.map((opt) => (
|
|
3135
|
+
<MenuDropdownItem key={opt.value} onClick={() => { handleSort(opt.value); close() }} shortcut={sortBy === opt.value ? <Icon name={sortDir === 'desc' ? 'arrow-down' : 'arrow-up'} size={11} /> : undefined}>{opt.label}</MenuDropdownItem>
|
|
3136
|
+
))}
|
|
3137
|
+
</div>
|
|
3138
|
+
)}
|
|
3139
|
+
</MenuItem>
|
|
3140
|
+
</div>
|
|
3141
|
+
</>
|
|
3142
|
+
)}
|
|
3143
|
+
belowActions={layout === 'off' ? null : <div className="h-8 hidden md:flex items-center"><SortControls options={SORT_OPTIONS} sortBy={sortBy} sortDir={sortDir} onSort={handleSort} /></div>}
|
|
3117
3144
|
/* ONE WALL BODY, shared with the merged surface (2026-09-22) */
|
|
3118
3145
|
renderItem={(filtered) => (
|
|
3119
3146
|
<WallBody files={filtered} layout={layout} sortBy={sortBy} sortDir={sortDir} pageSize={pageSize} listId={listId}
|
|
@@ -25,9 +25,12 @@ export default function MediaTileGallery({ items = [], layout = 'stack', cols =
|
|
|
25
25
|
|
|
26
26
|
return (
|
|
27
27
|
<>
|
|
28
|
+
{/* `cols` is the count from `sm` up; below it the grid is two across (08-breakpoints rule 6 —
|
|
29
|
+
* grid collapse is canonical, first break sm). The count rides a variable so the theme's one
|
|
30
|
+
* phone rule can override it: an inline `gridTemplateColumns` could not be out-ranked. */}
|
|
28
31
|
<div
|
|
29
|
-
className={isGrid ? 'grid gap-4' : 'flex flex-col gap-4'}
|
|
30
|
-
style={isGrid ? {
|
|
32
|
+
className={isGrid ? 'kol-media-tiles grid gap-4' : 'flex flex-col gap-4'}
|
|
33
|
+
style={isGrid ? { '--kol-media-tiles-cols': cols } : undefined}
|
|
31
34
|
>
|
|
32
35
|
{items.map((item, i) => (
|
|
33
36
|
<figure key={i} className="min-w-0">
|
|
@@ -66,8 +66,13 @@ function ViewerStage({ media, index, onIndexChange }) {
|
|
|
66
66
|
return () => document.removeEventListener('keydown', onKey)
|
|
67
67
|
}, [emblaApi])
|
|
68
68
|
|
|
69
|
+
/* THE STAGE SETS ITS OWN WIDTH (2026-10-08, apps/media/viewer): FullscreenOverlay's sheet hugs
|
|
70
|
+
* its content, and the overlay is a grid whose one track is sized by that content — so `w-full`
|
|
71
|
+
* here resolved against nothing, the embla track grew to every slide side by side (six slides,
|
|
72
|
+
* 3888px at 1440) and the stage showed blank with the first image 1.6k off the left edge. The
|
|
73
|
+
* viewport minus the overlay's 24px lane each side is the width a fullscreen stage means. */
|
|
69
74
|
return (
|
|
70
|
-
<div className="relative w-
|
|
75
|
+
<div className="relative w-[calc(100vw-3rem)]">
|
|
71
76
|
<div className="overflow-hidden" ref={emblaRef}>
|
|
72
77
|
<div className="flex items-center">
|
|
73
78
|
{media.map((item, i) => (
|
|
@@ -122,8 +127,15 @@ export default function MediaViewer({ open, media = [], index = 0, onIndexChange
|
|
|
122
127
|
const actionRow = typeof actions === 'function' ? actions(media[index], index) : actions
|
|
123
128
|
|
|
124
129
|
return (
|
|
125
|
-
|
|
130
|
+
/* `scrim` (2026-10-08, apps/media/viewer): without it the overlay painted its opaque light
|
|
131
|
+
* surface, and the viewer's inverse ink — caption, the paging chips — drew light on light and
|
|
132
|
+
* read as "an image and nothing else". The chips (md and up) and the caption were always there. */
|
|
133
|
+
<FullscreenOverlay open scrim onClose={onClose}>
|
|
126
134
|
<ViewerStage media={media} index={index} onIndexChange={onIndexChange} />
|
|
135
|
+
{/* on a phone the chips are off and a swipe pages — the position says there is more */}
|
|
136
|
+
{media.length > 1 && (
|
|
137
|
+
<div className="md:hidden mt-3 text-center kol-helper-12 text-fg-inverse-64" aria-live="polite">{index + 1} / {media.length}</div>
|
|
138
|
+
)}
|
|
127
139
|
{actionRow && (
|
|
128
140
|
<div className="mt-4 flex items-center justify-center gap-2">{actionRow}</div>
|
|
129
141
|
)}
|
|
@@ -195,13 +195,18 @@ export default function SectionSplit({
|
|
|
195
195
|
<section
|
|
196
196
|
ref={themeRef}
|
|
197
197
|
data-theme={themeStamp}
|
|
198
|
-
className={`kol-section-split flex flex-col justify-center
|
|
198
|
+
className={`kol-section-split flex flex-col justify-center py-16 md:py-24 lg:py-32 [--kol-section-py:4rem] md:[--kol-section-py:6rem] lg:[--kol-section-py:8rem] ${minHeightClass(height)} ${surfaceClass(background, 'none')} ${bleed} ${className}`.replace(/\s+/g, ' ').trim()}
|
|
199
199
|
style={sectionStyle}
|
|
200
200
|
>
|
|
201
201
|
{/* ONE cap for the whole section family (user ruling 2026-08-26): the shell's
|
|
202
202
|
* --kol-container-max ladder — 100% → 1400 → 1600 → 1800. Split ran 1200,
|
|
203
203
|
* cards 1400, the CTA 1600: three numbers for one job, unflagged. */}
|
|
204
|
-
|
|
204
|
+
{/* …AND THE RULED GUTTER IS INSIDE THAT CAP (section-split-content-media-and-ruled-gutter,
|
|
205
|
+
* kol-website 2026-10-06): the section padded itself px-5/8/14 OUTSIDE the cap, so its text
|
|
206
|
+
* started 56 at 1440 against 48 for every `.kol-page` band below it, and at 1920 the padding
|
|
207
|
+
* fell outside the cap and did nothing. Now the inner box is `.kol-page`'s own measure —
|
|
208
|
+
* the cap, then `--kol-pad-section-x` inside it. `fill` keeps its edge-to-edge halves. */}
|
|
209
|
+
<div className={`w-full max-w-[var(--kol-container-max,var(--kol-content-shell,1800px))] mx-auto px-[var(--kol-pad-section-x,20px)] ${grid} ${innerClassName}`.replace(/\s+/g, ' ').trim()}>
|
|
205
210
|
{/* `order` rather than `flex-row-reverse`: the grid is one column below
|
|
206
211
|
* 901px, and DOM order is what decides the stack there. */}
|
|
207
212
|
<SectionText
|
|
@@ -262,8 +267,13 @@ export default function SectionSplit({
|
|
|
262
267
|
* rendered 2.22 at 1600 and cropped its image. From 901 the width is `auto` again:
|
|
263
268
|
* the rung sets the height, the ratio sets the width, the column caps it — the
|
|
264
269
|
* 2026-08-27 rule written on the `ratio` prop. */
|
|
265
|
-
|
|
266
|
-
|
|
270
|
+
/* `ratio="auto"` IS CONTENT, NOT A FRAME (section-split-content-media-and-ruled-gutter,
|
|
271
|
+
* kol-website 2026-10-06): with no ratio there is nothing for the width to follow, so
|
|
272
|
+
* the rung-minus-padding height made a fixed window over a card with its own height —
|
|
273
|
+
* /studio's 621px ProfileCard showed as a 104px strip at 1440. Auto media takes the
|
|
274
|
+
* column's width and its own height, at every width; a ratio keeps the bounded rule. */
|
|
275
|
+
className={`kol-section-split-visual relative w-full max-w-full justify-self-center rounded-[var(--kol-radius-sm)] ${ratio === 'auto' ? '' : 'min-[901px]:w-auto min-[901px]:h-[calc(var(--kol-section-h,60vh)_-_2*var(--kol-section-py,4rem))]'} ${mediaClip ? 'overflow-hidden' : ''} ${mediaHover ? 'is-hoverable' : ''} ${mediaFirst ? 'order-1' : ''} ${centred ? 'max-w-[640px]' : ''}`.replace(/\s+/g, ' ').trim()}
|
|
276
|
+
style={ratio === 'auto' ? undefined : { aspectRatio: ratio }}
|
|
267
277
|
>
|
|
268
278
|
{media ?? (placeholder === true
|
|
269
279
|
? <AssetPlaceholder className="h-full w-full" />
|
|
@@ -121,7 +121,10 @@ export function usePopover({
|
|
|
121
121
|
|
|
122
122
|
const interactions = useInteractions([
|
|
123
123
|
useClick(data.context, { enabled: click }),
|
|
124
|
-
|
|
124
|
+
/* `mouseOnly` — a hover is a mouse's (2026-10-05, found on labs' phone sheet). Touch has none:
|
|
125
|
+
* a tap read as a hover that never ends, so a tooltip opened on the tap and stayed over the
|
|
126
|
+
* next control. Only Tooltip opens on hover, and the keyboard still reaches it through `focus`. */
|
|
127
|
+
useHover(data.context, { enabled: hover, delay: hoverDelay, move: false, mouseOnly: true }),
|
|
125
128
|
useFocus(data.context, { enabled: focus }),
|
|
126
129
|
/* `dismiss` may be an object of floating-ui's own options — `{ outsidePress: false }` keeps Esc
|
|
127
130
|
* and drops the outside click (Dropdown `stayOpen`) */
|