@kolkrabbi/kol-workshop 0.6.0 → 0.18.1

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.
@@ -0,0 +1,168 @@
1
+ import { useState } from 'react'
2
+ import RailSection from './RailSection.jsx'
3
+ import RailRow from './RailRow.jsx'
4
+
5
+ /**
6
+ * RightRail — THE right rail. One component, every route.
7
+ *
8
+ * WHY IT EXISTS (user ruling 2026-08-01). The rail was TWO components:
9
+ * `AutoToc` in the showcase's ShellChrome served every non-vault page, and
10
+ * `DocReaderSidebar` in DocumentationReader overrode it through
11
+ * ShellTocContext on a vault route. They disagreed about which sections exist —
12
+ * one had Top tags and no Related, the other had Related and tag chips and no
13
+ * Top tags — so sections appeared and vanished as you moved, and an edit to one
14
+ * rail was invisible in the other. That is the same two-systems fault the rail
15
+ * LADDER (RailSection) and the rail ROW (RailRow) were each built to end, one
16
+ * level further out.
17
+ *
18
+ * THE SECTION SET IS FIXED — *"these are not conditional, they should be
19
+ * STANDARDISED"*. Every section renders on every route. A section with nothing
20
+ * in it renders with `(0)` and an empty body; it does not disappear, because a
21
+ * rail whose shape depends on its content teaches the reader nothing.
22
+ *
23
+ * THIS PAGE ── Contents the headings of the open document
24
+ * LINKS ── Quick actions what you can do from here
25
+ * ── Tags THIS page's tags first and brighter, then
26
+ * the rest of the system underneath
27
+ * ── Related other documents this one names, and its
28
+ * sources
29
+ *
30
+ * `LINKS`, after one turn as `TOOLS` (2026-08-01). The left rail already has a
31
+ * `TOOLS` category — the routes the app serves — so the same word sat on the
32
+ * same rung in both rails meaning two unrelated things. The right rail's is the
33
+ * one that moved: its contents ARE destinations plus the actions that reach
34
+ * them, and the left rail's TOOLS is a fixed route list that cannot be renamed
35
+ * without lying about what it holds.
36
+ *
37
+ * ONE Tags section, not two. `Tags` and `Top tags` were separate groups saying
38
+ * the same word twice; a reader had to learn which was which. They are one list
39
+ * now, ordered: this page's own tags at the top carrying `emphasis` ink, the
40
+ * system's most-used underneath at the resting stop. Order and ink carry the
41
+ * distinction the two headings used to.
42
+ *
43
+ * THE DIV RULE — *"sometimes you group things sometimes not, there is no rule
44
+ * in the madness"*. There is one now, and it is applied without exception:
45
+ *
46
+ * 1. An L1 category ALWAYS wraps its groups in `.shell-rail-stack-inner`.
47
+ * 2. An L2 group ALWAYS wraps its rows in `nav.shell-nav-items`.
48
+ * 3. Nothing else wraps anything. No bare children at a rung, no conditional
49
+ * wrapper, no `<div>` added because one section happened to need spacing.
50
+ *
51
+ * Spacing therefore comes from exactly two classes, both theme-owned, and a
52
+ * section cannot acquire its own geometry by accident.
53
+ *
54
+ * @param {Array} toc [{ id, label, sub }] — headings of the open doc
55
+ * @param {string} [activeId] the heading currently in view (the caller owns
56
+ * the scroll spy; both rails must agree on it)
57
+ * @param {Array} related [{ id, href, label }] — label is the target's
58
+ * own SHORT title, never a wikilink display text
59
+ * @param {Array} actions [{ id, label, icon, to?, onClick? }]
60
+ * @param {Array} topTags [{ tag, count }] — the way in, not a dump
61
+ * @param {Array} tags [string] — this page's own tags
62
+ * @param {Function} renderTag (tag) => node — the chip renderer, injected so
63
+ * this package does not reach into kol-component
64
+ * @param {Function} onTagClick (tag) => void
65
+ */
66
+ export default function RightRail({
67
+ toc = [],
68
+ activeId,
69
+ related = [],
70
+ actions = [],
71
+ topTags = [],
72
+ tags = [],
73
+ renderTag,
74
+ onTagClick,
75
+ icon: IconComponent,
76
+ }) {
77
+ /* One collapse map, not a useState per section — a section added later must
78
+ * not need a new hook at the top of this function. */
79
+ const [collapsed, setCollapsed] = useState({})
80
+ const toggle = (key) => setCollapsed((c) => ({ ...c, [key]: !c[key] }))
81
+
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))
85
+
86
+ /* Every group is described here rather than spelled out in JSX below, so a
87
+ * section cannot be present in one branch and missing in another — the exact
88
+ * defect that split the two rails. */
89
+ const group = (key, label, count, children) => (
90
+ <RailSection
91
+ level={2}
92
+ label={label}
93
+ count={count}
94
+ collapsed={!!collapsed[key]}
95
+ onToggle={() => toggle(key)}
96
+ icon={IconComponent}
97
+ >
98
+ <nav className="shell-nav-items">{children}</nav>
99
+ </RailSection>
100
+ )
101
+
102
+ return (
103
+ <div className="shell-rail-stack">
104
+ <RailSection level={1} label="This page">
105
+ <div className="shell-rail-stack-inner">
106
+ {group('toc', 'Contents', toc.length,
107
+ toc.map((h) => (
108
+ <RailRow key={h.id} href={`#${h.id}`} active={h.id === activeId} sub={h.sub}>
109
+ {h.label}
110
+ </RailRow>
111
+ ))
112
+ )}
113
+ </div>
114
+ </RailSection>
115
+
116
+ <RailSection level={1} label="Links">
117
+ <div className="shell-rail-stack-inner">
118
+ {group('actions', 'Quick actions', actions.length,
119
+ actions.map((a) => (
120
+ <RailRow key={a.id} to={a.to} onClick={a.onClick} icon={a.icon}>
121
+ {a.label}
122
+ </RailRow>
123
+ ))
124
+ )}
125
+
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, (
131
+ <>
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>
152
+ ))}
153
+ </>
154
+ ))}
155
+
156
+ {/* Related carries SOURCES too (user ruling 2026-08-01) — a repo URL
157
+ * or an external reference is another document this one names, and
158
+ * splitting them made two sections that answer the same question. */}
159
+ {group('related', 'Related', related.length,
160
+ related.map((r) => (
161
+ <RailRow key={r.id} to={r.href} href={r.url}>{r.label}</RailRow>
162
+ ))
163
+ )}
164
+ </div>
165
+ </RailSection>
166
+ </div>
167
+ )
168
+ }
@@ -2,8 +2,10 @@ import { createContext, 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
4
  import ShellSidebar from './ShellSidebar.jsx'
5
- import { Button, ShellDrawer, ShellSearchOverlay, Tooltip } from '@kolkrabbi/kol-component'
5
+ import { Button, IconFrame, ShellDrawer, ShellSearchOverlay, Tooltip } from '@kolkrabbi/kol-component'
6
6
  import { matchSearchItems } from '../engine/search.js'
7
+ import { useTagMode } from '../tags/TagModeContext.jsx'
8
+ import TagModeOverlay from '../tags/TagModeOverlay.jsx'
7
9
  import { Asset } from '@kolkrabbi/kol-brand/svg'
8
10
 
9
11
  // Pages can register right-rail TOC content via this context.
@@ -37,9 +39,33 @@ const MainColumn = ({ children, fullHeight }) => (
37
39
  className={`w-full min-w-0 h-full min-h-0 ${fullHeight ? 'overflow-hidden flex flex-col' : 'overflow-y-auto overscroll-none'}`}
38
40
  style={fullHeight ? undefined : { scrollbarGutter: 'stable' }}
39
41
  >
42
+ {/* THE cap lives HERE (2026-07-31), on the CONTENT — not on the grid that
43
+ * holds the rails. The theme's law is written about a page ("content
44
+ * LEFT-ANCHORED inside"), and the rails are chrome, not page. Capping the
45
+ * grid centred all three columns and pulled both rails inward off the
46
+ * viewport edge; the rails now sit flush at the chrome inset and only the
47
+ * main column carries a cap.
48
+ *
49
+ * It caps at CANVAS, not shell (user ruling 2026-07-31). Shell is the
50
+ * frame token and the middle grid track can never reach it — after both
51
+ * rails, gutters and inset there is ~1516 of room at a 2200 window — so a
52
+ * shell cap here was real code that could never fire, and every page just
53
+ * inherited whatever the window gave it. Canvas is the rung that binds.
54
+ * One decision, made once: 14 pages needed no per-page cap.
55
+ * `fullHeight` stays uncapped on purpose — it IS the fill-the-viewport
56
+ * escape hatch (iframe embeds).
57
+ *
58
+ * LEFT-ANCHORED, not centred (user ruling 2026-08-01). The capped div
59
+ * below carried `mx-auto`, which centres the column inside the main
60
+ * track — so above the canvas width the content drifted away from the
61
+ * rail it is supposed to line up with. The one-frame law has said
62
+ * "content LEFT-ANCHORED inside" since 2026-07-28 (kol-theme.css content
63
+ * block; docs/documentation/01-foundations/05-layout-systems.md): the
64
+ * FRAME centres in the viewport, the CONTENT does not centre in the
65
+ * frame. `validate:width` W4 asserts it now. */}
40
66
  {fullHeight
41
67
  ? children
42
- : <div className="pt-6 md:pt-6 lg:pt-8 pb-16">{children}</div>
68
+ : <div className="w-full max-w-[var(--kol-content-canvas)] pt-6 md:pt-6 lg:pt-8 pb-16">{children}</div>
43
69
  }
44
70
  </main>
45
71
  )
@@ -52,11 +78,13 @@ const MainColumn = ({ children, fullHeight }) => (
52
78
  * decision and must not be stated twice differently. */
53
79
  const TocColumn = ({ children }) => (
54
80
  <aside aria-label="Table of contents" className="shell-sidebar-sticky hidden xl:block shrink-0 h-full min-h-0 overflow-y-auto overflow-x-hidden overscroll-none pt-6 md:pt-6 lg:pt-8 pb-8">
55
- {/* The 224px lives HERE, not on the grid track (see gridCols). An inner
56
- * wrapper with a width means a rail whose content renders null measures
57
- * zero and the column collapses — the page gets the space back instead of
58
- * staring at a reserved empty gutter. */}
59
- <div className="w-56 empty:hidden">{children}</div>
81
+ {/* The WIDTH IS THE GRID TRACK now (--kol-shell-toc-w, see gridCols), not
82
+ * an inner wrapper. It used to be `w-56 empty:hidden` here precisely so a
83
+ * rail whose content rendered null would measure zero and let the main
84
+ * column reclaim the space — REVERSED by user ruling 2026-08-01: an empty
85
+ * rail still holds its column, because a rail that disappears re-flows
86
+ * main and the same page ends up at two different widths. */}
87
+ <div className="w-full">{children}</div>
60
88
  </aside>
61
89
  )
62
90
 
@@ -64,8 +92,21 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
64
92
  const [isNavDrawerOpen, setIsNavDrawerOpen] = useState(false)
65
93
  const [navCollapsed, setNavCollapsed] = useState(false)
66
94
  const [tocCollapsed, setTocCollapsed] = useState(false)
67
- const [isSearchOpen, setIsSearchOpen] = useState(false)
68
- const [searchQuery, setSearchQuery] = useState('')
95
+ /* ONE QUERY (user ruling 2026-08-01). The palette's text used to live here
96
+ * while tags lived in TagModeContext — two states, and therefore two
97
+ * surfaces. Both facets are the context's now. The local pair survives ONLY
98
+ * for a consumer mounting the shell without a TagModeProvider, where the
99
+ * context is the inert fallback and every keystroke would no-op. */
100
+ const tagMode = useTagMode()
101
+ const [localOpen, setLocalOpen] = useState(false)
102
+ const [localQuery, setLocalQuery] = useState('')
103
+
104
+ const isSearchOpen = tagMode.isProvided ? tagMode.isOpen : localOpen
105
+ const searchQuery = tagMode.isProvided ? tagMode.text : localQuery
106
+ const setSearchQuery = tagMode.isProvided ? tagMode.setText : setLocalQuery
107
+ const setIsSearchOpen = tagMode.isProvided
108
+ ? (v) => (v ? tagMode.openTagMode() : tagMode.closeTagMode())
109
+ : setLocalOpen
69
110
  const [tocContent, setTocContent] = useState(null)
70
111
  const [isFullHeight, setIsFullHeight] = useState(false)
71
112
  const [isShortcutsOpen, setIsShortcutsOpen] = useState(false)
@@ -103,33 +144,38 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
103
144
  }, [])
