@kolkrabbi/kol-workshop 0.11.0 → 0.19.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.11.0",
3
+ "version": "0.19.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,10 +14,10 @@
14
14
  "dependencies": {
15
15
  "d3": "^7.9.0",
16
16
  "@kolkrabbi/kol-brand": "^0.1.2",
17
- "@kolkrabbi/kol-component": "0.19.0",
18
- "@kolkrabbi/kol-framework": "0.11.1",
19
- "@kolkrabbi/kol-icons": "0.8.11",
20
- "@kolkrabbi/kol-theme": "0.18.0"
17
+ "@kolkrabbi/kol-component": "0.26.1",
18
+ "@kolkrabbi/kol-icons": "0.10.0",
19
+ "@kolkrabbi/kol-framework": "0.14.0",
20
+ "@kolkrabbi/kol-theme": "0.31.2"
21
21
  },
22
22
  "peerDependencies": {
23
23
  "react": "^18.3.0 || ^19.0.0",
@@ -25,7 +25,7 @@ const WorkshopDefaultSidebar = ({ routes = [], basePath = '/workshop' }) => {
25
25
  const links = currentChild?.links
26
26
 
27
27
  return (
28
- <div className="space-y-4">
28
+ <div className="shell-rail-stack-inner">
29
29
  {siblings.length > 1 && (
30
30
  <div>
31
31
  <div className="shell-sidebar-label kol-doc-eyebrow">{parentRoute.label}</div>
@@ -60,7 +60,7 @@ const DocsSidebar = ({ inventory = [], docHref, basePath, onNavigate, collapsed,
60
60
  }
61
61
 
62
62
  return (
63
- <div className="space-y-4">
63
+ <div className="shell-rail-stack-inner">
64
64
  {/* L1 from RailSection — the same rung and the same box as every other
65
65
  * rail header. No count: the eyebrow names the material, the groups
66
66
  * inside carry the tally (user ruling 2026-08-01). */}
@@ -72,7 +72,7 @@ const DocsSidebar = ({ inventory = [], docHref, basePath, onNavigate, collapsed,
72
72
  onToggle={onToggle}
73
73
  onNavigate={onNavigate}
74
74
  >
75
- <div className="space-y-4">
75
+ <div className="shell-rail-stack-inner">
76
76
  {Object.entries(groupedDocs)
77
77
  .sort(([a], [b]) => a.localeCompare(b))
78
78
  .map(([major, docs]) => {
@@ -87,7 +87,7 @@ const DocsSidebar = ({ inventory = [], docHref, basePath, onNavigate, collapsed,
87
87
  onToggle={() => toggleGroup(major)}
88
88
  icon={Icon}
89
89
  >
90
- <div className="shell-nav-items">
90
+ <nav className="shell-nav-items">
91
91
  {docs.map((d) => (
92
92
  <RailRow
93
93
  key={d.id}
@@ -99,7 +99,7 @@ const DocsSidebar = ({ inventory = [], docHref, basePath, onNavigate, collapsed,
99
99
  {cleanTitle(d.title, d.id)}
100
100
  </RailRow>
101
101
  ))}
102
- </div>
102
+ </nav>
103
103
  </RailSection>
104
104
  </div>
105
105
  )
@@ -134,7 +134,7 @@ const WorkshopSidebar = ({
134
134
  )
135
135
 
136
136
  return (
137
- <div className="space-y-6">
137
+ <div className="shell-rail-stack">
138
138
  <ShellSidebar
139
139
  routes={workshopRoutes}
140
140
  basePath={basePath}
@@ -1,3 +1,4 @@
1
+ import { useLayoutEffect, useRef, useState } from 'react'
1
2
  import { Icon, Tag } from '@kolkrabbi/kol-component'
2
3
  import { useTagMode } from '../tags'
3
4
  import TagPath from '../tags/TagPath.jsx'
@@ -77,6 +78,19 @@ const FIELD_LABELS = {
77
78
  modified: 'Modified',
78
79
  }
79
80
 
81
+ /* THE FALLBACK IS A LABEL TOO (user ruling 2026-08-01). `FIELD_LABELS[key] ??
82
+ * key` printed an unnamed key raw, so a doc carrying `imported_from` before it
83
+ * was listed rendered the row as `imported_from` — snake_case and lowercase, in
84
+ * a column where every neighbour is sentence case. *"its very amateur that you
85
+ * cant start a sentence or word with a Capital?"*
86
+ *
87
+ * Casing is authored at the data layer, never by CSS (the no-auto-text-transform
88
+ * law) — so the fallback authors it here, where the string is made. */
89
+ const humanise = (key) => {
90
+ const words = String(key).replace(/[-_]+/g, ' ').trim()
91
+ return words.charAt(0).toUpperCase() + words.slice(1)
92
+ }
93
+
80
94
  /* Reading ORDER, not an allowlist. The kol-docs contract first
81
95
  * (`.kol/docs-framework/01-conventions.md`: required title/type/status/updated/
82
96
  * tags, then recommended description/aliases, then optional), legacy
@@ -92,13 +106,33 @@ const FIELD_LABELS = {
92
106
  * `related` is the one deliberate omission — the rail renders it as live links,
93
107
  * so printing the raw wikilinks here would be the same thing twice. */
94
108
  const FIELD_ORDER = [
95
- 'title', 'type', 'status', 'updated', 'tags',
109
+ /* `created` sits ABOVE `updated` (user ruling 2026-08-01) — a document's age
110
+ * reads oldest-first, and `updated` on its own says nothing about how long
111
+ * the thing has existed. */
112
+ 'title', 'type', 'status', 'created', 'updated', 'tags',
96
113
  'description', 'aliases', 'sources',
97
- 'created', 'verified', 'audience', 'superseded_by', 'drift',
114
+ 'verified', 'audience', 'superseded_by', 'drift',
98
115
  'category', 'date', 'modified', 'version',
99
116
  ]
100
117
 
101
- const HIDDEN = new Set(['related'])
118
+ /* `related` — the rail renders it as live links; printing the raw wikilinks
119
+ * here would be the same thing twice.
120
+ *
121
+ * `aliases` — user ruling 2026-08-01: *"why is this a category in frontmatter?
122
+ * what purpose … its just not relevant"*. It is an OBSIDIAN field, not a tag
123
+ * and not a document fact: `.kol/docs-framework/01-conventions.md:65` says it
124
+ * feeds the Quick Switcher and search autocomplete and explicitly does **not**
125
+ * affect wikilink resolution. Nothing in this app reads it. It stays in the
126
+ * file for Obsidian and stops taking a row in a panel that renders facts. */
127
+ const HIDDEN = new Set(['related', 'aliases'])
128
+
129
+ /* VALUES ARE SENTENCE CASE TOO (user ruling 2026-08-01). The labels were fixed
130
+ * and the values were not, so the panel read `Type reference` / `Status
131
+ * active` — a capitalised label pointing at a lowercase word. These two fields
132
+ * carry closed enums from the kol-docs contract, so the case is authored here
133
+ * against the key, never with a text-transform. Every other value is content
134
+ * and is printed exactly as written. */
135
+ const CASED_VALUE_FIELDS = new Set(['type', 'status'])
102
136
 
103
137
  /** Contract order first, then anything else the doc carries, alphabetically. */
104
138
  const orderFields = (metadata) => {
@@ -124,15 +158,33 @@ const formatDate = (dateStr) => {
124
158
 
125
159
  const DocsFrontmatter = ({ metadata, docId }) => {
126
160
  const { openTagMode } = useTagMode()
127
- if (!metadata || Object.keys(metadata).length === 0) return null
161
+ /* THE PANEL COLLAPSES, THE PAGE SCROLLS (user call 2026-08-09). A long
162
+ * frontmatter (Dropdown: classes + reuses + tokens) made the panel the
163
+ * page, and its per-field scrollboxes captured the wheel on the way down.
164
+ * The whole panel now caps behind a fade and expands in normal flow — the
165
+ * toggle renders only when the content actually overflows the cap. */
166
+ const bodyRef = useRef(null)
167
+ const [expanded, setExpanded] = useState(false)
168
+ const [overflowing, setOverflowing] = useState(false)
169
+
170
+ const fields = metadata ? orderFields(metadata) : []
128
171
 
129
- const fields = orderFields(metadata)
172
+ useLayoutEffect(() => {
173
+ const el = bodyRef.current
174
+ if (el) setOverflowing(el.scrollHeight > el.clientHeight + 1)
175
+ }, [metadata, expanded])
130
176
 
131
177
  if (fields.length === 0) return null
132
178
 
179
+ const capped = !expanded
180
+
133
181
  return (
134
182
  <div className="docs-frontmatter">
135
183
  <p className="shell-sidebar-label kol-doc-eyebrow">Frontmatter</p>
184
+ <div
185
+ ref={bodyRef}
186
+ className={`docs-frontmatter-body${capped ? ' docs-frontmatter-body--capped' : ''}`}
187
+ >
136
188
  {fields.map((key) => {
137
189
  const value = metadata[key]
138
190
  const icon = FIELD_ICONS[key]
@@ -141,9 +193,9 @@ const DocsFrontmatter = ({ metadata, docId }) => {
141
193
  <div key={key} className="docs-frontmatter-row">
142
194
  <span className="docs-frontmatter-key kol-helper-12 text-meta">
143
195
  {icon && <Icon name={icon} size={14} />}
144
- {FIELD_LABELS[key] ?? key}
196
+ {FIELD_LABELS[key] ?? humanise(key)}
145
197
  </span>
146
- <span className="kol-mono-12 text-strong">
198
+ <span className="docs-frontmatter-value kol-mono-12 text-strong">
147
199
  {key === 'tags' && Array.isArray(value) ? (
148
200
  /* `naked`, the SAME Tag rendering the rail's own tag list uses
149
201
  * (DocReaderSidebar). This was the default filled variant, so
@@ -168,8 +220,31 @@ const DocsFrontmatter = ({ metadata, docId }) => {
168
220
  </span>
169
221
  ) : DATE_FIELDS.has(key) ? (
170
222
  formatDate(String(value))
223
+ ) : CASED_VALUE_FIELDS.has(key) ? (
224
+ humanise(String(value))
171
225
  ) : Array.isArray(value) ? (
172
- value.join(' · ')
226
+ /* STACKED, ONE PER LINE (user ruling 2026-08-01). Arrays were
227
+ * `value.join(' · ')` — a single run-on string, so `sources`
228
+ * with three file paths ran straight off the right edge of the
229
+ * panel and the reader could not tell where one path ended and
230
+ * the next began. A list of paths IS a list; the dot-join was
231
+ * printing it as a sentence.
232
+ *
233
+ * `break-all` because these are paths, not words: a wrapped
234
+ * path must break at the character, never leave a gap and drop
235
+ * the whole segment to the next line.
236
+ *
237
+ * THE LIST SCROLLS PAST ITS CAP (user question 2026-08-01:
238
+ * "I just wonder what it will be when the sources are f.e. more
239
+ * then 15?"). Unbounded, fifteen paths push the article fifteen
240
+ * lines down and the metadata panel becomes the page. No
241
+ * file-type icons — a path already ends in its own extension,
242
+ * and a glyph restating it is decoration. */
243
+ <span className="docs-frontmatter-list">
244
+ {value.map((item, i) => (
245
+ <span key={i} className="break-all">{String(item)}</span>
246
+ ))}
247
+ </span>
173
248
  ) : (
174
249
  String(value)
175
250
  )}
@@ -177,6 +252,18 @@ const DocsFrontmatter = ({ metadata, docId }) => {
177
252
  </div>
178
253
  )
179
254
  })}
255
+ {capped && overflowing && <div className="docs-frontmatter-fade" aria-hidden="true" />}
256
+ </div>
257
+ {(overflowing || expanded) && (
258
+ <button
259
+ type="button"
260
+ className="docs-frontmatter-toggle kol-helper-12 text-meta"
261
+ onClick={() => setExpanded((v) => !v)}
262
+ >
263
+ <Icon name={expanded ? 'chevron-up' : 'chevron-down'} size={14} />
264
+ {expanded ? 'Collapse' : 'Expand'}
265
+ </button>
266
+ )}
180
267
  </div>
181
268
  )
182
269
  }
@@ -1,139 +1,52 @@
1
1
  import { useContext, useEffect, useMemo, useState } from 'react'
2
2
  import { useParams, Link, useNavigate } from 'react-router-dom'
3
- import { CodeBlock, Divider, DocsToc, Icon, Table, Tag } from '@kolkrabbi/kol-component'
4
- import { ShellTocContext, RailSection, RailRow } from '../shell'
3
+ import { CodeBlock, Divider, Icon, Table, useScrollSpy } from '@kolkrabbi/kol-component'
4
+ import { ShellTocContext, RightRail } from '../shell'
5
5
  import { useTagMode } from '../tags'
6
+ import TagPath from '../tags/TagPath.jsx'
6
7
  import { parseDocsMarkdown, isIndexFile } from '../engine'
8
+ import { buildTagCounts } from '../engine/tags.js'
9
+ import { fileLabel } from '../engine/doc-helpers.js'
7
10
  import DocsHeader from './DocsHeader.jsx'
8
11
  import DocsArticle from './DocsArticle.jsx'
9
12
  import DocsFrontmatter from './DocsFrontmatter.jsx'
10
13
  import { DocSection } from './DocKit.jsx'
11
14
  import { renderInlineTokens } from './render-tokens.jsx'
12
15
 
13
- /**
14
- * A rail section header. Same anatomy as the LEFT tree's group header — a
15
- * rotating chevron, the label, and the item count pushed right — because the
16
- * two rails are one system and a reader shouldn't have to learn each side
17
- * separately.
18
- *
19
- * These were bare text with no count and no hover. They carry both now, and
20
- * the whole row is the toggle — with no chevron drawn at L1 (user ruling
21
- * 2026-08-01): the section collapses and expands, the affordance just isn't an
22
- * icon.
23
- *
24
- * It is a thin wrapper over RailSection, which owns the rung: the class, the
25
- * box, and WHERE THE COUNT SITS. This header used to hand-write all three and
26
- * borrowed `.shell-nav-group-header` + `.shell-sidebar-label` to do it, which
27
- * is how the two rails ended up printing their counts on different rows.
28
- */
29
- const SidebarSection = ({ sectionKey, label, count, collapsedSections, toggleSection, children }) => (
30
- <RailSection
31
- level={2}
32
- label={label}
33
- count={count > 0 ? count : undefined}
34
- collapsed={!!collapsedSections[sectionKey]}
35
- onToggle={() => toggleSection(sectionKey)}
36
- icon={Icon}
37
- >
38
- {children}
39
- </RailSection>
40
- )
41
-
42
- const DocReaderSidebar = ({ toc, allTags, related, docId, docsIndexHref, componentsHref, docFilePath }) => {
16
+ /* THE RIGHT RAIL IS ONE COMPONENT (user ruling 2026-08-01). This file used to
17
+ * carry its own — `DocReaderSidebar`, with its own section set, its own collapse
18
+ * state and its own JSX — while the showcase's ShellChrome carried a second one.
19
+ * They disagreed about which sections exist, so sections appeared and vanished,
20
+ * and `SidebarSection` here was a third spelling of a rung `RailSection` already
21
+ * owned. All of it is `RightRail` now; this is an adapter that hands it data. */
22
+ const DocReaderSidebar = ({ toc, allTags, related, docId, docsIndexHref, componentsHref, docFilePath, topTags = [] }) => {
43
23
  const navigate = useNavigate()
44
24
  const { openTagMode } = useTagMode()
45
- const [collapsedSections, setCollapsedSections] = useState({})
46
- const toggleSection = (key) => setCollapsedSections(prev => ({ ...prev, [key]: !prev[key] }))
47
-
48
- return (
49
- /* THE SAME LADDER AS THE LEFT RAIL (user ruling 2026-08-01). One L1 eyebrow
50
- * naming the body, then L2 groups that collapse and carry a chevron + a
51
- * count. These four sections used to be four bare L1 eyebrows stacked with
52
- * no parent and no chevron — the left rail's shape stated differently on
53
- * the other side of the page. */
54
- <RailSection level={1} label="This page">
55
- <div className="space-y-6">
56
- <SidebarSection
57
- sectionKey="toc"
58
- label="Contents"
59
- count={toc?.length}
60
- collapsedSections={collapsedSections}
61
- toggleSection={toggleSection}
62
- >
63
- <DocsToc toc={toc} root="#main" />
64
- </SidebarSection>
25
+ /* `#main` is the scroll container — the shell is `fixed inset-0` with its own
26
+ * scroll regions, so a viewport-rooted observer never fires. Same call the
27
+ * other rail makes; the rail is handed the answer, never a second observer. */
28
+ const activeId = useScrollSpy((toc ?? []).map((h) => h.id), { root: '#main' })
65
29
 
66
- {related.length > 0 && (
67
- <SidebarSection
68
- sectionKey="related"
69
- label="Related"
70
- count={related.length}
71
- collapsedSections={collapsedSections}
72
- toggleSection={toggleSection}
73
- >
74
- {/* ONE rail voice (user law): rows = the shell-nav-item idiom. */}
75
- <nav className="flex flex-col">
76
- {related.map((r) => (
77
- <RailRow key={r.id} to={r.href}>{r.label}</RailRow>
78
- ))}
79
- </nav>
80
- </SidebarSection>
81
- )}
30
+ const actions = [
31
+ { id: 'back', label: 'Back', icon: <Icon name="arrow-left" size={14} />, onClick: () => navigate(-1) },
32
+ { id: 'docs', label: 'All documentation', icon: <Icon name="book-open" size={14} />, to: docsIndexHref },
33
+ { id: 'components', label: 'View components', icon: <Icon name="grid" size={14} />, to: componentsHref },
34
+ { id: 'copy', label: 'Copy path', icon: <Icon name="copy" size={14} />, onClick: () => navigator.clipboard.writeText(docFilePath(docId)) },
35
+ { id: 'graph', label: 'Graph view', icon: <Icon name="polygon" size={14} />, onClick: () => openTagMode(null, { view: 'graph' }) },
36
+ ]
82
37
 
83
- <SidebarSection
84
- sectionKey="actions"
85
- label="Quick actions"
86
- collapsedSections={collapsedSections}
87
- toggleSection={toggleSection}
88
- >
89
- {/* Quick actions wear the SAME row as On-this-page and Related. They
90
- * were `.shell-sidebar-action`, a third geometry with no left indent,
91
- * so this rail carried three different row idioms — under the
92
- * "ONE rail voice" comment above. */}
93
- <div>
94
- <RailRow onClick={() => navigate(-1)} icon={<Icon name="arrow-left" size={14} />}>Back</RailRow>
95
- <RailRow to={docsIndexHref} icon={<Icon name="book-open" size={14} />}>All documentation</RailRow>
96
- <RailRow to={componentsHref} icon={<Icon name="grid" size={14} />}>View components</RailRow>
97
- <RailRow onClick={() => navigator.clipboard.writeText(docFilePath(docId))} icon={<Icon name="copy" size={14} />}>Copy path</RailRow>
98
- <RailRow onClick={() => openTagMode(null, { view: 'graph' })} icon={<Icon name="polygon" size={14} />}>Graph view</RailRow>
99
- </div>
100
- </SidebarSection>
101
-
102
- {/* TAGS IS A CATEGORY (user ruling 2026-08-01) — not a bare tag dump.
103
- * It holds the two ENTRY POINTS into the tag system plus this page's
104
- * own tags. The graph view was reachable only through an unlabelled hex
105
- * glyph floating at the corner of the overlay: a feature nobody could
106
- * find unless they already knew it existed. Both entries are rail rows
107
- * now, in the one row idiom. */}
108
- <SidebarSection
109
- sectionKey="tags"
110
- count={allTags?.length}
111
- label="Tags"
112
- collapsedSections={collapsedSections}
113
- toggleSection={toggleSection}
114
- >
115
- {/* NO Search row (user ruling 2026-08-01) — the nav search icon already
116
- * opens the one modal, and a second door to it is the duplication we
117
- * just spent an arc deleting. Graph view moved to Quick actions: it
118
- * is an ACTION on the tag system, not one of this page's tags. */}
119
- {allTags.length > 0 && (
120
- /* No `color`, no `size` — see DocsFrontmatter. `sm` is the default
121
- * and the only size; a colour would cost the hover state. */
122
- <div className="flex flex-wrap gap-1.5 items-start min-w-0 w-full mt-2">
123
- {allTags.map((tag) => (
124
- <Tag
125
- key={tag}
126
- onClick={() => openTagMode(tag)}
127
- className="max-w-full overflow-hidden text-ellipsis"
128
- >
129
- {tag}
130
- </Tag>
131
- ))}
132
- </div>
133
- )}
134
- </SidebarSection>
135
- </div>
136
- </RailSection>
38
+ return (
39
+ <RightRail
40
+ toc={toc}
41
+ activeId={activeId}
42
+ related={related}
43
+ actions={actions}
44
+ topTags={topTags}
45
+ tags={allTags}
46
+ renderTag={(tag) => <TagPath tag={tag} />}
47
+ onTagClick={(tag) => openTagMode(tag)}
48
+ icon={Icon}
49
+ />
137
50
  )
138
51
  }
139
52
 
@@ -331,6 +244,11 @@ const DocumentationReader = ({
331
244
  }, [rawMarkdown])
332
245
 
333
246
  // Combine frontmatter tags with inline hashtags
247
+ /* THE SAME Top tags THE OTHER RAIL SHOWS (user ruling 2026-08-01). The rail
248
+ * is standardised, so this section cannot exist on one route and not another;
249
+ * the inventory is already a prop here, so there is nothing to fetch. */
250
+ const topTags = useMemo(() => buildTagCounts(inventory).slice(0, 12), [inventory])
251
+
334
252
  const allTags = useMemo(() => {
335
253
  const frontmatterTags = doc?.metadata?.tags || []
336
254
  return [...new Set([...frontmatterTags, ...inlineTags])]
@@ -366,7 +284,15 @@ const DocumentationReader = ({
366
284
  inventory.find((d) => d.file.replace(/\.md$/, '') === full) ??
367
285
  inventory.find((d) => d.file.replace(/\.md$/, '').toLowerCase() === `${full}/index`.toLowerCase()) ??
368
286
  inventory.find((d) => d.id === basename)
369
- return hit ? { id: hit.id, href: docHref(hit.id), label: display } : null
287
+ /* `fileLabel`, THE rail-label rule — the same one the left tree uses
288
+ * (user rulings 2026-08-01). Three spellings have been tried on this
289
+ * row: the wikilink's display text (`[[02-color|color]]` → lowercase
290
+ * `color`), then the doc's frontmatter title (which carried a subtitle
291
+ * after an em dash, so the row read `Color — anchors and ramps` and
292
+ * truncated). A rail row is a NAME: one or two words, from the
293
+ * filename, identical to how the same doc appears in the left tree.
294
+ * The display text stays the fallback for an unresolvable target. */
295
+ return hit ? { id: hit.id, href: docHref(hit.id), label: fileLabel(hit.file) || display } : null
370
296
  })
371
297
  .filter(Boolean)
372
298
  }, [doc, inventory, docHref])
@@ -381,6 +307,7 @@ const DocumentationReader = ({
381
307
  key={docId}
382
308
  toc={toc}
383
309
  allTags={allTags}
310
+ topTags={topTags}
384
311
  related={related}
385
312
  docId={docId}
386
313
  docsIndexHref={docsIndex}
@@ -389,7 +316,7 @@ const DocumentationReader = ({
389
316
  />
390
317
  )
391
318
  return () => setTocContent(null)
392
- }, [setTocContent, docId, toc, allTags, related, docsIndex, components, docFilePath])
319
+ }, [setTocContent, docId, toc, allTags, topTags, related, docsIndex, components, docFilePath])
393
320
 
394
321
  if (!doc) {
395
322
  return (
@@ -20,6 +20,11 @@ import { Link } from 'react-router-dom'
20
20
  *
21
21
  * L1 section `.shell-sidebar-toggle` + kol-doc-eyebrow quiet, no chevron
22
22
  * L2 group `.shell-nav-group-header` + kol-mono-14 louder, chevron
23
+ *
24
+ * L2 carries NO colour class: `.shell-nav-group-header` owns weight 500 and
25
+ * `--kol-fg-shout` itself (user ruling 2026-08-01). It used to carry
26
+ * `text-body` — a utility that resolved to nothing at the time, and would now
27
+ * fight the rule for the same property. One owner.
23
28
  * L3 row `.shell-nav-item` + kol-mono-14 leaf, DocsToc/links
24
29
  *
25
30
  * THE RULES THIS COMPONENT ENFORCES BY CONSTRUCTION
@@ -71,13 +76,13 @@ export default function RailSection({
71
76
  * four call sites in two rails, spelled differently at each. */
72
77
  const headerClass = level === 1
73
78
  ? 'shell-sidebar-toggle kol-doc-eyebrow'
74
- : 'shell-nav-group-header kol-mono-14 text-body'
79
+ : 'shell-nav-group-header kol-mono-14'
75
80
 
76
81
  /* A static header is not a button and must not claim to be one. It still
77
82
  * wears the same box — `.shell-sidebar-label` shares L1's rule. */
78
83
  const staticClass = level === 1
79
84
  ? 'shell-sidebar-label kol-doc-eyebrow'
80
- : 'shell-nav-group-header kol-mono-14 text-body'
85
+ : 'shell-nav-group-header kol-mono-14'
81
86
 
82
87
  /* L2 only. At L1 the caret is not drawn (user ruling) — the row is still the
83
88
  * toggle, so the affordance exists, it just isn't a glyph. */
@@ -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
+ }
@@ -1,8 +1,8 @@
1
1
  import { createContext, useState, useEffect, Suspense } from 'react'
2
2
  import { Outlet, Link, useLocation, useNavigate } from 'react-router-dom'
3
- import { ShellHeader, HEADER_ICON } from '@kolkrabbi/kol-framework'
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
7
  import { useTagMode } from '../tags/TagModeContext.jsx'
8
8
  import TagModeOverlay from '../tags/TagModeOverlay.jsx'
@@ -222,7 +222,12 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
222
222
  <>
223
223
  {actions}
224
224
  <Tooltip label="Search">
225
- <Button variant="ghost" quiet iconOnly="search" iconSize={HEADER_ICON} 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" />
226
231
  </Tooltip>
227
232
  </>
228
233
  )
@@ -69,7 +69,11 @@ const ShellSidebar = ({ routes = [], basePath = '/', onNavigate, label = 'Naviga
69
69
  * on the groups inside it (user ruling 2026-08-01). RailSection refuses one
70
70
  * at this rung, so there is nothing to compute here. */
71
71
  return (
72
- <div className="space-y-4">
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">
73
77
  <RailSection
74
78
  level={1}
75
79
  label={label}
@@ -78,7 +82,7 @@ const ShellSidebar = ({ routes = [], basePath = '/', onNavigate, label = 'Naviga
78
82
  onToggle={handleToggle}
79
83
  onNavigate={onNavigate}
80
84
  >
81
- <div className="space-y-4">
85
+ <div className="shell-rail-stack-inner">
82
86
  {routes.map((route) => {
83
87
  /* A group with no children is not a group — it is a link. It used
84
88
  * to render as a header anyway: a chevron that rotated over an
@@ -93,14 +97,20 @@ const ShellSidebar = ({ routes = [], basePath = '/', onNavigate, label = 'Naviga
93
97
  level={2}
94
98
  label={route.label}
95
99
  count={hasChildren ? route.children.length : undefined}
96
- to={hasChildren ? undefined : getSectionRootPath(route, basePath)}
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)}
97
107
  collapsible={hasChildren}
98
108
  collapsed={!!collapsedSections[route.id]}
99
109
  onToggle={() => handleSectionClick(route)}
100
110
  onNavigate={onNavigate}
101
111
  icon={Icon}
102
112
  >
103
- <div className="shell-nav-items">
113
+ <nav className="shell-nav-items">
104
114
  {(route.children ?? []).map((child) => (
105
115
  <RailRow
106
116
  key={child.id}
@@ -110,7 +120,7 @@ const ShellSidebar = ({ routes = [], basePath = '/', onNavigate, label = 'Naviga
110
120
  {child.label}
111
121
  </RailRow>
112
122
  ))}
113
- </div>
123
+ </nav>
114
124
  </RailSection>
115
125
  </div>
116
126
  )
@@ -16,3 +16,7 @@ export { default as RailSection } from './RailSection.jsx'
16
16
  /* RailRow — THE L3 rail row. The look lives in `.shell-nav-item`; this owns the
17
17
  * markup so nine hand-written utility stacks cannot come back. R4 asserts it. */
18
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'
@@ -17,7 +17,10 @@ export default function TagPath({ tag }) {
17
17
  if (!parts.length) return leaf
18
18
  return (
19
19
  <>
20
- <span className="text-fg-32">{parts.join('/')}/</span>
20
+ {/* 64, not 32 (user ruling 2026-08-01). The namespace dims — it does not
21
+ * disappear. At 32 the prefix dropped so far under the leaf that a
22
+ * nested tag read as a one-word tag with dirt in front of it. */}
23
+ <span className="text-fg-64">{parts.join('/')}/</span>
21
24
  {leaf}
22
25
  </>
23
26
  )