@kolkrabbi/kol-workshop 0.11.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 +1 -1
- package/src/compositions/WorkshopSidebar.jsx +5 -5
- package/src/docs/DocsFrontmatter.jsx +62 -6
- package/src/docs/DocumentationReader.jsx +51 -124
- package/src/shell/RailSection.jsx +7 -2
- package/src/shell/RightRail.jsx +168 -0
- package/src/shell/ShellLayout.jsx +8 -3
- package/src/shell/ShellSidebar.jsx +15 -5
- package/src/shell/index.js +4 -0
- package/src/tags/TagPath.jsx +4 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-workshop",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.1",
|
|
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.
|
|
18
|
-
"@kolkrabbi/kol-
|
|
19
|
-
"@kolkrabbi/kol-
|
|
20
|
-
"@kolkrabbi/kol-theme": "0.
|
|
17
|
+
"@kolkrabbi/kol-component": "0.24.0",
|
|
18
|
+
"@kolkrabbi/kol-icons": "0.10.0",
|
|
19
|
+
"@kolkrabbi/kol-framework": "0.13.0",
|
|
20
|
+
"@kolkrabbi/kol-theme": "0.30.1"
|
|
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="
|
|
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="
|
|
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="
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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="
|
|
137
|
+
<div className="shell-rail-stack">
|
|
138
138
|
<ShellSidebar
|
|
139
139
|
routes={workshopRoutes}
|
|
140
140
|
basePath={basePath}
|
|
@@ -77,6 +77,19 @@ const FIELD_LABELS = {
|
|
|
77
77
|
modified: 'Modified',
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
/* THE FALLBACK IS A LABEL TOO (user ruling 2026-08-01). `FIELD_LABELS[key] ??
|
|
81
|
+
* key` printed an unnamed key raw, so a doc carrying `imported_from` before it
|
|
82
|
+
* was listed rendered the row as `imported_from` — snake_case and lowercase, in
|
|
83
|
+
* a column where every neighbour is sentence case. *"its very amateur that you
|
|
84
|
+
* cant start a sentence or word with a Capital?"*
|
|
85
|
+
*
|
|
86
|
+
* Casing is authored at the data layer, never by CSS (the no-auto-text-transform
|
|
87
|
+
* law) — so the fallback authors it here, where the string is made. */
|
|
88
|
+
const humanise = (key) => {
|
|
89
|
+
const words = String(key).replace(/[-_]+/g, ' ').trim()
|
|
90
|
+
return words.charAt(0).toUpperCase() + words.slice(1)
|
|
91
|
+
}
|
|
92
|
+
|
|
80
93
|
/* Reading ORDER, not an allowlist. The kol-docs contract first
|
|
81
94
|
* (`.kol/docs-framework/01-conventions.md`: required title/type/status/updated/
|
|
82
95
|
* tags, then recommended description/aliases, then optional), legacy
|
|
@@ -92,13 +105,33 @@ const FIELD_LABELS = {
|
|
|
92
105
|
* `related` is the one deliberate omission — the rail renders it as live links,
|
|
93
106
|
* so printing the raw wikilinks here would be the same thing twice. */
|
|
94
107
|
const FIELD_ORDER = [
|
|
95
|
-
|
|
108
|
+
/* `created` sits ABOVE `updated` (user ruling 2026-08-01) — a document's age
|
|
109
|
+
* reads oldest-first, and `updated` on its own says nothing about how long
|
|
110
|
+
* the thing has existed. */
|
|
111
|
+
'title', 'type', 'status', 'created', 'updated', 'tags',
|
|
96
112
|
'description', 'aliases', 'sources',
|
|
97
|
-
'
|
|
113
|
+
'verified', 'audience', 'superseded_by', 'drift',
|
|
98
114
|
'category', 'date', 'modified', 'version',
|
|
99
115
|
]
|
|
100
116
|
|
|
101
|
-
|
|
117
|
+
/* `related` — the rail renders it as live links; printing the raw wikilinks
|
|
118
|
+
* here would be the same thing twice.
|
|
119
|
+
*
|
|
120
|
+
* `aliases` — user ruling 2026-08-01: *"why is this a category in frontmatter?
|
|
121
|
+
* what purpose … its just not relevant"*. It is an OBSIDIAN field, not a tag
|
|
122
|
+
* and not a document fact: `.kol/docs-framework/01-conventions.md:65` says it
|
|
123
|
+
* feeds the Quick Switcher and search autocomplete and explicitly does **not**
|
|
124
|
+
* affect wikilink resolution. Nothing in this app reads it. It stays in the
|
|
125
|
+
* file for Obsidian and stops taking a row in a panel that renders facts. */
|
|
126
|
+
const HIDDEN = new Set(['related', 'aliases'])
|
|
127
|
+
|
|
128
|
+
/* VALUES ARE SENTENCE CASE TOO (user ruling 2026-08-01). The labels were fixed
|
|
129
|
+
* and the values were not, so the panel read `Type reference` / `Status
|
|
130
|
+
* active` — a capitalised label pointing at a lowercase word. These two fields
|
|
131
|
+
* carry closed enums from the kol-docs contract, so the case is authored here
|
|
132
|
+
* against the key, never with a text-transform. Every other value is content
|
|
133
|
+
* and is printed exactly as written. */
|
|
134
|
+
const CASED_VALUE_FIELDS = new Set(['type', 'status'])
|
|
102
135
|
|
|
103
136
|
/** Contract order first, then anything else the doc carries, alphabetically. */
|
|
104
137
|
const orderFields = (metadata) => {
|
|
@@ -141,9 +174,9 @@ const DocsFrontmatter = ({ metadata, docId }) => {
|
|
|
141
174
|
<div key={key} className="docs-frontmatter-row">
|
|
142
175
|
<span className="docs-frontmatter-key kol-helper-12 text-meta">
|
|
143
176
|
{icon && <Icon name={icon} size={14} />}
|
|
144
|
-
{FIELD_LABELS[key] ?? key}
|
|
177
|
+
{FIELD_LABELS[key] ?? humanise(key)}
|
|
145
178
|
</span>
|
|
146
|
-
<span className="kol-mono-12 text-strong">
|
|
179
|
+
<span className="docs-frontmatter-value kol-mono-12 text-strong">
|
|
147
180
|
{key === 'tags' && Array.isArray(value) ? (
|
|
148
181
|
/* `naked`, the SAME Tag rendering the rail's own tag list uses
|
|
149
182
|
* (DocReaderSidebar). This was the default filled variant, so
|
|
@@ -168,8 +201,31 @@ const DocsFrontmatter = ({ metadata, docId }) => {
|
|
|
168
201
|
</span>
|
|
169
202
|
) : DATE_FIELDS.has(key) ? (
|
|
170
203
|
formatDate(String(value))
|
|
204
|
+
) : CASED_VALUE_FIELDS.has(key) ? (
|
|
205
|
+
humanise(String(value))
|
|
171
206
|
) : Array.isArray(value) ? (
|
|
172
|
-
|
|
207
|
+
/* STACKED, ONE PER LINE (user ruling 2026-08-01). Arrays were
|
|
208
|
+
* `value.join(' · ')` — a single run-on string, so `sources`
|
|
209
|
+
* with three file paths ran straight off the right edge of the
|
|
210
|
+
* panel and the reader could not tell where one path ended and
|
|
211
|
+
* the next began. A list of paths IS a list; the dot-join was
|
|
212
|
+
* printing it as a sentence.
|
|
213
|
+
*
|
|
214
|
+
* `break-all` because these are paths, not words: a wrapped
|
|
215
|
+
* path must break at the character, never leave a gap and drop
|
|
216
|
+
* the whole segment to the next line.
|
|
217
|
+
*
|
|
218
|
+
* THE LIST SCROLLS PAST ITS CAP (user question 2026-08-01:
|
|
219
|
+
* "I just wonder what it will be when the sources are f.e. more
|
|
220
|
+
* then 15?"). Unbounded, fifteen paths push the article fifteen
|
|
221
|
+
* lines down and the metadata panel becomes the page. No
|
|
222
|
+
* file-type icons — a path already ends in its own extension,
|
|
223
|
+
* and a glyph restating it is decoration. */
|
|
224
|
+
<span className="docs-frontmatter-list">
|
|
225
|
+
{value.map((item, i) => (
|
|
226
|
+
<span key={i} className="break-all">{String(item)}</span>
|
|
227
|
+
))}
|
|
228
|
+
</span>
|
|
173
229
|
) : (
|
|
174
230
|
String(value)
|
|
175
231
|
)}
|
|
@@ -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,
|
|
4
|
-
import { ShellTocContext,
|
|
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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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="
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
-
</
|
|
123
|
+
</nav>
|
|
114
124
|
</RailSection>
|
|
115
125
|
</div>
|
|
116
126
|
)
|
package/src/shell/index.js
CHANGED
|
@@ -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'
|
package/src/tags/TagPath.jsx
CHANGED
|
@@ -17,7 +17,10 @@ export default function TagPath({ tag }) {
|
|
|
17
17
|
if (!parts.length) return leaf
|
|
18
18
|
return (
|
|
19
19
|
<>
|
|
20
|
-
|
|
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
|
)
|