104
145
 
105
146
  const effectiveTocContent = tocContent ?? defaultTocContent
106
- /* An element is truthy even when the component inside it renders nothing, so
107
- * this alone reserved a 224px rail on every route — including ones with no
108
- * headings. Three fixes were rejected before this one: measuring the mounted
109
- * column DEADLOCKS (once the column unmounts the probe is gone and can never
110
- * report content again); calling a render-prop inline makes the consumer's
111
- * hooks belong to THIS component, one refactor away from a crash; and a
112
- * boolean prop puts the answer in the consumer, which doesn't know either.
147
+ /* THE RAIL HOLDS ITS COLUMN, EMPTY OR NOT (user ruling 2026-08-01).
148
+ *
149
+ * This used to hunt for a way to tell whether the rail had real content, so
150
+ * an empty one could collapse and hand its space back to main. That whole
151
+ * question is now moot — and it was the wrong question. A rail that vanishes
152
+ * on content-less routes means / renders at one main width and /foundations
153
+ * at another: the layout becomes a property of the page's content instead of
154
+ * the shell. The user caught it on the home page, where the TOC has no
155
+ * headings to show.
113
156
  *
114
- * The column decides for itself instead. The track is `auto`, not a fixed
115
- * 224px, and the width lives on the rail's CONTENT — so a rail that renders
116
- * nothing collapses to zero and the main column takes the space back. No
117
- * signal to keep in sync, nothing to deadlock. */
157
+ * So the track is RESERVED whenever the viewport is wide enough and the user
158
+ * has not collapsed it by hand. `hasToc` survives for ONE job — whether the
159
+ * header offers a toggle at all — because a control that collapses an
160
+ * already-empty rail is noise. Collapsing is still a user action, and a user
161
+ * action is allowed to change the layout; content appearing is not. */
118
162
  const hasToc = Boolean(effectiveTocContent)
