@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.
@@ -1,152 +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 } 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 { parseDocsMarkdown, isIndexFile, getTagColor } from '../engine'
6
+ import TagPath from '../tags/TagPath.jsx'
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. They collapsed on click with no chevron, no count and
20
- * no hover, so the only way to discover they were interactive was to click
21
- * something that looked inert and watch the panel vanish. An affordance that
22
- * only announces itself after you've used it isn't one.
23
- */
24
- const SidebarSection = ({ sectionKey, label, count, collapsedSections, toggleSection, children }) => {
25
- const isOpen = !collapsedSections[sectionKey]
26
- return (
27
- <div>
28
- <button
29
- type="button"
30
- className="shell-nav-group-header w-full text-left shell-sidebar-label kol-doc-eyebrow"
31
- onClick={() => toggleSection(sectionKey)}
32
- aria-expanded={isOpen}
33
- >
34
- <span className="flex items-center gap-2">
35
- <Icon
36
- name="chevron-right"
37
- size={12}
38
- className={`transition-transform ${isOpen ? 'rotate-90' : ''}`}
39
- />
40
- {label}
41
- </span>
42
- {count > 0 && <span className="kol-mono-14 text-subtle">({count})</span>}
43
- </button>
44
- {isOpen && children}
45
- </div>
46
- )
47
- }
48
-
49
- 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 = [] }) => {
50
23
  const navigate = useNavigate()
51
24
  const { openTagMode } = useTagMode()
52
- const [collapsedSections, setCollapsedSections] = useState({})
53
- const toggleSection = (key) => setCollapsedSections(prev => ({ ...prev, [key]: !prev[key] }))
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' })
54
29
 
55
- return (
56
- <div className="space-y-6">
57
- <SidebarSection
58
- sectionKey="toc"
59
- label="On this page"
60
- count={toc?.length}
61
- collapsedSections={collapsedSections}
62
- toggleSection={toggleSection}
63
- >
64
- <DocsToc toc={toc} />
65
- </SidebarSection>
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
+ ]
66
37
 
67
- {related.length > 0 && (
68
- <SidebarSection
69
- sectionKey="related"
70
- label="Related"
71
- count={related.length}
72
- collapsedSections={collapsedSections}
73
- toggleSection={toggleSection}
74
- >
75
- {/* ONE rail voice (user law): rows = the shell-nav-item idiom. */}
76
- <nav className="flex flex-col">
77
- {related.map((r) => (
78
- <Link key={r.id} to={r.href} className="shell-nav-item block kol-mono-14 text-body transition-colors hover:text-emphasis">
79
- {r.label}
80
- </Link>
81
- ))}
82
- </nav>
83
- </SidebarSection>
84
- )}
85
-
86
- <SidebarSection
87
- sectionKey="actions"
88
- label="Quick actions"
89
- collapsedSections={collapsedSections}
90
- toggleSection={toggleSection}
91
- >
92
- {/* Quick actions wear the SAME row as On-this-page and Related. They
93
- * were `.shell-sidebar-action`, a third geometry with no left indent,
94
- * so this rail carried three different row idioms — under the
95
- * "ONE rail voice" comment above. */}
96
- <div>
97
- <button
98
- type="button"
99
- className="shell-nav-item flex items-center gap-2 kol-mono-14 text-body transition-colors hover:text-emphasis"
100
- onClick={() => navigate(-1)}
101
- >
102
- <Icon name="arrow-left" size={14} />
103
- Back
104
- </button>
105
- <Link to={docsIndexHref} className="shell-nav-item flex items-center gap-2 kol-mono-14 text-body transition-colors hover:text-emphasis">
106
- <Icon name="book-open" size={14} />
107
- All documentation
108
- </Link>
109
- <Link to={componentsHref} className="shell-nav-item flex items-center gap-2 kol-mono-14 text-body transition-colors hover:text-emphasis">
110
- <Icon name="grid" size={14} />
111
- View components
112
- </Link>
113
- <button
114
- type="button"
115
- className="shell-nav-item flex items-center gap-2 kol-mono-14 text-body transition-colors hover:text-emphasis"
116
- onClick={() => navigator.clipboard.writeText(docFilePath(docId))}
117
- title="Copy file path to clipboard"
118
- >
119
- <Icon name="copy" size={14} />
120
- Copy path
121
- </button>
122
- </div>
123
- </SidebarSection>
124
-
125
- {allTags.length > 0 && (
126
- <SidebarSection
127
- sectionKey="tags"
128
- count={allTags?.length}
129
- label="Tags"
130
- collapsedSections={collapsedSections}
131
- toggleSection={toggleSection}
132
- >
133
- <div className="flex flex-col gap-1.5 items-start min-w-0 w-full">
134
- {allTags.map((tag) => (
135
- <Tag
136
- key={tag}
137
- variant="naked"
138
- size="lg"
139
- color={getTagColor(tag)}
140
- onClick={() => openTagMode(tag)}
141
- className="max-w-full overflow-hidden text-ellipsis"
142
- >
143
- {tag}
144
- </Tag>
145
- ))}
146
- </div>
147
- </SidebarSection>
148
- )}
149
- </div>
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
+ />
150
50
  )
