@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.
- package/package.json +5 -5
- package/src/compositions/WorkshopDefaultSidebar.jsx +8 -32
- package/src/compositions/WorkshopSidebar.jsx +39 -64
- package/src/docs/DocKit.jsx +21 -4
- package/src/docs/DocsFrontmatter.jsx +147 -26
- package/src/docs/DocumentationReader.jsx +57 -141
- package/src/engine/doc-helpers.js +31 -0
- package/src/engine/index.js +2 -1
- package/src/engine/parse-markdown.js +5 -1
- package/src/shell/RailRow.jsx +85 -0
- package/src/shell/RailSection.jsx +183 -0
- package/src/shell/RightRail.jsx +168 -0
- package/src/shell/ShellLayout.jsx +117 -41
- package/src/shell/ShellSidebar.jsx +61 -106
- package/src/shell/index.js +11 -0
- package/src/tags/TagModeContext.jsx +52 -6
- package/src/tags/TagModeOverlay.jsx +69 -120
- package/src/tags/TagPath.jsx +27 -0
- package/src/tags/index.js +1 -0
|
@@ -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,
|
|
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
|
|
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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
56
|
-
<
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
|
410
|
-
*
|
|
411
|
-
|
|
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+)/)
|
package/src/engine/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|