119
163
  const showNav = !navCollapsed
120
- const showToc = hasToc && !tocCollapsed
164
+ const showToc = !tocCollapsed
121
165
 
122
166
  const layoutType = showNav && showToc ? 'nav-toc' : showNav ? 'nav' : showToc ? 'toc' : 'none'
123
167
 
124
- /* `auto`, not `224px` — see hasToc above. The width is declared on the rail's
125
- * content (TocColumn's inner wrapper), so a rail with nothing in it measures
126
- * zero and the main column reclaims the space. */
168
+ /* A FIXED track (--kol-shell-toc-w), not `auto`. `auto` sized the column to
169
+ * its content, which is exactly what let an empty rail measure zero. Both
170
+ * rail widths are tokens: the left is --kol-sidenav-w (the one number every
171
+ * grid track that lines up with it already reads), the right is
172
+ * --kol-shell-toc-w. */
127
173
  const gridCols = showNav
128
174
  ? showToc
129
- ? 'lg:grid-cols-[256px_minmax(0,1fr)] xl:grid-cols-[256px_minmax(0,1fr)_auto]'
130
- : 'lg:grid-cols-[256px_minmax(0,1fr)]'
175
+ ? 'lg:grid-cols-[var(--kol-sidenav-w)_minmax(0,1fr)] xl:grid-cols-[var(--kol-sidenav-w)_minmax(0,1fr)_var(--kol-shell-toc-w)]'
176
+ : 'lg:grid-cols-[var(--kol-sidenav-w)_minmax(0,1fr)]'
131
177
  : showToc
