@kolkrabbi/kol-workshop 0.32.0 → 0.34.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-workshop",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.34.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "KOL workshop/docs system — a handrolled markdown engine, search, tag system (incl. a d3 tag graph), docs viewer, and docs shell. Sits above @kolkrabbi/kol-{theme,icons,component,framework}; content is consumer-injected (the package never globs docs).",
|
|
6
6
|
"license": "MIT",
|
|
@@ -13,12 +13,12 @@
|
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
15
|
"d3": "^7.9.0",
|
|
16
|
-
"@kolkrabbi/kol-brand": "^0.1.3",
|
|
17
16
|
"@kolkrabbi/kol-markdown": "^0.1.2",
|
|
18
|
-
"@kolkrabbi/kol-search": "^0.2.0"
|
|
17
|
+
"@kolkrabbi/kol-search": "^0.2.0",
|
|
18
|
+
"@kolkrabbi/kol-brand": "^0.1.3"
|
|
19
19
|
},
|
|
20
20
|
"peerDependencies": {
|
|
21
|
-
"@kolkrabbi/kol-component": ">=0.
|
|
21
|
+
"@kolkrabbi/kol-component": ">=0.231.0",
|
|
22
22
|
"@kolkrabbi/kol-framework": ">=0.45.0",
|
|
23
23
|
"@kolkrabbi/kol-theme": ">=0.155.0",
|
|
24
24
|
"@kolkrabbi/kol-icons": ">=0.18.0",
|
|
@@ -27,10 +27,10 @@
|
|
|
27
27
|
"react-router-dom": "^6.0.0 || ^7.0.0"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
|
-
"@kolkrabbi/kol-
|
|
31
|
-
"@kolkrabbi/kol-
|
|
32
|
-
"@kolkrabbi/kol-theme": "^0.
|
|
33
|
-
"@kolkrabbi/kol-framework": "^0.
|
|
30
|
+
"@kolkrabbi/kol-icons": "^0.31.0",
|
|
31
|
+
"@kolkrabbi/kol-component": "^0.231.0",
|
|
32
|
+
"@kolkrabbi/kol-theme": "^0.159.0",
|
|
33
|
+
"@kolkrabbi/kol-framework": "^0.48.0"
|
|
34
34
|
},
|
|
35
35
|
"files": [
|
|
36
36
|
"src",
|
|
@@ -85,6 +85,9 @@ const DocumentationReader = ({
|
|
|
85
85
|
routes = {},
|
|
86
86
|
docId: docIdProp,
|
|
87
87
|
showFrontmatter = true,
|
|
88
|
+
/* false → render the article only and leave the right rail to the page (a HOME sits on top of
|
|
89
|
+
* a page whose sections the rail must list — 2026-09-30, "This page (0)" on every home) */
|
|
90
|
+
rail = true,
|
|
88
91
|
}) => {
|
|
89
92
|
const {
|
|
90
93
|
docsIndex = '/docs',
|
|
@@ -309,7 +312,7 @@ const DocumentationReader = ({
|
|
|
309
312
|
* ShellTocContext then defaults to null, so calling it unguarded threw.
|
|
310
313
|
* No shell → no right rail, and the reader just renders its article. */
|
|
311
314
|
useEffect(() => {
|
|
312
|
-
if (!setTocContent) return undefined
|
|
315
|
+
if (!setTocContent || !rail) return undefined
|
|
313
316
|
setTocContent(
|
|
314
317
|
<DocReaderSidebar
|
|
315
318
|
key={docId}
|
|
@@ -324,7 +327,7 @@ const DocumentationReader = ({
|
|
|
324
327
|
/>
|
|
325
328
|
)
|
|
326
329
|
return () => setTocContent(null)
|
|
327
|
-
}, [setTocContent, docId, toc, allTags, topTags, related, docsIndex, components, docFilePath])
|
|
330
|
+
}, [rail, setTocContent, docId, toc, allTags, topTags, related, docsIndex, components, docFilePath])
|
|
328
331
|
|
|
329
332
|
if (!doc) {
|
|
330
333
|
return (
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useState } from 'react'
|
|
2
2
|
import { Link } from 'react-router-dom'
|
|
3
|
+
import { Icon } from '@kolkrabbi/kol-component'
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* RailSection — THE rail ladder. One component owns every rail header, at
|
|
@@ -70,7 +71,7 @@ export default function RailSection({
|
|
|
70
71
|
onToggle,
|
|
71
72
|
defaultCollapsed = false,
|
|
72
73
|
onNavigate,
|
|
73
|
-
icon: IconComponent,
|
|
74
|
+
icon: IconComponent = Icon,
|
|
74
75
|
children,
|
|
75
76
|
}) {
|
|
76
77
|
const [internal, setInternal] = useState(defaultCollapsed)
|
|
@@ -120,14 +121,16 @@ export default function RailSection({
|
|
|
120
121
|
* the eyebrow felt expand-only. A category's door lives in the header tabs.
|
|
121
122
|
* L2 keeps the split (2026-08-02 ruling): its label is a chapter's only
|
|
122
123
|
* door to the landing page, and the chevron marks the toggle. */
|
|
123
|
-
|
|
124
|
+
/* L1 JOINS L2 (2026-09-30, user: every label opens its home — *"components from the top and
|
|
125
|
+
* components go to the same components home"*; reverses the 2026-08-09 pure-toggle eyebrow). */
|
|
126
|
+
const labelNode = to ? (
|
|
124
127
|
/* The label navigates; the rest of the row toggles. stopPropagation keeps
|
|
125
128
|
* one click from doing both. */
|
|
126
129
|
<Link
|
|
127
130
|
to={to}
|
|
128
131
|
onClick={(e) => {
|
|
132
|
+
/* the label opens the page, the chevron folds (audit § Homes) */
|
|
129
133
|
e.stopPropagation()
|
|
130
|
-
if (isCollapsed && toggle) toggle()
|
|
131
134
|
if (onNavigate) onNavigate(e)
|
|
132
135
|
}}
|
|
133
136
|
>
|
|
@@ -2,7 +2,7 @@ import { createContext, useContext, useMemo, useRef, useState, useEffect, Suspen
|
|
|
2
2
|
import { Outlet, Link, useLocation, useNavigate } from 'react-router-dom'
|
|
3
3
|
import { ShellHeader } from '@kolkrabbi/kol-framework'
|
|
4
4
|
import ShellSidebar, { RAIL_FOLD_EVENT } from './ShellSidebar.jsx'
|
|
5
|
-
import { IconFrame, ShellDrawer, ShellSearchOverlay, ShortcutsOverlay, SettingsSections, SettingsSwitch, Tooltip } from '@kolkrabbi/kol-component'
|
|
5
|
+
import { IconFrame, ShellDrawer, ShellSearchOverlay, ShortcutsOverlay, SettingsPanel, SettingsSections, SettingsSwitch, Tooltip, useDragResize } from '@kolkrabbi/kol-component'
|
|
6
6
|
import { createIndex, search } from '@kolkrabbi/kol-search'
|
|
7
7
|
import { useTagMode } from '../tags/TagModeContext.jsx'
|
|
8
8
|
import TagModeOverlay from '../tags/TagModeOverlay.jsx'
|
|
@@ -79,37 +79,27 @@ export const SHELL_SCROLL_ROOT = '#shell-scroll'
|
|
|
79
79
|
* overflow (`.shell-rail`, kol-components-workshop.css): exactly one region
|
|
80
80
|
* tall, a seam toward the page, the chrome inset inside the seam.
|
|
81
81
|
* overflow-x-hidden (2026-07-30): long tree rows must not scroll sideways. */
|
|
82
|
-
/* RESIZABLE RAILS (2026-09-30
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
* token
|
|
86
|
-
* the
|
|
87
|
-
|
|
88
|
-
const
|
|
89
|
-
const
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
99
|
-
return (
|
|
100
|
-
<div
|
|
101
|
-
role="separator"
|
|
102
|
-
aria-orientation="vertical"
|
|
103
|
-
aria-label="Resize rail"
|
|
104
|
-
onPointerDown={start}
|
|
105
|
-
onDoubleClick={() => onChange(null)}
|
|
106
|
-
className={`absolute top-0 hidden h-full w-1.5 cursor-col-resize hover:bg-oq-08 lg:block ${side === 'end' ? 'right-0' : 'left-0'}`}
|
|
107
|
-
/>
|
|
108
|
-
)
|
|
82
|
+
/* RESIZABLE RAILS (2026-09-30) — THE DS GESTURE, not a local one: `useDragResize` (the grab edge
|
|
83
|
+
* SideNav and EditorShell wear — drag resizes, a click or a drag under the snap collapses, arrows
|
|
84
|
+
* step, release near the default snaps back) and the `.kol-rail-grab` pill. Each rail has its own
|
|
85
|
+
* token family (`kol-shell-nav` · `kol-shell-toc`, tokens in kol-framework.css) so the two never
|
|
86
|
+
* drag together; width lasts the session (persistWidth off, the 2026-09-03 ruling). A collapse
|
|
87
|
+
* from the grab is the shell's own "hide the rail" — the same state `[` / `]` toggle. */
|
|
88
|
+
const useRailGrab = (token, side, hide) => {
|
|
89
|
+
const ref = useRef(null)
|
|
90
|
+
const { collapsed, toggleCollapsed, grabProps } = useDragResize(ref, { token, side })
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
if (!collapsed) return
|
|
93
|
+
hide()
|
|
94
|
+
toggleCollapsed() // un-stamp the hook's collapse — the shell's own state now holds it
|
|
95
|
+
document.documentElement.style.removeProperty(`--${token}-w`) // it returns at the stylesheet width
|
|
96
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
97
|
+
}, [collapsed])
|
|
98
|
+
return { ref, grab: <div {...grabProps} className={`kol-rail-grab kol-rail-grab--${side} hidden lg:block`} /> }
|
|
109
99
|
}
|
|
110
100
|
|
|
111
|
-
const NavColumn = ({ children, grip }) => (
|
|
112
|
-
<aside aria-label="Navigation" className="shell-rail shell-rail--nav shell-sidebar-sticky hidden lg:block pt-6 md:pt-6 lg:pt-8 pb-8">
|
|
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-8">
|
|
113
103
|
{children}
|
|
114
104
|
{grip}
|
|
115
105
|
</aside>
|
|
@@ -148,8 +138,8 @@ const MainColumn = ({ children, fullHeight, width = 'canvas', padStart, padEnd }
|
|
|
148
138
|
/* `xl`, not `lg` — the grid only declares a third column at xl (gridCols
|
|
149
139
|
* below). The breakpoint here and the one in gridCols are one decision and
|
|
150
140
|
* must not be stated twice differently. */
|
|
151
|
-
const TocColumn = ({ children, grip }) => (
|
|
152
|
-
<aside 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-8">
|
|
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-8">
|
|
153
143
|
{grip}
|
|
154
144
|
{/* The width is the grid track (--kol-shell-toc-w). An empty rail still
|
|
155
145
|
* holds its column (user ruling 2026-08-01): a rail that disappears
|
|
@@ -195,11 +185,10 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
|
|
|
195
185
|
const [prefs, setPrefs] = useState(readSettings)
|
|
196
186
|
const setPref = (key, value) => setPrefs((p) => { const next = { ...p, [key]: value }; writeSettings(next); return next })
|
|
197
187
|
const [navCollapsed, setNavCollapsed] = useState(() => readSettings().navHidden === true)
|
|
198
|
-
|
|
199
|
-
const [navW, setNavW] = useState(() => readSettings().navW ?? null)
|
|
200
|
-
const [tocW, setTocW] = useState(() => readSettings().tocW ?? null)
|
|
201
|
-
const setRailW = (key, set) => (w) => { set(w); writeSettings({ ...readSettings(), [key]: w }) }
|
|
188
|
+
|
|
202
189
|
const [tocCollapsed, setTocCollapsed] = useState(() => readSettings().tocHidden === true)
|
|
190
|
+
const navGrab = useRailGrab('kol-shell-nav', 'left', () => { setNavCollapsed(true); writeSettings({ ...readSettings(), navHidden: true }) })
|
|
191
|
+
const tocGrab = useRailGrab('kol-shell-toc', 'right', () => { setTocCollapsed(true); writeSettings({ ...readSettings(), tocHidden: true }) })
|
|
203
192
|
const [isSettingsOpen, setIsSettingsOpen] = useState(false)
|
|
204
193
|
/* ONE QUERY (user ruling 2026-08-01). The palette's text used to live here
|
|
205
194
|
* while tags lived in TagModeContext — two states, and therefore two
|
|
@@ -254,8 +243,7 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
|
|
|
254
243
|
* app — it was a `?` sheet of its own here. `?` still opens it. */
|
|
255
244
|
const SHORTCUTS = [
|
|
256
245
|
{ section: 'Search', items: [
|
|
257
|
-
{ id: 'k', label: 'Search everything', combo: '⌘ K' },
|
|
258
|
-
{ id: 'slash', label: 'Search everything', combo: '/' },
|
|
246
|
+
{ id: 'k', label: 'Search everything', combo: '⌘ K , /' },
|
|
259
247
|
{ id: 'enter', label: 'All results, on the search page', combo: '↵' },
|
|
260
248
|
] },
|
|
261
249
|
{ section: 'Shell', items: [
|
|
@@ -465,10 +453,7 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
|
|
|
465
453
|
<ShellContentWidthContext.Provider value={setContentWidth}>
|
|
466
454
|
<ShellTocCollapsedContext.Provider value={setTocCollapsed}>
|
|
467
455
|
<ShellNavCollapsedContext.Provider value={setNavCollapsed}>
|
|
468
|
-
<div
|
|
469
|
-
className="fixed inset-0 flex flex-col bg-surface-primary text-auto"
|
|
470
|
-
style={{ ...(navW ? { '--kol-shell-nav-w': `${navW}px` } : {}), ...(tocW ? { '--kol-shell-toc-w': `${tocW}px` } : {}) }}
|
|
471
|
-
>
|
|
456
|
+
<div className="fixed inset-0 flex flex-col bg-surface-primary text-auto">
|
|
472
457
|
<ShellHeader
|
|
473
458
|
brand={brand}
|
|
474
459
|
nav={navItems}
|
|
@@ -503,7 +488,7 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
|
|
|
503
488
|
>
|
|
504
489
|
<div className={`shell-content-grid grid ${gridCols}`} data-layout={layoutType} style={{ paddingInline: 'var(--kol-pad-chrome-x)' }}>
|
|
505
490
|
{showNav && (
|
|
506
|
-
<NavColumn
|
|
491
|
+
<NavColumn railRef={navGrab.ref} grip={navGrab.grab}>
|
|
507
492
|
{renderSidebar ? renderSidebar({ activeRoute }) : <ShellSidebar routes={routes} basePath={basePath} />}
|
|
508
493
|
</NavColumn>
|
|
509
494
|
)}
|
|
@@ -521,7 +506,7 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
|
|
|
521
506
|
* the grid's third track so main keeps one width across
|
|
522
507
|
* every route (user ruling 2026-08-01). */}
|
|
523
508
|
{showToc && (
|
|
524
|
-
<TocColumn
|
|
509
|
+
<TocColumn railRef={tocGrab.ref} grip={tocGrab.grab}>
|
|
525
510
|
{effectiveTocContent}
|
|
526
511
|
</TocColumn>
|
|
527
512
|
)}
|
|
@@ -550,6 +535,8 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
|
|
|
550
535
|
query={searchQuery}
|
|
551
536
|
onQueryChange={setSearchQuery}
|
|
552
537
|
results={searchResults}
|
|
538
|
+
/* an empty palette opens on the space table, not a blank box */
|
|
539
|
+
suggestions={navItems.map((n) => ({ id: `space:${n.href}`, label: n.label, href: n.href, icon: 'arrow-right', group: 'Spaces' }))}
|
|
553
540
|
/* THE EXPANDED BODY (user ruling 2026-08-01). The tag browser is
|
|
554
541
|
* not a sibling overlay — it is this palette's second state. Enter
|
|
555
542
|
* commits the query and swaps the result rows for the full body;
|
|
@@ -588,13 +575,12 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
|
|
|
588
575
|
* settings page or settings sidebar … to offload some functional
|
|
589
576
|
* settings, search settings, sidenav settings"). A right drawer over
|
|
590
577
|
* the page, the same rows the apps' settings use (SettingsSections). */}
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
</ShellDrawer>
|
|
578
|
+
{/* THE APPROVED DRAWER (2026-09-30): kol-component's `SettingsPanel` — the composition locked
|
|
579
|
+
* 2026-08-27 that media's Display settings and Trash already wear: title header, divided
|
|
580
|
+
* sections. This was a hand-built ShellDrawer + eyebrow + undivided sections. */}
|
|
581
|
+
<SettingsPanel open={isSettingsOpen} variant="drawer" title="Settings" onClose={() => setIsSettingsOpen(false)}>
|
|
582
|
+
<SettingsSections sections={settingsSections} divided />
|
|
583
|
+
</SettingsPanel>
|
|
598
584
|
</div>
|
|
599
585
|
</ShellNavCollapsedContext.Provider>
|
|
600
586
|
</ShellTocCollapsedContext.Provider>
|
|
@@ -37,39 +37,26 @@ const ShellSidebar = ({ routes = [], basePath = '/', onNavigate, label = 'Naviga
|
|
|
37
37
|
const navCollapsed = isControlled ? collapsed : internalCollapsed
|
|
38
38
|
const handleToggle = isControlled ? onToggle : () => setInternalCollapsed(prev => !prev)
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
initial[route.id] = !isActive
|
|
49
|
-
})
|
|
50
|
-
return initial
|
|
40
|
+
/* THE RAIL FOLLOWS YOU — INTO A CHILD ONLY (2026-09-30, reversing the 2026-09-28 "open the
|
|
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 opens nothing. Every other chapter starts
|
|
43
|
+
* 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
|
+
const holdsPage = (route) => (route.children ?? []).some((c) => {
|
|
46
|
+
const cp = getChildPath(c, basePath).replace(/\/$/, '')
|
|
47
|
+
return normalizedPath === cp || normalizedPath.startsWith(cp + '/')
|
|
51
48
|
})
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
* Arriving in a group opens it AND folds its siblings, so the open group is
|
|
56
|
-
* the one you are in. Opening another by hand still works until you move. */
|
|
57
|
-
const isRouteActive = (route) => {
|
|
58
|
-
const sectionPath = getSectionRootPath(route, basePath)
|
|
59
|
-
const own = sectionPath === basePath
|
|
60
|
-
? normalizedPath === basePath
|
|
61
|
-
: normalizedPath === sectionPath || normalizedPath.startsWith(sectionPath + '/')
|
|
62
|
-
return own || (route.children ?? []).some((c) => {
|
|
63
|
-
const cp = getChildPath(c, basePath).replace(/\/$/, '')
|
|
64
|
-
return normalizedPath === cp || normalizedPath.startsWith(cp + '/')
|
|
65
|
-
})
|
|
49
|
+
const followed = () => {
|
|
50
|
+
const hit = routes.find(holdsPage)
|
|
51
|
+
return hit ? Object.fromEntries(routes.map((r) => [r.id, r !== hit])) : null
|
|
66
52
|
}
|
|
53
|
+
const [collapsedSections, setCollapsedSections] = useState(() => followed() ?? {})
|
|
67
54
|
useEffect(() => {
|
|
68
|
-
const
|
|
69
|
-
if (
|
|
70
|
-
setCollapsedSections(Object.fromEntries(routes.map((r) => [r.id, !active.includes(r)])))
|
|
55
|
+
const next = followed()
|
|
56
|
+
if (next) setCollapsedSections(next)
|
|
71
57
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
72
58
|
}, [normalizedPath, routes, basePath])
|
|
59
|
+
const isFolded = (route) => collapsedSections[route.id] ?? true
|
|
73
60
|
|
|
74
61
|
/* FOLD ALL (2026-09-30, the names audit: *"shortcut collapse/expand the categories"*). The
|
|
75
62
|
* shell's `C` key broadcasts one target state; every rail's chapters take it. */
|
|
@@ -80,7 +67,7 @@ const ShellSidebar = ({ routes = [], basePath = '/', onNavigate, label = 'Naviga
|
|
|
80
67
|
}, [routes])
|
|
81
68
|
|
|
82
69
|
const handleSectionClick = (route) => {
|
|
83
|
-
setCollapsedSections((prev) => ({ ...prev, [route.id]: !prev[route.id] }))
|
|
70
|
+
setCollapsedSections((prev) => ({ ...prev, [route.id]: !(prev[route.id] ?? true) }))
|
|
84
71
|
}
|
|
85
72
|
|
|
86
73
|
/* The L1 count is every leaf row under it — RailSection shows it only while
|
|
@@ -111,27 +98,34 @@ const ShellSidebar = ({ routes = [], basePath = '/', onNavigate, label = 'Naviga
|
|
|
111
98
|
* it should. `collapsible` is what that distinction is now. */
|
|
112
99
|
const hasChildren = route.children?.length > 0
|
|
113
100
|
|
|
101
|
+
/* A CHILDLESS ENTRY IS A ROW, not a chapter (2026-09-30): drawn at L2 it read as an
|
|
102
|
+
* empty category with a blank caret slot. */
|
|
103
|
+
if (!hasChildren) {
|
|
104
|
+
return (
|
|
105
|
+
<nav key={route.id} className="shell-nav-items">
|
|
106
|
+
<RailRow to={route.path ?? getSectionRootPath(route, basePath)} onNavigate={onNavigate}>
|
|
107
|
+
{route.label}
|
|
108
|
+
</RailRow>
|
|
109
|
+
</nav>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
114
113
|
return (
|
|
115
114
|
<div key={route.id} className="shell-nav-group">
|
|
116
115
|
<RailSection
|
|
117
116
|
level={2}
|
|
118
117
|
label={route.label}
|
|
119
|
-
count={
|
|
120
|
-
/* THE HEADER ALWAYS LINKS (2026-08-02)
|
|
121
|
-
*
|
|
122
|
-
* a dead header and its index had to ride as a row inside
|
|
123
|
-
* itself. RailSection already separates the two gestures —
|
|
124
|
-
* the label navigates, the rest of the row toggles — so a
|
|
125
|
-
* group can open its landing page and still collapse. */
|
|
118
|
+
count={route.children.length}
|
|
119
|
+
/* THE HEADER ALWAYS LINKS (2026-08-02): the label opens the chapter's home,
|
|
120
|
+
* the chevron folds. */
|
|
126
121
|
to={route.path ?? getSectionRootPath(route, basePath)}
|
|
127
|
-
|
|
128
|
-
collapsed={!!collapsedSections[route.id]}
|
|
122
|
+
collapsed={isFolded(route)}
|
|
129
123
|
onToggle={() => handleSectionClick(route)}
|
|
130
124
|
onNavigate={onNavigate}
|
|
131
125
|
icon={Icon}
|
|
132
126
|
>
|
|
133
127
|
<nav className="shell-nav-items">
|
|
134
|
-
{
|
|
128
|
+
{route.children.map((child) => (
|
|
135
129
|
<RailRow
|
|
136
130
|
key={child.id}
|
|
137
131
|
to={getChildPath(child, basePath)}
|