@kolkrabbi/kol-workshop 0.35.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-workshop",
3
- "version": "0.35.0",
3
+ "version": "0.36.0",
4
4
  "private": false,
5
5
  "description": "Docs viewer, shell and tag system",
6
6
  "license": "MIT",
@@ -13,14 +13,14 @@
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.234.0",
21
+ "@kolkrabbi/kol-component": ">=0.235.0",
22
22
  "@kolkrabbi/kol-framework": ">=0.45.0",
23
- "@kolkrabbi/kol-theme": ">=0.161.0",
23
+ "@kolkrabbi/kol-theme": ">=0.162.0",
24
24
  "@kolkrabbi/kol-icons": ">=0.18.0",
25
25
  "gsap": "^3.13.0",
26
26
  "react": "^18.3.0 || ^19.0.0",
@@ -28,10 +28,10 @@
28
28
  "react-router-dom": "^6.0.0 || ^7.0.0"
29
29
  },
30
30
  "devDependencies": {
31
- "@kolkrabbi/kol-component": "^0.234.0",
32
- "@kolkrabbi/kol-icons": "^0.32.0",
31
+ "@kolkrabbi/kol-component": "^0.235.0",
32
+ "@kolkrabbi/kol-theme": "^0.162.0",
33
33
  "@kolkrabbi/kol-framework": "^0.48.0",
34
- "@kolkrabbi/kol-theme": "^0.161.0"
34
+ "@kolkrabbi/kol-icons": "^0.32.0"
35
35
  },
