@kolkrabbi/kol-workshop 0.34.0 → 0.36.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 +9 -8
- package/src/docs/DocsFrontmatter.jsx +10 -4
- package/src/docs/DocumentationReader.jsx +21 -4
- package/src/exhibit/ExhibitCard.jsx +1 -1
- package/src/exhibit/ExhibitLinkCard.jsx +1 -1
- package/src/exhibit/ExhibitOverview.jsx +1 -1
- package/src/exhibit/ExhibitSidebar.jsx +1 -1
- package/src/search/ResultRow.jsx +6 -4
- package/src/search/SearchPage.jsx +103 -68
- package/src/shell/RailSection.jsx +5 -3
- package/src/shell/RightRail.jsx +43 -22
- package/src/shell/ShellLayout.jsx +54 -12
- package/src/shell/ShellSidebar.jsx +89 -57
- package/src/tags/TagGraph.jsx +180 -165
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createContext, useContext, useMemo, useRef, useState, useEffect, Suspense } from 'react'
|
|
2
|
-
import { Outlet, Link, useLocation, useNavigate } from 'react-router-dom'
|
|
2
|
+
import { Outlet, Link, useLocation, useNavigate, useNavigationType } from 'react-router-dom'
|
|
3
3
|
import { ShellHeader } from '@kolkrabbi/kol-framework'
|
|
4
4
|
import ShellSidebar, { RAIL_FOLD_EVENT } from './ShellSidebar.jsx'
|
|
5
5
|
import { IconFrame, ShellDrawer, ShellSearchOverlay, ShortcutsOverlay, SettingsPanel, SettingsSections, SettingsSwitch, Tooltip, useDragResize } from '@kolkrabbi/kol-component'
|
|
@@ -99,7 +99,7 @@ const useRailGrab = (token, side, hide) => {
|
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
const NavColumn = ({ children, grip, railRef }) => (
|
|
102
|
-
<aside ref={railRef} aria-label="Navigation" className="shell-rail shell-rail--nav shell-sidebar-sticky hidden lg:block pt-6 md:pt-6 lg:pt-8 pb-
|
|
102
|
+
<aside ref={railRef} aria-label="Navigation" className="shell-rail shell-rail--nav shell-sidebar-sticky hidden lg:block pt-6 md:pt-6 lg:pt-8 pb-14">
|
|
103
103
|
{children}
|
|
104
104
|
{grip}
|
|
105
105
|
</aside>
|
|
@@ -139,7 +139,7 @@ const MainColumn = ({ children, fullHeight, width = 'canvas', padStart, padEnd }
|
|
|
139
139
|
* below). The breakpoint here and the one in gridCols are one decision and
|
|
140
140
|
* must not be stated twice differently. */
|
|
141
141
|
const TocColumn = ({ children, grip, railRef }) => (
|
|
142
|
-
<aside ref={railRef} aria-label="Table of contents" className="shell-rail shell-rail--toc shell-sidebar-sticky hidden xl:block pt-6 md:pt-6 lg:pt-8 pb-
|
|
142
|
+
<aside ref={railRef} aria-label="Table of contents" className="shell-rail shell-rail--toc shell-sidebar-sticky hidden xl:block pt-6 md:pt-6 lg:pt-8 pb-14">
|
|
143
143
|
{grip}
|
|
144
144
|
{/* The width is the grid track (--kol-shell-toc-w). An empty rail still
|
|
145
145
|
* holds its column (user ruling 2026-08-01): a rail that disappears
|
|
@@ -231,28 +231,58 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
|
|
|
231
231
|
|
|
232
232
|
/* a new page starts at the top of the one scroll region; a hash is the
|
|
233
233
|
* browser's to resolve */
|
|
234
|
+
/* BACK RETURNS TO WHERE YOU WERE (2026-10-01 — user: "'back' loses the place vertically in the
|
|
235
|
+
* scroll … now I have to scroll and find my place"). Each history entry remembers its own
|
|
236
|
+
* scroll offset; Back/Forward (POP) restores it, a fresh navigation still starts at the top.
|
|
237
|
+
* The page may still be laying out when the entry returns (lazy walls, async demos), so the
|
|
238
|
+
* restore is retried for a moment until the region is tall enough to reach the offset. */
|
|
239
|
+
const navigationType = useNavigationType()
|
|
240
|
+
const scrollMemory = useRef(new Map())
|
|
234
241
|
useEffect(() => {
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
242
|
+
const el = document.getElementById('shell-scroll')
|
|
243
|
+
if (!el) return undefined
|
|
244
|
+
const key = location.key
|
|
245
|
+
const save = () => scrollMemory.current.set(key, el.scrollTop)
|
|
246
|
+
el.addEventListener('scroll', save, { passive: true })
|
|
247
|
+
return () => el.removeEventListener('scroll', save)
|
|
248
|
+
}, [location.key])
|
|
249
|
+
useEffect(() => {
|
|
250
|
+
if (location.hash) return undefined
|
|
251
|
+
const el = document.getElementById('shell-scroll')
|
|
252
|
+
if (!el) return undefined
|
|
253
|
+
const saved = navigationType === 'POP' ? scrollMemory.current.get(location.key) : 0
|
|
254
|
+
if (!saved) { el.scrollTo(0, 0); return undefined }
|
|
255
|
+
let frame
|
|
256
|
+
let tries = 60
|
|
257
|
+
const restore = () => {
|
|
258
|
+
el.scrollTo(0, saved)
|
|
259
|
+
if (Math.abs(el.scrollTop - saved) > 1 && tries-- > 0) frame = requestAnimationFrame(restore)
|
|
260
|
+
}
|
|
261
|
+
restore()
|
|
262
|
+
return () => cancelAnimationFrame(frame)
|
|
263
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
264
|
+
}, [location.pathname, location.hash, location.key])
|
|
238
265
|
|
|
239
266
|
/* THE shortcut map. One list, rendered by the `S` sheet AND bound by the
|
|
240
267
|
* handler below — a shortcut that isn't in this array doesn't exist, so the
|
|
241
268
|
* sheet can never drift from the bindings. The sheet is kol-component's
|
|
242
269
|
* ShortcutsOverlay (2026-09-28): the same sheet, on the same key, as every
|
|
243
270
|
* app — it was a `?` sheet of its own here. `?` still opens it. */
|
|
271
|
+
/* FEWEST WORDS (the showcase review W16, 2026-09-30 — user: "shortcuts overlay, too wordy"):
|
|
272
|
+
* a label names the thing the key does, nothing more. `\\` hides or shows both rails at once. */
|
|
244
273
|
const SHORTCUTS = [
|
|
245
274
|
{ section: 'Search', items: [
|
|
246
|
-
{ id: 'k', label: 'Search
|
|
247
|
-
{ id: 'enter', label: 'All results
|
|
275
|
+
{ id: 'k', label: 'Search', combo: '⌘ K , /' },
|
|
276
|
+
{ id: 'enter', label: 'All results', combo: '↵' },
|
|
248
277
|
] },
|
|
249
278
|
{ section: 'Shell', items: [
|
|
250
|
-
{ id: 'sheet', label: '
|
|
279
|
+
{ id: 'sheet', label: 'Shortcuts', combo: 'S' },
|
|
251
280
|
{ id: 'settings', label: 'Settings', combo: ',' },
|
|
252
281
|
{ id: 'nav', label: 'Left rail', combo: '[' },
|
|
253
282
|
{ id: 'toc', label: 'Right rail', combo: ']' },
|
|
254
|
-
{ id: '
|
|
255
|
-
{ id: '
|
|
283
|
+
{ id: 'rails', label: 'Both rails', combo: '\\' },
|
|
284
|
+
{ id: 'fold', label: 'Fold all', combo: 'C' },
|
|
285
|
+
{ id: 'esc', label: 'Close', combo: 'Esc' },
|
|
256
286
|
] },
|
|
257
287
|
...(shortcuts.length ? [{ section: 'Page', items: shortcuts.map(({ id, label, combo }) => ({ id, label, combo })) }] : []),
|
|
258
288
|
]
|
|
@@ -288,6 +318,14 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
|
|
|
288
318
|
} else if (e.key === ']') {
|
|
289
319
|
e.preventDefault()
|
|
290
320
|
setTocCollapsed((v) => { writeSettings({ ...readSettings(), tocHidden: !v }); return !v })
|
|
321
|
+
} else if (e.key === '\\') {
|
|
322
|
+
/* BOTH RAILS (W16): any rail showing → hide both; both hidden → show both */
|
|
323
|
+
e.preventDefault()
|
|
324
|
+
const s = readSettings()
|
|
325
|
+
const hide = !(s.navHidden && s.tocHidden)
|
|
326
|
+
setNavCollapsed(hide)
|
|
327
|
+
setTocCollapsed(hide)
|
|
328
|
+
writeSettings({ ...s, navHidden: hide, tocHidden: hide })
|
|
291
329
|
} else if (e.key === 'c' || e.key === 'C') {
|
|
292
330
|
e.preventDefault()
|
|
293
331
|
foldedRef.current = !foldedRef.current
|
|
@@ -437,7 +475,7 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
|
|
|
437
475
|
{ id: 'toc', label: 'Right rail', render: () => <SettingsSwitch on={!tocCollapsed} onChange={(on) => { setTocCollapsed(!on); setPref('tocHidden', !on) }} /> },
|
|
438
476
|
] },
|
|
439
477
|
{ label: 'Search', rows: [
|
|
440
|
-
{ id: 'scope', label: '
|
|
478
|
+
{ id: 'scope', label: 'This space only', render: () => <SettingsSwitch on={!!prefs.limitToSpace} onChange={(on) => setPref('limitToSpace', on)} /> },
|
|
441
479
|
] },
|
|
442
480
|
...settings,
|
|
443
481
|
{ label: 'Keys', rowGap: 1, rows: SHORTCUTS.flatMap((sec) => sec.items).map((k) => ({
|
|
@@ -549,6 +587,10 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
|
|
|
549
587
|
* it. Without a `searchPath` Enter keeps the older in-place browser. */
|
|
550
588
|
onExpand={searchPath ? openResultsPage : () => tagMode.setExpanded?.(true)}
|
|
551
589
|
enterLabel={searchPath ? `All results for “${searchQuery.trim()}”` : undefined}
|
|
590
|
+
/* ⌘ENTER AND THE FOOTER LINK open the results page (2026-10-01); plain Enter opens
|
|
591
|
+
* the highlighted row and only falls through to the page when nothing matched. */
|
|
592
|
+
onOpenResults={searchPath ? openResultsPage : undefined}
|
|
593
|
+
resultsLabel={`All results for “${searchQuery.trim()}”`}
|
|
552
594
|
chips={tagMode.isProvided ? tagMode.activeTags : []}
|
|
553
595
|
onRemoveChip={tagMode.removeTag}
|
|
554
596
|
placeholder="Search…"
|
|
@@ -39,16 +39,44 @@ const ShellSidebar = ({ routes = [], basePath = '/', onNavigate, label = 'Naviga
|
|
|
39
39
|
|
|
40
40
|
/* THE RAIL FOLLOWS YOU — INTO A CHILD ONLY (2026-09-30, reversing the 2026-09-28 "open the
|
|
41
41
|
* group you are in" on its landing page too). Landing on a page INSIDE a chapter opens that
|
|
42
|
-
* chapter and folds the rest; a chapter's own home
|
|
42
|
+
* chapter and folds the rest; a chapter's own home keeps that chapter open (2026-10-01, see
|
|
43
|
+
* `chainTo`). Every other chapter starts
|
|
43
44
|
* folded — including ones this rail has never seen (a Group-by switch hands it new ids, and an
|
|
44
|
-
* unknown id used to read as open, so every chapter sprang open).
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
* unknown id used to read as open, so every chapter sprang open).
|
|
46
|
+
*
|
|
47
|
+
* ANY DEPTH (2026-09-30, the showcase review W1 — user: Library › Composition › Components ›
|
|
48
|
+
* Atoms › Button). A chapter may hold chapters; every group at every depth is a fold of its own,
|
|
49
|
+
* and the chain down to where you are opens and everything off it folds. */
|
|
50
|
+
const isHere = (route) => {
|
|
51
|
+
/* a pathless group is not a place — without this its basePath fallback matched every URL */
|
|
52
|
+
if (!route.path) return false
|
|
53
|
+
const cp = getChildPath(route, basePath).replace(/\/$/, '')
|
|
47
54
|
return normalizedPath === cp || normalizedPath.startsWith(cp + '/')
|
|
48
|
-
}
|
|
55
|
+
}
|
|
56
|
+
const groupsOf = (list) => list.flatMap((r) => (r.children?.length ? [r, ...groupsOf(r.children)] : []))
|
|
57
|
+
/* ONE PLACE YOU ARE (W4, 2026-09-30). A page can sit in more than one group — Button is an
|
|
58
|
+
* atom AND a member of every set that uses it — and opening every group that holds it would
|
|
59
|
+
* unfold half the rail. The first place in tree order wins: its chain opens, the rest fold. */
|
|
60
|
+
const chainTo = (list, trail = []) => {
|
|
61
|
+
for (const r of list) {
|
|
62
|
+
if (!r.children?.length) continue
|
|
63
|
+
/* deepest first: `/sets` holds `/sets/app-shell` by prefix, but the set's own row is the place */
|
|
64
|
+
const deeper = chainTo(r.children, [...trail, r])
|
|
65
|
+
if (deeper) return deeper
|
|
66
|
+
if (r.children.some(isHere)) return [...trail, r]
|
|
67
|
+
/* A GROUP'S OWN HOME KEEPS IT OPEN (user 2026-10-01: *"I dont want to collapse the list
|
|
68
|
+
* like that when I just want to go to atoms home and still see the atoms list"* — reverses
|
|
69
|
+
* the 2026-09-30 "a chapter's own home opens nothing"). The label is a link; only the
|
|
70
|
+
* chevron folds. */
|
|
71
|
+
if (r.path && getChildPath(r, basePath).replace(/\/$/, '') === normalizedPath) return [...trail, r]
|
|
72
|
+
}
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
49
75
|
const followed = () => {
|
|
50
|
-
const
|
|
51
|
-
|
|
76
|
+
const chain = chainTo(routes)
|
|
77
|
+
if (!chain) return null
|
|
78
|
+
const open = new Set(chain.map((g) => g.id))
|
|
79
|
+
return Object.fromEntries(groupsOf(routes).map((g) => [g.id, !open.has(g.id)]))
|
|
52
80
|
}
|
|
53
81
|
const [collapsedSections, setCollapsedSections] = useState(() => followed() ?? {})
|
|
54
82
|
useEffect(() => {
|
|
@@ -61,7 +89,7 @@ const ShellSidebar = ({ routes = [], basePath = '/', onNavigate, label = 'Naviga
|
|
|
61
89
|
/* FOLD ALL (2026-09-30, the names audit: *"shortcut collapse/expand the categories"*). The
|
|
62
90
|
* shell's `C` key broadcasts one target state; every rail's chapters take it. */
|
|
63
91
|
useEffect(() => {
|
|
64
|
-
const onFold = (e) => setCollapsedSections(Object.fromEntries(routes.map((r) => [r.id, !!e.detail?.collapsed])))
|
|
92
|
+
const onFold = (e) => setCollapsedSections(Object.fromEntries(groupsOf(routes).map((r) => [r.id, !!e.detail?.collapsed])))
|
|
65
93
|
window.addEventListener(RAIL_FOLD_EVENT, onFold)
|
|
66
94
|
return () => window.removeEventListener(RAIL_FOLD_EVENT, onFold)
|
|
67
95
|
}, [routes])
|
|
@@ -71,8 +99,37 @@ const ShellSidebar = ({ routes = [], basePath = '/', onNavigate, label = 'Naviga
|
|
|
71
99
|
}
|
|
72
100
|
|
|
73
101
|
/* The L1 count is every leaf row under it — RailSection shows it only while
|
|
74
|
-
* the section is folded (2026-09-28). */
|
|
75
|
-
const
|
|
102
|
+
* the section is folded (2026-09-28). At any depth: leaves, not groups. */
|
|
103
|
+
const leavesOf = (list) => list.reduce((n, r) => n + (r.children?.length ? leavesOf(r.children) : 1), 0)
|
|
104
|
+
const leafCount = leavesOf(routes)
|
|
105
|
+
|
|
106
|
+
/* ONE GROUP, AT ANY DEPTH. A chapter's child with children of its own is a chapter again — the
|
|
107
|
+
* same RailSection rung, the same count and chevron, stepped in one row-indent by `.shell-nav-nest`
|
|
108
|
+
* so its caret sits on its siblings' text edge. A leaf is a RailRow wherever it lands. */
|
|
109
|
+
const renderGroup = (route) => (
|
|
110
|
+
<RailSection
|
|
111
|
+
level={2}
|
|
112
|
+
label={route.label}
|
|
113
|
+
count={route.children.length}
|
|
114
|
+
/* THE HEADER ALWAYS LINKS (2026-08-02): the label opens the chapter's home,
|
|
115
|
+
* the chevron folds. */
|
|
116
|
+
to={route.path ?? getSectionRootPath(route, basePath)}
|
|
117
|
+
collapsed={isFolded(route)}
|
|
118
|
+
onToggle={() => handleSectionClick(route)}
|
|
119
|
+
onNavigate={onNavigate}
|
|
120
|
+
icon={Icon}
|
|
121
|
+
>
|
|
122
|
+
<nav className="shell-nav-items">
|
|
123
|
+
{route.children.map((child) => (child.children?.length ? (
|
|
124
|
+
<div key={child.id} className="shell-nav-nest">{renderGroup(child)}</div>
|
|
125
|
+
) : (
|
|
126
|
+
<RailRow key={child.id} to={getChildPath(child, basePath)} onNavigate={onNavigate}>
|
|
127
|
+
{child.label}
|
|
128
|
+
</RailRow>
|
|
129
|
+
)))}
|
|
130
|
+
</nav>
|
|
131
|
+
</RailSection>
|
|
132
|
+
)
|
|
76
133
|
return (
|
|
77
134
|
/* One rail layout, one class (2026-08-01). This was `space-y-4` against the
|
|
78
135
|
* right rail's `space-y-6` against the outer `flex flex-col gap-6` — three
|
|
@@ -90,55 +147,30 @@ const ShellSidebar = ({ routes = [], basePath = '/', onNavigate, label = 'Naviga
|
|
|
90
147
|
icon={Icon}
|
|
91
148
|
>
|
|
92
149
|
<div className="shell-rail-stack-inner">
|
|
93
|
-
{
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
150
|
+
{/* LOOSE ROWS SHARE ONE LIST (the showcase review W12, 2026-09-30 — user: "the spaces are
|
|
151
|
+
* incorrect in the left sidebar"). Each childless entry rendered its own <nav>, and the
|
|
152
|
+
* stack's 16px gap landed between every pair, so a rail of four rows (Search) read 46px
|
|
153
|
+
* apart instead of a list's 26. Consecutive rows are one run now; a chapter breaks it. */}
|
|
154
|
+
{routes.reduce((runs, route) => {
|
|
155
|
+
const last = runs[runs.length - 1]
|
|
156
|
+
if (!route.children?.length && last && !last.group) last.rows.push(route)
|
|
157
|
+
else runs.push(route.children?.length ? { group: route } : { rows: [route] })
|
|
158
|
+
return runs
|
|
159
|
+
}, []).map((run) => (run.group ? (
|
|
160
|
+
<div key={run.group.id} className="shell-nav-group">
|
|
161
|
+
{renderGroup(run.group)}
|
|
162
|
+
</div>
|
|
163
|
+
) : (
|
|
101
164
|
/* A CHILDLESS ENTRY IS A ROW, not a chapter (2026-09-30): drawn at L2 it read as an
|
|
102
165
|
* empty category with a blank caret slot. */
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
<
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
return (
|
|
114
|
-
<div key={route.id} className="shell-nav-group">
|
|
115
|
-
<RailSection
|
|
116
|
-
level={2}
|
|
117
|
-
label={route.label}
|
|
118
|
-
count={route.children.length}
|
|
119
|
-
/* THE HEADER ALWAYS LINKS (2026-08-02): the label opens the chapter's home,
|
|
120
|
-
* the chevron folds. */
|
|
121
|
-
to={route.path ?? getSectionRootPath(route, basePath)}
|
|
122
|
-
collapsed={isFolded(route)}
|
|
123
|
-
onToggle={() => handleSectionClick(route)}
|
|
124
|
-
onNavigate={onNavigate}
|
|
125
|
-
icon={Icon}
|
|
126
|
-
>
|
|
127
|
-
<nav className="shell-nav-items">
|
|
128
|
-
{route.children.map((child) => (
|
|
129
|
-
<RailRow
|
|
130
|
-
key={child.id}
|
|
131
|
-
to={getChildPath(child, basePath)}
|
|
132
|
-
onNavigate={onNavigate}
|
|
133
|
-
>
|
|
134
|
-
{child.label}
|
|
135
|
-
</RailRow>
|
|
136
|
-
))}
|
|
137
|
-
</nav>
|
|
138
|
-
</RailSection>
|
|
139
|
-
</div>
|
|
140
|
-
)
|
|
141
|
-
})}
|
|
166
|
+
<nav key={run.rows[0].id} className="shell-nav-items">
|
|
167
|
+
{run.rows.map((route) => (
|
|
168
|
+
<RailRow key={route.id} to={route.path ?? getSectionRootPath(route, basePath)} onNavigate={onNavigate}>
|
|
169
|
+
{route.label}
|
|
170
|
+
</RailRow>
|
|
171
|
+
))}
|
|
172
|
+
</nav>
|
|
173
|
+
)))}
|
|
142
174
|
</div>
|
|
143
175
|
</RailSection>
|
|
144
176
|
</div>
|