@kolkrabbi/kol-workshop 0.30.1 → 0.31.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.30.1",
3
+ "version": "0.31.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",
@@ -14,8 +14,8 @@
14
14
  "dependencies": {
15
15
  "d3": "^7.9.0",
16
16
  "@kolkrabbi/kol-search": "^0.2.0",
17
- "@kolkrabbi/kol-markdown": "^0.1.1",
18
- "@kolkrabbi/kol-brand": "^0.1.3"
17
+ "@kolkrabbi/kol-brand": "^0.1.3",
18
+ "@kolkrabbi/kol-markdown": "^0.1.2"
19
19
  },
20
20
  "peerDependencies": {
21
21
  "@kolkrabbi/kol-component": ">=0.228.0",
@@ -27,10 +27,10 @@
27
27
  "react-router-dom": "^6.0.0 || ^7.0.0"
28
28
  },
29
29
  "devDependencies": {
30
- "@kolkrabbi/kol-component": "^0.229.0",
31
- "@kolkrabbi/kol-icons": "^0.29.0",
32
- "@kolkrabbi/kol-theme": "^0.156.0",
33
- "@kolkrabbi/kol-framework": "^0.45.0"
30
+ "@kolkrabbi/kol-component": "^0.230.0",
31
+ "@kolkrabbi/kol-framework": "^0.46.0",
32
+ "@kolkrabbi/kol-theme": "^0.157.0",
33
+ "@kolkrabbi/kol-icons": "^0.29.1"
34
34
  },