36
36
  "files": [
37
37
  "src",
@@ -331,7 +331,9 @@ const DocumentationReader = ({
331
331
  setTocContent(
332
332
  <DocReaderSidebar
333
333
  key={docId}
334
- toc={toc}
334
+ /* THE TITLE LEADS THE OUTLINE (2026-10-01 — user: "something is always on this page"): a
335
+ * chapter index with no h2 used to hand the rail an empty list */
336
+ toc={docTitle ? [{ id: 'page-top', label: docTitle }, ...toc] : toc}
335
337
  allTags={allTags}
336
338
  topTags={topTags}
337
339
  related={related}
@@ -342,7 +344,7 @@ const DocumentationReader = ({
342
344
  />
343
345
  )
344
346
  return () => setTocContent(null)
345
- }, [rail, setTocContent, docId, toc, allTags, topTags, related, docsIndex, components, docFilePath])
347
+ }, [rail, setTocContent, docId, docTitle, toc, allTags, topTags, related, docsIndex, components, docFilePath])
346
348
 
347
349
  if (!doc) {
348
350
  return (
@@ -365,7 +367,7 @@ const DocumentationReader = ({
365
367
  <DocsArticle>
366
368
  {showFrontmatter && <DocsFrontmatter metadata={doc.metadata} docId={docId} />}
367
369
  {docTitle && (
368
- <h1 className="kol-doc-heading">{docTitle}</h1>
370
+ <h1 id="page-top" className="kol-doc-heading scroll-mt-20">{docTitle}</h1>
369
371
  )}
370
372
  {/* Render intro blocks (excluding H1 which is docTitle) */}
371
373
  {introBlocks.filter(b => b.type !== 'heading1').map((block, index) =>
@@ -1,10 +1,9 @@
1
- import { Fragment, useMemo, useState } from 'react'
1
+ import { Fragment, useEffect, useMemo, useRef, useState } from 'react'
2
2
  import { getTagColor } from '@kolkrabbi/kol-markdown'
3
- import { useSearchParams } from 'react-router-dom'
4
- import { EmptyState, Input, TabsRow, Tag } from '@kolkrabbi/kol-component'
3
+ import { useNavigate, useSearchParams } from 'react-router-dom'
4
+ import { EmptyState, Input, Tag } from '@kolkrabbi/kol-component'
5
5
  import ResultRow from './ResultRow.jsx'
6
6
  import { createIndex, search, parseQuery } from '@kolkrabbi/kol-search'
7
- import { DocHeader, DocSection } from '../docs/DocKit.jsx'
8
7
 
9
8
  /**
10
9
  * SearchPage — The search results page. THE results page (2026-09-28). Enter in the shell's palette
@@ -20,8 +19,10 @@ import { DocHeader, DocSection } from '../docs/DocKit.jsx'
20
19
  *
21
20
  * @param {Array} items kol-search items: { id, title, href, kind?, space?, category?, tags?, headings?, keywords?, description?, date? }
22
21
  * @param {Array} [spaces] the scope row, `[{ value, label }]` in header order — `value` is an item's `space`
23
- * @param {Array} [facets] `[[field, label]]` shown as filter rows (default kind · category · tags)
22
+ * @param {Array} [facets] `[[field, label]]` — the FIRST is the visible filter row, the rest fold behind `+`
23
+ * with the scope and the read-as line (default kind · category · tags)
24
24
  * @param {number} [limit=60]
25
+ * @param {string} [resultsPath] the route that shows results, when this box can also sit on another page
25
26
  */
26
27
  const DEFAULT_FACETS = [['kind', 'Kind'], ['category', 'Category'], ['tags', 'Tags']]
27
28
  const PREFIX = { space: 'in', kind: 'kind', category: 'cat', tags: 'tag' }
@@ -46,13 +47,18 @@ const readToken = (t) => (t.kind === 'term'
46
47
  ? `${t.field} ${t.value}`
47
48
  : `${t.negate ? 'not ' : ''}${t.field === 'tags' ? 'tag' : t.field}: ${t.value}${t.via === 'smart' ? ' (from the word)' : ''}`)
48
49
 
49
- export default function SearchPage({ items = [], spaces = [], facets = DEFAULT_FACETS, limit = 60, rowVariant = 'underline' }) {
50
+ export default function SearchPage({ items = [], spaces = [], facets = DEFAULT_FACETS, limit = 60, rowVariant = 'underline', resultsPath }) {
50
51
  const [params, setParams] = useSearchParams()
52
+ const navigate = useNavigate()
51
53
  const q = params.get('q') ?? ''
52
54
  const index = useMemo(() => createIndex(items), [items])
53
55
  const out = useMemo(() => search(index, q, { limit }), [index, q, limit])
54
56
 
55
- const setQ = (next) => setParams(next.trim() ? { q: next } : {}, { replace: true })
57
+ /* `resultsPath`: the query is written to THAT route — a box on a search home sends its first
58
+ * keystroke straight to the results page instead of filtering in place */
59
+ const setQ = (next) => (resultsPath
60
+ ? navigate(next.trim() ? `${resultsPath}?q=${encodeURIComponent(next)}` : resultsPath, { replace: true })
61
+ : setParams(next.trim() ? { q: next } : {}, { replace: true }))
56
62
  /* a pick adds its token; picking it again takes the token back out */
57
63
  const toggleToken = (field, value) => {
58
64
  const tok = token(field, value)
@@ -61,29 +67,48 @@ export default function SearchPage({ items = [], spaces = [], facets = DEFAULT_F
61
67
  setQ((has ? parts.filter((p) => p !== tok) : [...parts, tok]).join(' '))
62
68
  }
63
69
  const removeToken = (raw) => setQ(q.split(/\s+/).filter((p) => p && p !== raw).join(' '))
70
+ /* THE BOX OWNS ITS TEXT (2026-10-01). The URL is still the state, but a navigation lands a beat
71
+ * after the keystroke, and an input controlled by the URL alone dropped the letter typed in
72
+ * between ("tag" arrived as "tg"). The box shows what was typed; the URL follows; a query that
73
+ * changes from OUTSIDE (a chip, Back, a link) is read back into the box. */
74
+ const [text, setText] = useState(q)
75
+ const pushed = useRef(q)
76
+ useEffect(() => {
77
+ if (q !== pushed.current) { pushed.current = q; setText(q) }
78
+ }, [q])
79
+ const type = (next) => { pushed.current = next.trim() ? next : ''; setText(next); setQ(next) }
64
80
  const inSpace = parseQuery(q).filters.space ?? []
65
81
 
66
- /* THE FACETS ARE TABS (the showcase review W11, 2026-09-30 — user: "could kind category and tags be
67
- * folded into tabs? … if you are searching something and press enter, why do you need to scroll
68
- * million kilometers down to get to the actual link"). Three facet sections sat between the box and
69
- * the results; now one tab strip picks which facet's chips show, and the results follow it. */
70
- const shown = facets.filter(([field]) => (out.facets[field] ?? []).length)
71
- const [facetTab, setFacetTab] = useState(facets[0]?.[0])
72
- const activeFacet = shown.find(([f]) => f === facetTab) ?? shown[0]
82
+ /* ONE FILTER ROW (2026-10-01 — user: "why are there so many categories and options, is it relevant
83
+ * to the results we are seeking as a 'searcher'"; ruled: kind stays, the rest folds behind a `+`).
84
+ * The page was a scope row, a "read as" row, a tab strip and its chips before the first result.
85
+ * Now: the box, one row of kind chips, the results. Scope · read-as · category · tags are the
86
+ * same controls, behind the `+` — and open by themselves while one of them is narrowing the list,
87
+ * so an active filter is never hidden. Reverses W11's tab strip (2026-09-30). */
88
+ const [first, ...rest] = facets.filter(([field]) => (out.facets[field] ?? []).length)
89
+ const tokens = out.query.tokens
90
+ const narrowed = inSpace.length > 0 || tokens.some((t) => t.kind !== 'term' && t.field !== first?.[0])
91
+ const [more, setMore] = useState(false)
92
+ const open = more || narrowed
93
+ const chips = ([field]) => (out.facets[field] ?? []).slice(0, 24).map((f) => (
94
+ <Tag key={f.value} hash={field === 'tags'} color={field === 'tags' ? getTagColor(f.value) : undefined} active={f.selected} onClick={() => toggleToken(field, f.value)}>
95
+ {f.value}<span className="ml-1.5 text-subtle">{f.count}</span>
96
+ </Tag>
97
+ ))
73
98
 
74
99
  return (
75
- <div className="flex flex-col gap-10">
76
- <DocHeader
77
- eyebrow="Search"
78
- title="Search"
79
- lede={q ? `${out.total} result${out.total === 1 ? '' : 's'} for “${q}”.` : 'Every page on the site.'}
80
- />
100
+ <div className="flex flex-col gap-6">
101
+ {/* ONE LINE, AND ONLY ONCE THERE IS A QUERY (2026-10-01 — user: "section is search and the title
102
+ * is search, seems like redundant noise"): the eyebrow, the title and the count were three
103
+ * lines, and the count was printed again on the results heading. Without a query the page is
104
+ * its home's (the consumer renders it above) and this is just the box. */}
105
+ {q && <h1 className="kol-doc-section-title">{`${out.total} result${out.total === 1 ? '' : 's'} for “${q}”.`}</h1>}
81
106
 
82
- <div className="flex flex-col gap-4">
107
+ <div className="flex flex-col gap-3">
83
108
  {/* md, the ladder's default — lg towered over the page (W11) */}
84
109
  <Input
85
- value={q}
86
- onChange={(e) => setQ(e.target.value)}
110
+ value={text}
111
+ onChange={(e) => type(e.target.value)}
87
112
  placeholder="Search — try atom, tag:pattern/input, in:docs, -legacy"
88
113
  iconLeft="search"
89
114
  size="md"
@@ -91,57 +116,63 @@ export default function SearchPage({ items = [], spaces = [], facets = DEFAULT_F
91
116
  autoFocus
92
117
  />
93
118
  {/* every chip on the page is THE Tag, one size (W11 — they were ghost Buttons and md Pills) */}
94
- {spaces.length > 0 && (
95
- <div className="flex flex-wrap items-center gap-2" role="group" aria-label="Scope">
96
- <Tag hash={false} active={!inSpace.length} onClick={() => setQ(q.split(/\s+/).filter((p) => p && !/^in:/.test(p)).join(' '))}>Everything</Tag>
97
- {spaces.map((sp) => (
98
- <Tag key={sp.value} hash={false} active={inSpace.includes(sp.value)} onClick={() => toggleToken('space', sp.value)}>{sp.label}</Tag>
99
- ))}
119
+ {q && (first || spaces.length > 0) && (
120
+ <div className="flex flex-wrap items-center gap-2" role="group" aria-label={first?.[1] ?? 'Filters'}>
121
+ {first && chips(first)}
122
+ <Tag hash={false} variant="secondary" active={open} onClick={() => setMore((m) => !m)} aria-expanded={open} aria-label="More filters">{open ? '−' : '+'}</Tag>
100
123
  </div>
101
124
  )}
102
- {out.query.tokens.length > 0 && (
103
- <div className="flex flex-wrap items-center gap-2">
104
- <span className="kol-doc-eyebrow">Read as</span>
105
- {out.query.tokens.map((t, i) => (
106
- <Tag key={`${t.raw}-${i}`} hash={false} variant="secondary" onRemove={() => removeToken(t.raw)} aria-label={`Remove ${t.raw}`}>{readToken(t)}</Tag>
125
+ {q && open && (
126
+ <div className="flex flex-col gap-3">
127
+ {spaces.length > 0 && (
128
+ <div className="flex flex-wrap items-center gap-2" role="group" aria-label="Scope">
129
+ <span className="kol-doc-eyebrow">Scope</span>
130
+ <Tag hash={false} active={!inSpace.length} onClick={() => setQ(q.split(/\s+/).filter((p) => p && !/^in:/.test(p)).join(' '))}>Everything</Tag>
131
+ {spaces.map((sp) => (
132
+ <Tag key={sp.value} hash={false} active={inSpace.includes(sp.value)} onClick={() => toggleToken('space', sp.value)}>{sp.label}</Tag>
133
+ ))}
134
+ </div>
135
+ )}
136
+ {rest.map((facet) => (
137
+ <div key={facet[0]} className="flex flex-wrap items-center gap-2" role="group" aria-label={facet[1]}>
138
+ <span className="kol-doc-eyebrow">{facet[1]}</span>
139
+ {chips(facet)}
140
+ </div>
107
141
  ))}
142
+ {tokens.length > 0 && (
143
+ <div className="flex flex-wrap items-center gap-2">
144
+ <span className="kol-doc-eyebrow">Read as</span>
145
+ {tokens.map((t, i) => (
146
+ <Tag key={`${t.raw}-${i}`} hash={false} variant="secondary" onRemove={() => removeToken(t.raw)} aria-label={`Remove ${t.raw}`}>{readToken(t)}</Tag>
147
+ ))}
148
+ </div>
149
+ )}
108
150
  </div>
109
151
  )}
110
152
  </div>
111
153
 
112
- {activeFacet && (
113
- <div className="flex flex-col gap-4">
114
- <TabsRow tabs={shown.map(([field, label]) => ({ id: field, label }))} value={activeFacet[0]} onChange={setFacetTab} />
115
- <div className="flex flex-wrap gap-2">
116
- {(out.facets[activeFacet[0]] ?? []).slice(0, 24).map((f) => (
117
- <Tag key={f.value} hash={activeFacet[0] === 'tags'} color={activeFacet[0] === 'tags' ? getTagColor(f.value) : undefined} active={f.selected} onClick={() => toggleToken(activeFacet[0], f.value)}>
118
- {f.value}<span className="ml-1.5 text-subtle">{f.count}</span>
119
- </Tag>
120
- ))}
121
- </div>
122
- </div>
154
+ {q && (
155
+ <section id="results" className="flex flex-col gap-4 scroll-mt-20">
156
+ {out.results.length === 0 ? (
157
+ <EmptyState eyebrow="No results" title="Nothing matches." body="Remove a token above, or try fewer words." />
158
+ ) : (
159
+ <ol className="flex flex-col border-t border-fg-08">
160
+ {out.results.map((r) => (
161
+ <li key={r.item.id} className="border-t border-fg-08 first:border-t-0">
162
+ <ResultRow
163
+ to={r.item.href}
164
+ variant={rowVariant}
165
+ title={<Highlight text={r.item.title} ranges={r.highlights.title} />}
166
+ meta={[r.item.kind, r.item.category, r.item.space, r.item.date].filter(Boolean).join(' · ')}
167
+ description={r.item.description}
168
+ />
169
+ </li>
170
+ ))}
171
+ </ol>
172
+ )}
173
+ {out.total > out.results.length && <p className="kol-helper-12 text-meta">{out.total - out.results.length} more — narrow the query.</p>}
174
+ </section>
123
175
  )}
124
-
125
- <DocSection id="results" title={`Results (${out.total})`}>
126
- {out.results.length === 0 ? (
127
- <EmptyState eyebrow={q ? 'No results' : 'Search'} title={q ? 'Nothing matches.' : 'Type to search.'} body={q ? 'Remove a token above, or try fewer words.' : undefined} />
128
- ) : (
129
- <ol className="flex flex-col">
130
- {out.results.map((r) => (
131
- <li key={r.item.id} className="border-t border-fg-08 first:border-t-0">
132
- <ResultRow
133
- to={r.item.href}
134
- variant={rowVariant}
135
- title={<Highlight text={r.item.title} ranges={r.highlights.title} />}
136
- meta={[r.item.kind, r.item.category, r.item.space, r.item.date].filter(Boolean).join(' · ')}
137
- description={r.item.description}
138
- />
139
- </li>
140
- ))}
141
- </ol>
142
- )}
143
- {out.total > out.results.length && <p className="kol-helper-12 text-meta">{out.total - out.results.length} more — narrow the query.</p>}
144
- </DocSection>
145
176
  </div>
146
177
  )
147
178
  }
@@ -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-8">
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-8">
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,10 +231,37 @@ 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
- if (location.hash) return
236
- document.getElementById('shell-scroll')?.scrollTo(0, 0)
237
- }, [location.pathname, location.hash])
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
@@ -560,6 +587,10 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
560
587
  * it. Without a `searchPath` Enter keeps the older in-place browser. */
561
588
  onExpand={searchPath ? openResultsPage : () => tagMode.setExpanded?.(true)}
562
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()}”`}
563
594
  chips={tagMode.isProvided ? tagMode.activeTags : []}
564
595
  onRemoveChip={tagMode.removeTag}
565
596
  placeholder="Search…"
@@ -39,7 +39,8 @@ 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 opens nothing. Every other chapter starts
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
45
  * unknown id used to read as open, so every chapter sprang open).
45
46
  *
@@ -63,6 +64,11 @@ const ShellSidebar = ({ routes = [], basePath = '/', onNavigate, label = 'Naviga
63
64
  const deeper = chainTo(r.children, [...trail, r])
64
65
  if (deeper) return deeper
65
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]
66
72
  }
67
73
  return null
68
74
  }