132
- ? 'xl:grid-cols-[minmax(0,1fr)_auto]'
178
+ ? 'xl:grid-cols-[minmax(0,1fr)_var(--kol-shell-toc-w)]'
133
179
  : ''
134
180
 
135
181
  // Adapt the old flat `routes` + callbacks to the DS ShellHeader API
@@ -176,7 +222,12 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
176
222
  <>
177
223
  {actions}
178
224
  <Tooltip label="Search">
179
- <Button variant="ghost" quiet iconOnly="search" iconSize={18} onClick={() => setIsSearchOpen(true)} aria-label="Search" />
225
+ {/* IconFrame at the `lg` rung (user ruling 2026-08-01). Two faults in
226
+ * one call: it was a Button, and at the default `md` rung it drew the
227
+ * solo-lg glyph inside an `md` square while every neighbour sat on
228
+ * `lg`. `quiet` is gone too — on ghost it rested at oq-48 × 0.5, which
229
+ * made search the faintest thing in a row of oq-64 controls. */}
230
+ <IconFrame name="search" variant="nav" size="lg" onClick={() => setIsSearchOpen(true)} aria-label="Search" />
180
231
  </Tooltip>
181
232
  </>
182
233
  )
@@ -237,10 +288,19 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
237
288
  * Tailwind's 16/20/24 steps instead of the ramp's 20/32/48 — so