151
51
  }
152
52
 
@@ -344,6 +244,11 @@ const DocumentationReader = ({
344
244
  }, [rawMarkdown])
345
245
 
346
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
+
347
252
  const allTags = useMemo(() => {
348
253
  const frontmatterTags = doc?.metadata?.tags || []
349
254
  return [...new Set([...frontmatterTags, ...inlineTags])]
@@ -379,7 +284,15 @@ const DocumentationReader = ({
379
284
  inventory.find((d) => d.file.replace(/\.md$/, '') === full) ??
380
285
  inventory.find((d) => d.file.replace(/\.md$/, '').toLowerCase() === `${full}/index`.toLowerCase()) ??
381
286
  inventory.find((d) => d.id === basename)
382
- 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
383
296
  })
384
297
  .filter(Boolean)
385
298
  }, [doc, inventory, docHref])
@@ -394,6 +307,7 @@ const DocumentationReader = ({
394
307
  key={docId}
395
308
  toc={toc}
396
309
  allTags={allTags}
310
+ topTags={topTags}
397
311
  related={related}
398
312
  docId={docId}
399
313
  docsIndexHref={docsIndex}
@@ -402,13 +316,15 @@ const DocumentationReader = ({
402
316
  />
403
317
  )
404
318
  return () => setTocContent(null)
405
- }, [setTocContent, docId, toc, allTags, related, docsIndex, components, docFilePath])
319
+ }, [setTocContent, docId, toc, allTags, topTags, related, docsIndex, components, docFilePath])
406
320
 