35
35
  "files": [
36
36
  "src",
@@ -200,13 +200,13 @@ const DocsFrontmatter = ({ metadata, docId }) => {
200
200
  /* `naked`, the SAME Tag rendering the rail's own tag list uses
201
201
  * (DocReaderSidebar). This was the default filled variant, so
202
202
  * the one concept rendered as a solid pill here and as plain
203
- * coloured text six inches to the right.
203
+ * colored text six inches to the right.
204
204
  *
205
205
  * NO `color` (user ruling 2026-08-01): passing one swaps the
206
206
  * base class from `tag-control` to `tag tag--{color}`, and
207
- * only `tag-control` has a `:hover` rule — so a coloured Tag
207
+ * only `tag-control` has a `:hover` rule — so a colored Tag
208
208
  * silently loses its interaction state. Right component first;
209
- * colour is its own decision, later.
209
+ * color is its own decision, later.
210
210
  * `size` is omitted: `sm` is the default and the only size. */
211
211
  <span className="flex flex-wrap gap-1.5">
212
212
  {value.map((tag) => (
@@ -72,12 +72,19 @@ const DocReaderSidebar = ({ toc, allTags, related, docId, docsIndexHref, compone
72
72
  * (default `/docs?tag=…`).
73
73
  * @param {Function} routes.docFilePath (id) => on-disk path copied to clipboard
74
74
  * (default `docs/documentation/${id}.md`).
75
+ * @param {string} [props.docId] render this doc instead of the route's `:docId` — a
76
+ * HOME renders its markdown at the space's own URL
77
+ * (2026-09-30, the names audit: every level has a home).
78
+ * @param {boolean} [props.showFrontmatter=true] the frontmatter panel; a home hides it by
79
+ * default and the consumer toggles it.
75
80
  */
76
81
  const DocumentationReader = ({
77
82
  inventory = [],
78
83
  modules = {},
79
84
  docHref = (id) => `/docs/${id}`,
80
- routes = {}
85
+ routes = {},
86
+ docId: docIdProp,
87
+ showFrontmatter = true,
81
88
  }) => {
82
89
  const {
83
90
  docsIndex = '/docs',
@@ -86,7 +93,8 @@ const DocumentationReader = ({
86
93
  docFilePath = (id) => `docs/documentation/${id}.md`
87
94
  } = routes
88
95
 
89
- const { docId } = useParams()
96
+ const { docId: routeDocId } = useParams()
97
+ const docId = docIdProp ?? routeDocId
90
98
  const setTocContent = useContext(ShellTocContext)
91
99
  const { openTagMode } = useTagMode()
92
100
 
@@ -337,7 +345,7 @@ const DocumentationReader = ({
337
345
 
338
346
  return (
339
347
  <DocsArticle>
340
- <DocsFrontmatter metadata={doc.metadata} docId={docId} />
348
+ {showFrontmatter && <DocsFrontmatter metadata={doc.metadata} docId={docId} />}
341
349
  {docTitle && (
342
350
  <h1 className="kol-doc-heading">{docTitle}</h1>
343
351
  )}
@@ -0,0 +1,33 @@
1
+ import { Link } from 'react-router-dom'
2
+
3
+ /**
4
+ * ResultRow — one search result: a title, a meta line, an optional description, the whole row a
5
+ * link (2026-09-30, open questions Round 2 — the user: *"I like both, is this a component? if it
6
+ * is I would just have it available as variants … underline is more appealing currently"*).
7
+ *
8
+ * Two variants, each a complete pair — the wash needs its x padding, the underline must not have
9
+ * one (Round 1: x padding without a wash was drawn wrong):
10
+ * `underline` (default) — no ground; the title underlines and the row inks up on hover
11
+ * `wash` — x padding and a ground wash on hover
12
+ *
13
+ * @param {string} to the route the row opens
14
+ * @param {ReactNode} title the result's name (a highlighted node is fine)
15
+ * @param {string} [meta] kind · category · space · date
16
+ * @param {ReactNode} [description]
17
+ * @param {'underline'|'wash'} [variant='underline']
18
+ */
19
+ const VARIANTS = {
20
+ underline: { row: 'group flex flex-col gap-1 py-3 opacity-80 hover:opacity-100 focus-visible:opacity-100', title: 'group-hover:underline underline-offset-4' },
21
+ wash: { row: 'flex flex-col gap-1 px-3 py-3 hover:bg-fg-04', title: '' },
22
+ }
23
+
24
+ export default function ResultRow({ to, title, meta, description, variant = 'underline' }) {
25
+ const v = VARIANTS[variant] ?? VARIANTS.underline
26
+ return (
27
+ <Link to={to} className={v.row}>
28
+ <span className={`kol-doc-body text-emphasis ${v.title}`.trim()}>{title}</span>
29
+ {meta && <span className="kol-helper-12 text-subtle">{meta}</span>}
30
+ {description && <span className="kol-doc-body">{description}</span>}
31
+ </Link>
32
+ )
33
+ }
@@ -1,6 +1,7 @@
1
1
  import { Fragment, useMemo } from 'react'
2
- import { Link, useSearchParams } from 'react-router-dom'
3
- import { Button, Input, Pill } from '@kolkrabbi/kol-component'
2
+ import { useSearchParams } from 'react-router-dom'
3
+ import { Button, EmptyState, Input, Pill } from '@kolkrabbi/kol-component'
4
+ import ResultRow from './ResultRow.jsx'
4
5
  import { createIndex, search, parseQuery } from '@kolkrabbi/kol-search'
5
6
  import { DocHeader, DocSection } from '../docs/DocKit.jsx'
6
7
 
@@ -44,7 +45,7 @@ const readToken = (t) => (t.kind === 'term'
44
45
  ? `${t.field} ${t.value}`
45
46
  : `${t.negate ? 'not ' : ''}${t.field === 'tags' ? 'tag' : t.field}: ${t.value}${t.via === 'smart' ? ' (from the word)' : ''}`)
46
47
 
47
- export default function SearchPage({ items = [], spaces = [], facets = DEFAULT_FACETS, limit = 60 }) {
48
+ export default function SearchPage({ items = [], spaces = [], facets = DEFAULT_FACETS, limit = 60, rowVariant = 'underline' }) {
48
49
  const [params, setParams] = useSearchParams()
49
50
  const q = params.get('q') ?? ''
50
51
  const index = useMemo(() => createIndex(items), [items])
@@ -119,16 +120,18 @@ export default function SearchPage({ items = [], spaces = [], facets = DEFAULT_F
119
120
 
120
121
  <DocSection id="results" title={`Results (${out.total})`}>
121
122
  {out.results.length === 0 ? (
122
- <p className="kol-doc-body">{q ? 'Nothing matches. Remove a token above, or try fewer words.' : 'Type to search.'}</p>
123
+ <EmptyState eyebrow={q ? 'No results' : 'Search'} title={q ? 'Nothing matches.' : 'Type to search.'} body={q ? 'Remove a token above, or try fewer words.' : undefined} />
123
124
  ) : (
124
125
  <ol className="flex flex-col">
125
126
  {out.results.map((r) => (
126
127
  <li key={r.item.id} className="border-t border-fg-08 first:border-t-0">
127
- <Link to={r.item.href} className="flex flex-col gap-1 py-3 hover:bg-fg-04">
128
- <span className="kol-doc-body text-emphasis"><Highlight text={r.item.title} ranges={r.highlights.title} /></span>
129
- <span className="kol-helper-12 text-subtle">{[r.item.kind, r.item.category, r.item.space, r.item.date].filter(Boolean).join(' · ')}</span>
130
- {r.item.description && <span className="kol-doc-body">{r.item.description}</span>}
131
- </Link>
128
+ <ResultRow
129
+ to={r.item.href}
130
+ variant={rowVariant}
131
+ title={<Highlight text={r.item.title} ranges={r.highlights.title} />}
132
+ meta={[r.item.kind, r.item.category, r.item.space, r.item.date].filter(Boolean).join(' · ')}
133
+ description={r.item.description}
134
+ />
132
135
  </li>
133
136
  ))}
134
137
  </ol>
@@ -1 +1,2 @@
1
1
  export { default as SearchPage } from './SearchPage.jsx'
2
+ export { default as ResultRow } from './ResultRow.jsx'
@@ -19,7 +19,7 @@ import { NavLink } from 'react-router-dom'
19
19
  * eyebrow-box failure exactly, one rung further down, which is why the rails
20
20
  * kept drifting after RailSection fixed L1 and L2.
21
21
  *
22
- * The look now lives in `.shell-nav-item` (layout, colour, hover, focus,
22
+ * The look now lives in `.shell-nav-item` (layout, color, hover, focus,
23
23
  * active); this component owns the markup. `validate:rails` R4 fails a
24
24
  * hand-written `shell-nav-item` anywhere but here.
25
25
  *
@@ -21,7 +21,7 @@ import { Link } from 'react-router-dom'
21
21
  * L1 section `.shell-sidebar-toggle` + kol-doc-eyebrow quiet, state glyph at the end
22
22
  * L2 group `.shell-nav-group-header` + kol-mono-14 louder, chevron
23
23
  *
24
- * L2 carries NO colour class: `.shell-nav-group-header` owns weight 500 and
24
+ * L2 carries NO color class: `.shell-nav-group-header` owns weight 500 and
25
25
  * `--kol-fg-shout` itself (user ruling 2026-08-01). It used to carry
26
26
  * `text-body` — a utility that resolved to nothing at the time, and would now
27
27
  * fight the rule for the same property. One owner.
@@ -1,4 +1,5 @@
1
1
  import { useState } from 'react'
2
+ import { useLocation } from 'react-router-dom'
2
3
  import RailSection from './RailSection.jsx'
3
4
  import RailRow from './RailRow.jsx'
4
5
 
@@ -63,6 +64,14 @@ import RailRow from './RailRow.jsx'
63
64
  * this package does not reach into kol-component
64
65
  * @param {Function} onTagClick (tag) => void
65
66
  */
67
+ const PINS_KEY = 'kol-rail-pins'
68
+ const readPins = () => {
69
+ try { return JSON.parse(localStorage.getItem(PINS_KEY) || '[]') } catch { return [] }
70
+ }
71
+ const writePins = (pins) => {
72
+ try { localStorage.setItem(PINS_KEY, JSON.stringify(pins)) } catch { /* private mode */ }
73
+ }
74
+
66
75
  export default function RightRail({
67
76
  toc = [],
68
77
  activeId,
@@ -77,16 +86,46 @@ export default function RightRail({
77
86
  /* One collapse map, not a useState per section — a section added later must
78
87
  * not need a new hook at the top of this function. */
79
88
  const [collapsed, setCollapsed] = useState({})
89
+
90
+ /* PINNED (2026-09-30, the names audit: *"add to quicklook or favorites … in the right sidebar
91
+ * … maintain context-important documents during development … accessible until they dont need
92
+ * to be"*). Any page can be pinned from Quick actions; the pins show on every page until
93
+ * unpinned. Kept in this browser (localStorage) — a working set, not shared state. */
94
+ const { pathname } = useLocation()
95
+ const [pins, setPins] = useState(readPins)
96
+ const isPinned = pins.some((p) => p.path === pathname)
97
+ const togglePin = () => {
98
+ const label = document.querySelector('#main h1, main h1, h1')?.textContent?.trim() || pathname
99
+ const next = isPinned ? pins.filter((p) => p.path !== pathname) : [...pins, { path: pathname, label }]
100
+ setPins(next)
101
+ writePins(next)
102
+ }
103
+ const allActions = [
104
+ ...actions,
105
+ { id: 'pin', label: isPinned ? 'Unpin' : 'Pin', icon: IconComponent ? <IconComponent name="pushpin" size={14} /> : null, onClick: togglePin },
106
+ ]
80
107
  const toggle = (key) => setCollapsed((c) => ({ ...c, [key]: !c[key] }))
81
108
 
82
- /* A tag shown as "this page's" must not repeat below as "the system's". */
83
- const own = new Set(tags)
84
- const otherTags = topTags.filter(({ tag }) => !own.has(tag))
109
+ /* OWN TAGS ONLY, GROUPED BY NAMESPACE (2026-09-30, the names audit — ruled on the
110
+ * recommendation). The rail used to follow a page's own tags with the space's top tags, so a
111
+ * page with none (/docs/menus) still listed ten, none of them its own. The system's most-used
112
+ * tags are a way IN, and belong on a home and the search page; `topTags` is accepted and
113
+ * ignored so no consumer breaks. The namespace law stays in the data — the rail prints it once
114
+ * as a group label and the leaf under it, instead of truncating the full path on every row. */
115
+ const byNamespace = tags.reduce((m, tag) => {
116
+ const [ns, ...rest] = String(tag).split('/')
117
+ const leaf = rest.join('/') || ns
118
+ ;(m[rest.length ? ns : ''] ||= []).push({ tag, leaf })
119
+ return m
120
+ }, {})
121
+ void topTags
122
+ void renderTag
85
123
 
86
124
  /* Every group is described here rather than spelled out in JSX below, so a
87
125
  * section cannot be present in one branch and missing in another — the exact
88
126
  * defect that split the two rails. */
89
- const group = (key, label, count, children) => (
127
+ /* A chapter with nothing in it does not render (2026-09-30 — `Related (0)`). */
128
+ const group = (key, label, count, children) => count === 0 ? null : (
90
129
  <RailSection
91
130
  level={2}
92
131
  label={label}
@@ -101,7 +140,8 @@ export default function RightRail({
101
140
 
102
141
  return (
103
142
  <div className="shell-rail-stack">
104
- <RailSection level={1} label="This page">
143
+ {/* A folded category shows its count, the same as the left rail (2026-09-30) */}
144
+ <RailSection level={1} label="This page" count={toc.length}>
105
145
  <div className="shell-rail-stack-inner">
106
146
  {group('toc', 'Contents', toc.length,
107
147
  toc.map((h) => (
@@ -113,42 +153,40 @@ export default function RightRail({
113
153
  </div>
114
154
  </RailSection>
115
155
 
116
- <RailSection level={1} label="Links">
156
+ <RailSection level={1} label="Links" count={pins.length + allActions.length + tags.length + related.length}>
117
157
  <div className="shell-rail-stack-inner">
118
- {group('actions', 'Quick actions', actions.length,
119
- actions.map((a) => (
158
+ {group('pinned', 'Pinned', pins.length,
159
+ pins.map((p) => (
160
+ <RailRow key={p.path} to={p.path} icon={IconComponent ? <IconComponent name="pushpin" size={14} /> : null}>
161
+ {p.label}
162
+ </RailRow>
163
+ ))
164
+ )}
165
+
166
+ {group('actions', 'Quick actions', allActions.length,
167
+ allActions.map((a) => (
120
168
  <RailRow key={a.id} to={a.to} onClick={a.onClick} icon={a.icon}>
121
169
  {a.label}
122
170
  </RailRow>
123
171
  ))
124
172
  )}
125
173
 
126
- {/* ONE list, two ranks. This page's tags lead and carry `emphasis`;
127
- * the system's most-used follow at the resting stop with their
128
- * count. `otherTags` filters out anything already shown above, so a
129
- * tag cannot appear twice in one list. */}
130
- {group('tags', 'Tags', tags.length + otherTags.length, (
174
+ {group('tags', 'Tags', tags.length, (
131
175
  <>
132
- {tags.map((tag) => (
133
- <RailRow
134
- key={`own-${tag}`}
135
- onClick={() => onTagClick?.(tag)}
136
- icon={IconComponent ? <IconComponent name="hash-02" size={14} /> : null}
137
- className="shell-nav-item--own"
138
- >
139
- {renderTag ? renderTag(tag) : tag}
140
- </RailRow>
141
- ))}
142
- {otherTags.map(({ tag, count }) => (
143
- <RailRow
144
- key={`top-${tag}`}
145
- onClick={() => onTagClick?.(tag)}
146
- icon={IconComponent ? <IconComponent name="hash-02" size={14} /> : null}
147
- trailing={count}
148
- className="shell-nav-item--muted"
149
- >
150
- {renderTag ? renderTag(tag) : tag}
151
- </RailRow>
176
+ {Object.entries(byNamespace).map(([ns, list]) => (
177
+ <div key={`ns-${ns}`}>
178
+ {ns && <RailRow className="shell-nav-item--muted">{ns.charAt(0).toUpperCase() + ns.slice(1)}</RailRow>}
179
+ {list.map(({ tag, leaf }) => (
180
+ <RailRow
181
+ key={`own-${tag}`}
182
+ sub={!!ns}
183
+ onClick={() => onTagClick?.(tag)}
184
+ icon={IconComponent ? <IconComponent name="hash-02" size={14} /> : null}
185
+ >
186
+ {leaf}
187
+ </RailRow>
188
+ ))}
189
+ </div>
152
190
  ))}
153
191
  </>
154
192
  ))}
@@ -1,7 +1,7 @@
1
- import { createContext, useContext, useMemo, useState, useEffect, Suspense } from 'react'
1
+ import { createContext, useContext, useMemo, useRef, useState, useEffect, Suspense } from 'react'
2
2
  import { Outlet, Link, useLocation, useNavigate } from 'react-router-dom'
3
3
  import { ShellHeader } from '@kolkrabbi/kol-framework'
4
- import ShellSidebar from './ShellSidebar.jsx'
4
+ import ShellSidebar, { RAIL_FOLD_EVENT } from './ShellSidebar.jsx'
5
5
  import { IconFrame, ShellDrawer, ShellSearchOverlay, ShortcutsOverlay, SettingsSections, SettingsSwitch, Tooltip } from '@kolkrabbi/kol-component'
6
6
  import { createIndex, search } from '@kolkrabbi/kol-search'
7
7
  import { useTagMode } from '../tags/TagModeContext.jsx'
@@ -156,8 +156,10 @@ const toSearchItem = (item, i) => ({
156
156
  * @param {string|Function} [brandLabel] a TYPED label in the second wordmark slot instead of
157
157
  * the drawn WORKSHOP mark — a string, or `({ activeRoute }) => string`
158
158
  * so it names the space you are in
159
+ * @param {Array} [shortcuts] the consumer's own keys, `{ id, label, combo, key, run }` — listed
160
+ * in the `S` sheet and bound by the same handler (2026-09-30)
159
161
  */
160
- const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoSrc, brandLogoAlt = '', renderSidebar, searchItems, searchPath, settings = [], brandLabel, defaultTocContent, isActive: isActiveProp, actions }) => {
162
+ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoSrc, brandLogoAlt = '', renderSidebar, searchItems, searchPath, settings = [], brandLabel, defaultTocContent, isActive: isActiveProp, actions, shortcuts = [] }) => {
161
163
  const [isNavDrawerOpen, setIsNavDrawerOpen] = useState(false)
162
164
  const [prefs, setPrefs] = useState(readSettings)
163
165
  const setPref = (key, value) => setPrefs((p) => { const next = { ...p, [key]: value }; writeSettings(next); return next })
@@ -224,9 +226,17 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
224
226
  { section: 'Shell', items: [
225
227
  { id: 'sheet', label: 'This sheet', combo: 'S' },
226
228
  { id: 'settings', label: 'Settings', combo: ',' },
229
+ { id: 'nav', label: 'Left rail', combo: '[' },
230
+ { id: 'toc', label: 'Right rail', combo: ']' },
231
+ { id: 'fold', label: 'Fold or open every chapter', combo: 'C' },
227
232
  { id: 'esc', label: 'Close what is open', combo: 'Esc' },
228
233
  ] },
234
+ ...(shortcuts.length ? [{ section: 'Page', items: shortcuts.map(({ id, label, combo }) => ({ id, label, combo })) }] : []),
229
235
  ]
236
+ /* the handler binds once; the consumer's list and the fold state are read through refs */
237
+ const shortcutsRef = useRef(shortcuts)
238
+ shortcutsRef.current = shortcuts
239
+ const foldedRef = useRef(false)
230
240
 
231
241
  useEffect(() => {
232
242
  const handleKeyDown = (e) => {
@@ -249,6 +259,19 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
249
259
  } else if (e.key === ',') {
250
260
  e.preventDefault()
251
261
  setIsSettingsOpen((v) => !v)
262
+ } else if (e.key === '[') {
263
+ e.preventDefault()
264
+ setNavCollapsed((v) => { writeSettings({ ...readSettings(), navHidden: !v }); return !v })
265
+ } else if (e.key === ']') {
266
+ e.preventDefault()
267
+ setTocCollapsed((v) => { writeSettings({ ...readSettings(), tocHidden: !v }); return !v })
268
+ } else if (e.key === 'c' || e.key === 'C') {
269
+ e.preventDefault()
270
+ foldedRef.current = !foldedRef.current
271
+ window.dispatchEvent(new CustomEvent(RAIL_FOLD_EVENT, { detail: { collapsed: foldedRef.current } }))
272
+ } else if (shortcutsRef.current.some((s) => s.key === e.key)) {
273
+ e.preventDefault()
274
+ shortcutsRef.current.find((s) => s.key === e.key).run()
252
275
  } else if (e.key === 'Escape') {
253
276
  setIsShortcutsOpen(false)
254
277
  }
@@ -344,13 +367,13 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
344
367
  const searchTrigger = (
345
368
  <>
346
369
  {actions}
347
- <Tooltip label="Search · ⌘K or /">
370
+ <Tooltip label="Search">
348
371
  {/* `md` + full-ink nav (user re-rule 2026-08-09; repeals the 2026-08-01
349
372
  * lg ruling). The row law is unchanged: every header glyph on ONE
350
373
  * rung, one variant — the rung is just md now. */}
351
374
  <IconFrame name="search" variant="nav" size="md" onClick={() => setIsSearchOpen(true)} aria-label="Search" />
352
375
  </Tooltip>
353
- <Tooltip label="Settings · ,">
376
+ <Tooltip label="Settings">
354
377
  <IconFrame name="settings-01" variant="nav" size="md" onClick={() => setIsSettingsOpen(true)} aria-label="Settings" />
355
378
  </Tooltip>
356
379
  </>
@@ -24,6 +24,8 @@ const getChildPath = (child, basePath) => {
24
24
  return p.startsWith('/') ? p : `${basePath}/${p}`
25
25
  }
26
26
 
27
+ export const RAIL_FOLD_EVENT = 'kol-rail-fold'
28
+
27
29
  const ShellSidebar = ({ routes = [], basePath = '/', onNavigate, label = 'Navigation', labelTo, collapsed, onToggle, defaultCollapsed = false }) => {
28
30
  const location = useLocation()
29
31
  const normalizedPath = location.pathname.replace(/\/$/, '')
@@ -69,6 +71,14 @@ const ShellSidebar = ({ routes = [], basePath = '/', onNavigate, label = 'Naviga
69
71
  // eslint-disable-next-line react-hooks/exhaustive-deps
70
72
  }, [normalizedPath, routes, basePath])
71
73
 
74
+ /* FOLD ALL (2026-09-30, the names audit: *"shortcut collapse/expand the categories"*). The
75
+ * shell's `C` key broadcasts one target state; every rail's chapters take it. */
76
+ useEffect(() => {
77
+ const onFold = (e) => setCollapsedSections(Object.fromEntries(routes.map((r) => [r.id, !!e.detail?.collapsed])))
78
+ window.addEventListener(RAIL_FOLD_EVENT, onFold)
79
+ return () => window.removeEventListener(RAIL_FOLD_EVENT, onFold)
80
+ }, [routes])
81
+
72
82
  const handleSectionClick = (route) => {
73
83
  setCollapsedSections((prev) => ({ ...prev, [route.id]: !prev[route.id] }))
74
84
  }