238
289
  * every consumer page inherited the raw viewport (measured 2152px
239
290
  * of frame at a 2200px window against an 1800px law). Every
240
- * page-level width fix is downstream of this line. */}
291
+ * page-level width fix is downstream of this line.
292
+ *
293
+ * CORRECTED 2026-07-31: that fix capped THIS element, which holds
294
+ * all three columns — so the whole chrome centred and both rails
295
+ * were dragged inward off the viewport edge. The chrome frame
296
+ * takes the full available width and the rails justify to it; the
297
+ * cap moved one level down onto MainColumn's content, which is
298
+ * what the theme's law is written about. Same disease as
299
+ * --kol-container-max stopping at 1600 under an 1800 law: one
300
+ * element, two answers. */}
241
301
  <div
242
- className="mx-auto h-full w-full max-w-[var(--kol-content-shell)]"
243
- style={{ paddingInline: 'var(--kol-pad-section-x)' }}
302
+ className="h-full w-full"
303
+ style={{ paddingInline: 'var(--kol-pad-chrome-x)' }}
244
304
  >
245
305
  {/* gap lives in .shell-content-grid (theme) — a gap-8 utility here
246
306
  * outranks the layered theme rule at every width and killed the
@@ -261,6 +321,10 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
261
321
  </div>
262
322
  </MainColumn>
263
323
 
324
+ {/* Mounted on showToc alone — NOT on whether there is content
325
+ * to put in it. An empty TocColumn is the point: it holds
326
+ * the grid's third track so main keeps one width across
327
+ * every route (user ruling 2026-08-01). */}
264
328
  {showToc && (
265
329
  <TocColumn>
266
330
  {effectiveTocContent}
@@ -292,20 +356,32 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
292
356
  query={searchQuery}
293
357
  onQueryChange={setSearchQuery}
294
358
  results={searchResults}
359
+ /* THE EXPANDED BODY (user ruling 2026-08-01). The tag browser is
360
+ * not a sibling overlay — it is this palette's second state. Enter
361
+ * commits the query and swaps the result rows for the full body;
362
+ * committed tags ride along as chips on the same query. */
363
+ expanded={tagMode.isProvided && tagMode.expanded}
364
+ onExpand={() => tagMode.setExpanded?.(true)}
365
+ chips={tagMode.isProvided ? tagMode.activeTags : []}
366
+ onRemoveChip={tagMode.removeTag}
367
+ placeholder="Search…"
295
368
  onSelect={(item) => {
369
+ /* `action` before `href`: not every hit is a destination. A tag
370
+ * row FILTERS the same query and the palette STAYS OPEN — closing
371
+ * it was the behaviour that forced tags into a second overlay.
372
+ * Only a destination dismisses. */
373
+ if (typeof item?.action === 'function') {
374
+ item.action()
375
+ setSearchQuery('')
376
+ return
377
+ }
296
378
  setIsSearchOpen(false)
297
379
  setSearchQuery('')
298
- /* `action` before `href`: not every hit is a destination. A tag
299
- * row toggles state rather than navigating, and without this the
300
- * palette could only ever hold things that live at a URL — which
301
- * is why the tag search had to be a second, separate search box
302
- * that knew nothing about this one. The consumer supplies the
303
- * closure; the shell stays ignorant of what it does. */
304
- if (typeof item?.action === 'function') item.action()
305
- else if (item?.href) navigate(item.href)
380
+ if (item?.href) navigate(item.href)
306
381
  }}
307
- placeholder="Search…"
308
- />
382
+ >
383
+ {tagMode.isProvided && tagMode.expanded ? <TagModeOverlay /> : null}
384
+ </ShellSearchOverlay>
309
385
 
310
386
  {/* The `?` sheet. Rendered FROM the SHORTCUTS array above, so the help
311
387
  * and the bindings are one source — a shortcut can't be documented
@@ -1,6 +1,8 @@
1
1
  import { useState, useEffect } from 'react'
2
- import { NavLink, Link, useLocation } from 'react-router-dom'
3
- import { Icon, Tooltip } from '@kolkrabbi/kol-component'
2
+ import { useLocation } from 'react-router-dom'
3
+ import { Icon } from '@kolkrabbi/kol-component'
4
+ import RailSection from './RailSection.jsx'
5
+ import RailRow from './RailRow.jsx'
4
6
 
5
7
  const getSectionRootPath = (route, basePath) => {
6
8
  if (route.path !== undefined && route.path !== null) {
@@ -63,115 +65,68 @@ const ShellSidebar = ({ routes = [], basePath = '/', onNavigate, label = 'Naviga
63
65
  setCollapsedSections((prev) => ({ ...prev, [route.id]: !prev[route.id] }))
64
66
  }
65
67
 
68
+ /* No count at L1 — the eyebrow names a body of material and the tally lives
69
+ * on the groups inside it (user ruling 2026-08-01). RailSection refuses one
70
+ * at this rung, so there is nothing to compute here. */
66
71
  return (
67
- <div className="space-y-4">
68
- {/* kol-helper-10 types the wrapper box itself (was an untyped 16px box) */}
69
- <div className="shell-sidebar-toggle shell-sidebar-label kol-doc-eyebrow" style={{ justifyContent: 'space-between', paddingRight: '4px' }}>
70
- {labelTo ? (
71
- <Link to={labelTo} className="shell-sidebar-label kol-doc-eyebrow" onClick={(e) => {
72
- if (navCollapsed && handleToggle) handleToggle()
73
- if (onNavigate) onNavigate(e)
74
- }}>
75
- {label}
76
- </Link>
77
- ) : (
78
- /* Same voice as the labelTo branch. This read `kol-helper-10
79
- * text-meta`, so the one rail mounted without a labelTo (the vault's
80
- * "Documentation") rendered its label at a different size and colour
81
- * from its two siblings — a type fork caused purely by which props
82
- * the consumer happened to pass. */
83
- <button type="button" className="shell-sidebar-label kol-doc-eyebrow" onClick={handleToggle}>{label}</button>
84
- )}
85
- <Tooltip label={navCollapsed ? `Expand ${label}` : `Collapse ${label}`}>
86
- <button
87
- type="button"
88
- onClick={handleToggle}
89
- aria-label={navCollapsed ? `Expand ${label}` : `Collapse ${label}`}
90
- aria-expanded={!navCollapsed}
91
- className="flex items-center justify-center"
92
- style={{ height: '16.5px', marginBottom: '8px' }}
93
- >
94
- <Icon
95
- name="chevron-down"
96
- size={10}
97
- className={`stroke-[2.5] transition-transform ${navCollapsed ? '' : 'rotate-180'}`}
98
- />
99
- </button>
100
- </Tooltip>
101
- </div>
72
+ /* One rail layout, one class (2026-08-01). This was `space-y-4` against the
73
+ * right rail's `space-y-6` against the outer `flex flex-col gap-6` — three
74
+ * spellings of the same stack, and `space-y` fights any child that owns a
75
+ * margin, which the eyebrow box does. */
76
+ <div className="shell-rail-stack-inner">
77
+ <RailSection
78
+ level={1}
79
+ label={label}
80
+ to={labelTo}
81
+ collapsed={navCollapsed}
82
+ onToggle={handleToggle}
83
+ onNavigate={onNavigate}
84
+ >
85
+ <div className="shell-rail-stack-inner">
86
+ {routes.map((route) => {
87
+ /* A group with no children is not a group — it is a link. It used
88
+ * to render as a header anyway: a chevron that rotated over an
89
+ * empty body, no count, and no navigation, so clicking "Icons" or
90
+ * "Components" in the tree did nothing at all while looking like
91
+ * it should. `collapsible` is what that distinction is now. */
92
+ const hasChildren = route.children?.length > 0
102
93
 
103
- {!navCollapsed && <div className="space-y-4">
104
- {routes.map((route) => {
105
- const isExpanded = !collapsedSections[route.id]
106
-
107
- /* A group with no children is not a group — it is a link. It used to
108
- * render as a header anyway: a chevron that rotated over an empty
109
- * body, no count, and no navigation, so clicking "Icons" or
110
- * "Components" in the tree did nothing at all while looking like it
111
- * should. Now the chevron and the toggle only appear when there is
112
- * something to expand, and a childless row goes where it says. */
113
- const hasChildren = route.children?.length > 0
114
- const headerClass = 'shell-nav-group-header w-full text-left kol-mono-14 text-body'
115
-
116
- return (
117
- <div key={route.id} className="shell-nav-group">
118
- {hasChildren ? (
119
- <button
120
- type="button"
121
- className={headerClass}
122
- onClick={() => handleSectionClick(route)}
123
- aria-expanded={isExpanded}
94
+ return (
95
+ <div key={route.id} className="shell-nav-group">
96
+ <RailSection
97
+ level={2}
98
+ label={route.label}
99
+ count={hasChildren ? route.children.length : undefined}
100
+ /* THE HEADER ALWAYS LINKS (2026-08-02). It used to link only
101
+ * when the group had NO children, so a chapter with pages had
102
+ * a dead header and its index had to ride as a row inside
103
+ * itself. RailSection already separates the two gestures —
104
+ * the label navigates, the rest of the row toggles — so a
105
+ * group can open its landing page and still collapse. */
106
+ to={route.path ?? getSectionRootPath(route, basePath)}
107
+ collapsible={hasChildren}
108
+ collapsed={!!collapsedSections[route.id]}
109
+ onToggle={() => handleSectionClick(route)}
110
+ onNavigate={onNavigate}
111
+ icon={Icon}
124
112
  >
125
- <span className="flex items-center gap-2">
126
- <Icon
127
- name="chevron-right"
128
- size={12}
129
- className={`transition-transform ${isExpanded ? 'rotate-90' : ''}`}
130
- />
131
- {route.label}
132
- </span>
133
- <span className="kol-mono-14 text-subtle">({route.children.length})</span>
134
- </button>
135
- ) : (
136
- <Link
137
- to={getSectionRootPath(route, basePath)}
138
- className={headerClass}
139
- onClick={onNavigate}
140
- >
141
- {/* aligned with the expandable rows' label column — the
142
- * chevron's width, kept as space so the two kinds of row
143
- * share a left edge */}
144
- <span className="flex items-center gap-2">
145
- <span aria-hidden="true" style={{ width: 12 }} />
146
- {route.label}
147
- </span>
148
- </Link>
149
- )}
150
-
151
- {isExpanded && route.children?.length > 0 && (
152
- <div className="shell-nav-items">
153
- {route.children.map((child) => {
154
- const childPath = getChildPath(child, basePath)
155
- return (
156
- <NavLink
113
+ <nav className="shell-nav-items">
114
+ {(route.children ?? []).map((child) => (
115
+ <RailRow
157
116
  key={child.id}
158
- to={childPath}
159
- end
160
- className={({ isActive }) =>
161
- `shell-nav-item kol-mono-14 ${isActive ? 'text-emphasis' : 'text-body'}`
162
- }
163
- onClick={onNavigate}
117
+ to={getChildPath(child, basePath)}
118
+ onNavigate={onNavigate}
164
119
  >
165
- <span className="shell-nav-item-title">{child.label}</span>
166
- </NavLink>
167
- )
168
- })}
169
- </div>
170
- )}
171
- </div>
172
- )
173
- })}
174
- </div>}
120
+ {child.label}
121
+ </RailRow>
122
+ ))}
123
+ </nav>
124
+ </RailSection>
125
+ </div>
126
+ )
127
+ })}
128
+ </div>
129
+ </RailSection>
175
130
  </div>
176
131
  )
177
132
  }
@@ -9,3 +9,14 @@
9
9
  export { default as ShellLayout } from './ShellLayout.jsx'
10
10
  export { ShellTocContext, ShellFullHeightContext, ShellTocCollapsedContext } from './ShellLayout.jsx'
11
11
  export { default as ShellSidebar } from './ShellSidebar.jsx'
12
+ /* RailSection — THE rail ladder. Every rail header at every rung comes from
13
+ * here, in both rails; see the file header for why a class alone was not
14
+ * enough. `pnpm validate:rails` R4 asserts it. */
15
+ export { default as RailSection } from './RailSection.jsx'
16
+ /* RailRow — THE L3 rail row. The look lives in `.shell-nav-item`; this owns the
17
+ * markup so nine hand-written utility stacks cannot come back. R4 asserts it. */
18
+ export { default as RailRow } from './RailRow.jsx'
19
+ /* RightRail — THE right rail, one component for every route. It replaced two
20
+ * that disagreed about which sections exist, which is why sections used to
21
+ * appear and vanish. The section set is fixed and unconditional. */
22
+ export { default as RightRail } from './RightRail.jsx'