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