407
321
  if (!doc) {
408
322
  return (
409
- /* framed to a 1400px tier that was removed from the scale at theme
410
- * 0.11.22 and left behind here — a width nothing else still uses */
411
- <div className="mx-auto max-w-[var(--kol-content-panel)] py-16">
323
+ /* framed to a tier that was removed from the scale at theme 0.11.22 and
324
+ * left behind here — a width nothing else still uses; now on the panel
325
+ * token. Left-anchored, not centred (2026-08-01): the frame centres in
326
+ * the viewport, the content does not centre in the frame. */
327
+ <div className="max-w-[var(--kol-content-panel)] py-16">
412
328
  <DocsHeader title="Document Not Found" subtitle={`Could not find document: ${docId}`} />
413
329
  <p className="kol-mono-12 mt-6">
414
330
  <Link to={docsIndex} className="text-accent-primary">
@@ -9,6 +9,28 @@
9
9
  export const capitalise = (value) =>
10
10
  value ? value.charAt(0).toUpperCase() + value.slice(1) : value
11
11
 
12
+ /**
13
+ * fileLabel — a rail row's name, taken from the FILENAME (user ruling
14
+ * 2026-08-01).
15
+ *
16
+ * The tree used to label rows from the doc's H1, so `01-tokens.md` (opening
17
+ * `# Foundations — the token system`) rendered as "Foundations" — a row whose
18
+ * name appears nowhere in the folder, and two files whose H1s both began
19
+ * "Foundations" collapsed into two identical rows. A tree that mirrors a
20
+ * folder has to read the folder: what Finder shows is what the sidebar shows.
21
+ *
22
+ * 01-tokens.md → Tokens
23
+ * 04-layout-breakpoints.md → Layout breakpoints
24
+ *
25
+ * Titles still drive SEARCH and the page header; this is the tree label only.
26
+ */
27
+ export const fileLabel = (file = '') => {
28
+ const base = file.split('/').pop()?.replace(/\.md$/i, '') ?? ''
29
+ const named = base.replace(/^\d+(\.\d+)*[-.]?\s*/, '')
30
+ if (!named) return capitalise(base)
31
+ return capitalise(named.replace(/[-_]+/g, ' ').trim())
32
+ }
33
+
12
34
  // Index.md files (e.g. "00-metadata-index", "foundry-index") have no version dots
13
35
  export const isIndexFile = (id) => id.endsWith('-index') && !id.includes('.')
14
36
 
@@ -60,6 +82,15 @@ export const cleanTitle = (title, id) => {
60
82
 
61
83
  let cleaned = title.replace(/^\d+\.\d+\.\d+\s*/, '')
62
84
  cleaned = cleaned.replace(/^[A-Za-z\s]+:\s*/, '')
85
+ /* SUBTITLE OFF (user ruling 2026-08-01): "dont Foundtation - the token
86
+ * system, just 'foundation'". A doc's `title` is written for the document —
87
+ * "Type classes — the two families and when to use which" — and a rail row
88
+ * is not the place to read a sentence: three of eight rows wrapped to two
89
+ * and three lines. Everything from the em/en dash on is the subtitle, so the
90
+ * row keeps the name and drops the explanation. The DOC's own title is
91
+ * untouched; this is the rail's label only.
92
+ * Spaced dash only — an unspaced one is a compound word, not a separator. */
93
+ cleaned = cleaned.replace(/\s+[—–]\s+.*$/, '')
63
94
  cleaned = cleaned.trim() || title
64
95
 
65
96
  const versionMatch = id.match(/^(\d+\.\d+)\.(\d+)/)
@@ -2,7 +2,7 @@
2
2
  * The pure docs engine — zero dependencies, no React, no Vite.
3
3
  * Everything here is a plain function over strings/data.
4
4
  */
5
- export { parseDocsMarkdown, extractHashtags } from './parse-markdown.js'
5
+ export { parseDocsMarkdown, extractHashtags, processInlineMarkdown } from './parse-markdown.js'
6
6
  export { parseFrontmatter } from './frontmatter.js'
7
7
  export { buildInventory, buildInventoryCounts } from './build-inventory.js'
8
8
  export { matchSearchItems } from './search.js'
@@ -15,6 +15,7 @@ export {
15
15
  subsectionPrefixes,
16
16
  categoryLabels,
17
17
  cleanTitle,
18
+ fileLabel,
18
19
  getTagColor,
19
20
  groupDocsByMajor,
20
21
  } from './doc-helpers.js'
@@ -21,7 +21,11 @@ export const extractHashtags = (content) => {
21
21
  * Supports: bold, italic, inline code, links, images, color swatches, hashtags.
22
22
  * Returns a token array; the render layer turns tokens into elements.
23
23
  */
24
- const processInlineMarkdown = (text) => {
24
+ /* EXPORTED 2026-08-01. The props table needs the token producer so its cells
25
+ * can render `backticked` code as a chip instead of printing the backticks.
26
+ * It was module-private, which is why the table had no way to do it and the
27
+ * gap read as "the table is bad" rather than "the parser was unreachable". */
28
+ export const processInlineMarkdown = (text) => {
25
29
  if (!text) return []
26
30
 
27
31
  const tokens = []
@@ -0,0 +1,85 @@
1
+ import { NavLink } from 'react-router-dom'
2
+
3
+ /**
4
+ * RailRow — THE L3 rail row. The ladder's bottom rung, owned like the other two.
5
+ *
6
+ * WHY IT EXISTS (user ruling 2026-08-01). `.shell-nav-item` was a shared class
7
+ * name and nothing more: every call site hand-wrote its own utility stack
8
+ * around it, so ONE row idiom shipped as NINE different strings across five
9
+ * files —
10
+ *
11
+ * left tree `shell-nav-item kol-mono-14 ${active ? … }`
12
+ * toc rows `shell-nav-item block kol-mono-14 text-body transition-colors
13
+ * focus-visible:ring-focus hover:text-emphasis`
14
+ * related rows the same minus the focus ring
15
+ * quick actions `shell-nav-item flex items-center gap-2 …`
16
+ *
17
+ * — and their containers disagreed too (`space-y-0` against `space-y-4` for the
18
+ * same list). R1/R2 lock the TYPE class; nothing locked the stack. That is the
19
+ * eyebrow-box failure exactly, one rung further down, which is why the rails
20
+ * kept drifting after RailSection fixed L1 and L2.
21
+ *
22
+ * The look now lives in `.shell-nav-item` (layout, colour, hover, focus,
23
+ * active); this component owns the markup. `validate:rails` R4 fails a
24
+ * hand-written `shell-nav-item` anywhere but here.
25
+ *
26
+ * ACTIVE IS BOTH RAILS' (user ruling 2026-08-01). The left tree highlighted the
27
+ * current page and the right rail highlighted nothing — same rung, one state.
28
+ * `NavLink` drives it for routes; `active` drives it for hash links and
29
+ * buttons, which react-router cannot resolve.
30
+ *
31
+ * @param {string} [to] route → NavLink, active resolved by the router
32
+ * @param {string} [href] plain anchor (hash links: on-this-page)
33
+ * @param {Function} [onClick] no `to`/`href` → a <button>
34
+ * @param {boolean} [active] forced active state (hash links, buttons)
35
+ * @param {node} [icon] leading glyph
36
+ * @param {node} [trailing] pushed right — a count, a doc number
37
+ * @param {boolean} [sub] nested depth (h3 under h2)
38
+ */
39
+ export default function RailRow({
40
+ to,
41
+ href,
42
+ onClick,
43
+ active = false,
44
+ icon,
45
+ trailing,
46
+ sub = false,
47
+ onNavigate,
48
+ children,
49
+ className = '',
50
+ }) {
51
+ /* ONE string. It is assembled here and nowhere else — that is the entire
52
+ * point of the component. `kol-mono-14` is the rail's row ramp (R1). */
53
+ const cls = (isActive) =>
54
+ ['shell-nav-item', 'kol-mono-14', sub ? 'shell-nav-item-sub' : '', isActive ? 'is-active' : '', className]
55
+ .filter(Boolean)
56
+ .join(' ')
57
+
58
+ const inner = (
59
+ <>
60
+ {icon}
61
+ <span className="truncate">{children}</span>
62
+ {trailing != null && <span className="shell-nav-item-trailing kol-mono-14">{trailing}</span>}
63
+ </>
64
+ )
65
+
66
+ if (to) {
67
+ return (
68
+ <NavLink to={to} end className={({ isActive }) => cls(isActive || active)} onClick={onNavigate}>
69
+ {inner}
70
+ </NavLink>
71
+ )
72
+ }
73
+ if (href) {
74
+ return (
75
+ <a href={href} className={cls(active)} onClick={onNavigate}>
76
+ {inner}
77
+ </a>
78
+ )
79
+ }
80
+ return (
81
+ <button type="button" className={cls(active)} onClick={onClick}>
82
+ {inner}
83
+ </button>
84
+ )
85
+ }
@@ -0,0 +1,183 @@
1
+ import { useState } from 'react'
2
+ import { Link } from 'react-router-dom'
3
+
4
+ /**
5
+ * RailSection — THE rail ladder. One component owns every rail header, at
6
+ * every rung, in both rails.
7
+ *
8
+ * WHY IT EXISTS (user ruling 2026-08-01). The rails already had a class per
9
+ * rung — `.shell-sidebar-toggle` (L1), `.shell-nav-group-header` (L2),
10
+ * `.shell-nav-item` (L3) — and drifted anyway, because a class is vocabulary
11
+ * and not grammar. Nothing declared the three a LADDER, and every affordance
12
+ * that hangs off a rung (the count, the collapse, the chevron) was hand-typed
13
+ * at each call site. So the right rail put its count on L1 and the left rail
14
+ * put its count on L2, and the user had to catch it in a screenshot.
15
+ *
16
+ * L3 is the proof of the fix: it is the only rung a component already owned
17
+ * (`DocsToc`), and it is the only rung that never drifted.
18
+ *
19
+ * THE LADDER
20
+ *
21
+ * L1 section `.shell-sidebar-toggle` + kol-doc-eyebrow quiet, no chevron
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.
28
+ * L3 row `.shell-nav-item` + kol-mono-14 leaf, DocsToc/links
29
+ *
30
+ * THE RULES THIS COMPONENT ENFORCES BY CONSTRUCTION
31
+ *
32
+ * - A rung's class is chosen here, never at the call site. Same rung in
33
+ * either rail is literally the same string.
34
+ * - The COUNT is placed by the rung, not by the caller. You cannot put it in
35
+ * the wrong place because you never write the span.
36
+ * - No chevron at L1 (user ruling 2026-08-01) — the section still collapses
37
+ * and expands on click, the glyph is simply not drawn. L2 keeps its
38
+ * chevron: it is the rung where a caret means "there is a subtree here".
39
+ * - The box (padding + margin) belongs to the rung class alone. Nothing here
40
+ * sets y-spacing inline; see the eyebrow-box law in kol-components-workshop.css.
41
+ *
42
+ * `pnpm validate:rails` R4 asserts that rail headers come from here.
43
+ *
44
+ * @param {1|2} level which rung. L3 rows are links, not sections.
45
+ * @param {string} label header text, rendered verbatim (casing is authored)
46
+ * @param {number} [count] leaf rows this section contains — L1 counts the
47
+ * whole subtree, L2 counts its own children
48
+ * @param {string} [to] make the LABEL a link; the rest of the row still toggles
49
+ * @param {boolean} [collapsible=true] false → a static header, no toggle, no aria
50
+ * @param {boolean} [collapsed] controlled state; omit for uncontrolled
51
+ * @param {Function} [onToggle] required when `collapsed` is passed
52
+ * @param {boolean} [defaultCollapsed=false] uncontrolled initial state
53
+ * @param {Function} [onNavigate] click hook on the label link (close a drawer, etc.)
54
+ * @param {Icon} [icon] the chevron renderer, injected — the shell package
55
+ * must not reach into kol-component from here
56
+ */
57
+ export default function RailSection({
58
+ level = 1,
59
+ label,
60
+ count,
61
+ to,
62
+ collapsible = true,
63
+ collapsed,
64
+ onToggle,
65
+ defaultCollapsed = false,
66
+ onNavigate,
67
+ icon: IconComponent,
68
+ children,
69
+ }) {
70
+ const [internal, setInternal] = useState(defaultCollapsed)
71
+ const isControlled = collapsed !== undefined
72
+ const isCollapsed = isControlled ? collapsed : internal
73
+ const toggle = isControlled ? onToggle : () => setInternal((c) => !c)
74
+
75
+ /* The rung decides the class. This is the single line that used to live at
76
+ * four call sites in two rails, spelled differently at each. */
77
+ const headerClass = level === 1
78
+ ? 'shell-sidebar-toggle kol-doc-eyebrow'
79
+ : 'shell-nav-group-header kol-mono-14'
80
+
81
+ /* A static header is not a button and must not claim to be one. It still
82
+ * wears the same box — `.shell-sidebar-label` shares L1's rule. */
83
+ const staticClass = level === 1
84
+ ? 'shell-sidebar-label kol-doc-eyebrow'
85
+ : 'shell-nav-group-header kol-mono-14'
86
+
87
+ /* L2 only. At L1 the caret is not drawn (user ruling) — the row is still the
88
+ * toggle, so the affordance exists, it just isn't a glyph. */
89
+ const chevron = level === 2 && collapsible && IconComponent ? (
90
+ <IconComponent
91
+ name="chevron-right"
92
+ size={12}
93
+ className={`transition-transform ${isCollapsed ? '' : 'rotate-90'}`}
94
+ />
95
+ ) : null
96
+
97
+ /* THE COUNT IS AN L2 AFFORDANCE (user ruling 2026-08-01): "dont list counter
98
+ * next to eyebrow category, just inside". A category eyebrow names a body of
99
+ * material; the tally belongs to the groups inside it, not to the label over
100
+ * them. So L1 drops a `count` on the floor rather than rendering it — the
101
+ * rung refuses it, so no call site can put one back. */
102
+ const countNode = level === 2 && count !== undefined && count !== null
103
+ ? <span className="kol-mono-14 text-subtle">({count})</span>
104
+ : null
105
+
106
+ const labelNode = to ? (
107
+ /* The label navigates; the rest of the row toggles. stopPropagation keeps
108
+ * one click from doing both. */
109
+ <Link
110
+ to={to}
111
+ className={level === 1 ? 'kol-doc-eyebrow' : undefined}
112
+ onClick={(e) => {
113
+ e.stopPropagation()
114
+ if (isCollapsed && toggle) toggle()
115
+ if (onNavigate) onNavigate(e)
116
+ }}
117
+ >
118
+ {label}
119
+ </Link>
120
+ ) : (
121
+ <span>{label}</span>
122
+ )
123
+
124
+ const inner = (
125
+ <>
126
+ <span className="flex items-center gap-2">
127
+ {chevron}
128
+ {/* L2 rows without a caret keep the caret's width as space, so an
129
+ * expandable row and a childless one share a left edge. */}
130
+ {level === 2 && !chevron && <span aria-hidden="true" style={{ width: 12 }} />}
131
+ {labelNode}
132
+ </span>
133
+ {countNode}
134
+ </>
135
+ )
136
+
137
+ if (!collapsible) {
138
+ /* A rung with nothing to expand is not a section — it is a row that goes
139
+ * somewhere. It used to render as a header anyway: a chevron rotating over
140
+ * an empty body that never navigated, so "Icons" and "Components" did
141
+ * nothing when clicked. With `to` the WHOLE row is the link (the label
142
+ * alone would leave the rest of the row dead), and `inner` keeps the caret
143
+ * column as space so it shares a left edge with its expandable siblings. */
144
+ if (to) {
145
+ return (
146
+ <Link to={to} className={staticClass} onClick={onNavigate}>
147
+ <span className="flex items-center gap-2">
148
+ {level === 2 && <span aria-hidden="true" style={{ width: 12 }} />}
149
+ {label}
150
+ </span>
151
+ {countNode}
152
+ </Link>
153
+ )
154
+ }
155
+ return (
156
+ <div>
157
+ <div className={staticClass}>{inner}</div>
158
+ {children}
159
+ </div>
160
+ )
161
+ }
162
+
163
+ /* A div with role=button, not a <button>, because the label may be a Link —
164
+ * an anchor inside a button is invalid markup. The keyboard handler restores
165
+ * what the removed chevron button used to provide. */
166
+ return (
167
+ <div>
168
+ <div
169
+ className={headerClass}
170
+ role="button"
171
+ tabIndex={0}
172
+ aria-expanded={!isCollapsed}
173
+ onClick={toggle}
174
+ onKeyDown={(e) => {
175
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (toggle) toggle() }
176
+ }}
177
+ >
178
+ {inner}
179
+ </div>
180
+ {!isCollapsed && children}
181
+ </div>
182
+ )
183
+ }