@kolkrabbi/kol-workshop 0.6.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -5
- package/src/compositions/WorkshopDefaultSidebar.jsx +7 -31
- package/src/compositions/WorkshopSidebar.jsx +35 -60
- package/src/docs/DocKit.jsx +21 -4
- package/src/docs/DocsFrontmatter.jsx +86 -21
- package/src/docs/DocumentationReader.jsx +68 -79
- 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 +178 -0
- package/src/shell/ShellLayout.jsx +112 -41
- package/src/shell/ShellSidebar.jsx +50 -105
- package/src/shell/index.js +7 -0
- package/src/tags/TagModeContext.jsx +52 -6
- package/src/tags/TagModeOverlay.jsx +69 -120
- package/src/tags/TagPath.jsx +24 -0
- package/src/tags/index.js +1 -0
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,178 @@
|
|
|
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
|
+
* L3 row `.shell-nav-item` + kol-mono-14 leaf, DocsToc/links
|
|
24
|
+
*
|
|
25
|
+
* THE RULES THIS COMPONENT ENFORCES BY CONSTRUCTION
|
|
26
|
+
*
|
|
27
|
+
* - A rung's class is chosen here, never at the call site. Same rung in
|
|
28
|
+
* either rail is literally the same string.
|
|
29
|
+
* - The COUNT is placed by the rung, not by the caller. You cannot put it in
|
|
30
|
+
* the wrong place because you never write the span.
|
|
31
|
+
* - No chevron at L1 (user ruling 2026-08-01) — the section still collapses
|
|
32
|
+
* and expands on click, the glyph is simply not drawn. L2 keeps its
|
|
33
|
+
* chevron: it is the rung where a caret means "there is a subtree here".
|
|
34
|
+
* - The box (padding + margin) belongs to the rung class alone. Nothing here
|
|
35
|
+
* sets y-spacing inline; see the eyebrow-box law in kol-components-workshop.css.
|
|
36
|
+
*
|
|
37
|
+
* `pnpm validate:rails` R4 asserts that rail headers come from here.
|
|
38
|
+
*
|
|
39
|
+
* @param {1|2} level which rung. L3 rows are links, not sections.
|
|
40
|
+
* @param {string} label header text, rendered verbatim (casing is authored)
|
|
41
|
+
* @param {number} [count] leaf rows this section contains — L1 counts the
|
|
42
|
+
* whole subtree, L2 counts its own children
|
|
43
|
+
* @param {string} [to] make the LABEL a link; the rest of the row still toggles
|
|
44
|
+
* @param {boolean} [collapsible=true] false → a static header, no toggle, no aria
|
|
45
|
+
* @param {boolean} [collapsed] controlled state; omit for uncontrolled
|
|
46
|
+
* @param {Function} [onToggle] required when `collapsed` is passed
|
|
47
|
+
* @param {boolean} [defaultCollapsed=false] uncontrolled initial state
|
|
48
|
+
* @param {Function} [onNavigate] click hook on the label link (close a drawer, etc.)
|
|
49
|
+
* @param {Icon} [icon] the chevron renderer, injected — the shell package
|
|
50
|
+
* must not reach into kol-component from here
|
|
51
|
+
*/
|
|
52
|
+
export default function RailSection({
|
|
53
|
+
level = 1,
|
|
54
|
+
label,
|
|
55
|
+
count,
|
|
56
|
+
to,
|
|
57
|
+
collapsible = true,
|
|
58
|
+
collapsed,
|
|
59
|
+
onToggle,
|
|
60
|
+
defaultCollapsed = false,
|
|
61
|
+
onNavigate,
|
|
62
|
+
icon: IconComponent,
|
|
63
|
+
children,
|
|
64
|
+
}) {
|
|
65
|
+
const [internal, setInternal] = useState(defaultCollapsed)
|
|
66
|
+
const isControlled = collapsed !== undefined
|
|
67
|
+
const isCollapsed = isControlled ? collapsed : internal
|
|
68
|
+
const toggle = isControlled ? onToggle : () => setInternal((c) => !c)
|
|
69
|
+
|
|
70
|
+
/* The rung decides the class. This is the single line that used to live at
|
|
71
|
+
* four call sites in two rails, spelled differently at each. */
|
|
72
|
+
const headerClass = level === 1
|
|
73
|
+
? 'shell-sidebar-toggle kol-doc-eyebrow'
|
|
74
|
+
: 'shell-nav-group-header kol-mono-14 text-body'
|
|
75
|
+
|
|
76
|
+
/* A static header is not a button and must not claim to be one. It still
|
|
77
|
+
* wears the same box — `.shell-sidebar-label` shares L1's rule. */
|
|
78
|
+
const staticClass = level === 1
|
|
79
|
+
? 'shell-sidebar-label kol-doc-eyebrow'
|
|
80
|
+
: 'shell-nav-group-header kol-mono-14 text-body'
|
|
81
|
+
|
|
82
|
+
/* L2 only. At L1 the caret is not drawn (user ruling) — the row is still the
|
|
83
|
+
* toggle, so the affordance exists, it just isn't a glyph. */
|
|
84
|
+
const chevron = level === 2 && collapsible && IconComponent ? (
|
|
85
|
+
<IconComponent
|
|
86
|
+
name="chevron-right"
|
|
87
|
+
size={12}
|
|
88
|
+
className={`transition-transform ${isCollapsed ? '' : 'rotate-90'}`}
|
|
89
|
+
/>
|
|
90
|
+
) : null
|
|
91
|
+
|
|
92
|
+
/* THE COUNT IS AN L2 AFFORDANCE (user ruling 2026-08-01): "dont list counter
|
|
93
|
+
* next to eyebrow category, just inside". A category eyebrow names a body of
|
|
94
|
+
* material; the tally belongs to the groups inside it, not to the label over
|
|
95
|
+
* them. So L1 drops a `count` on the floor rather than rendering it — the
|
|
96
|
+
* rung refuses it, so no call site can put one back. */
|
|
97
|
+
const countNode = level === 2 && count !== undefined && count !== null
|
|
98
|
+
? <span className="kol-mono-14 text-subtle">({count})</span>
|
|
99
|
+
: null
|
|
100
|
+
|
|
101
|
+
const labelNode = to ? (
|
|
102
|
+
/* The label navigates; the rest of the row toggles. stopPropagation keeps
|
|
103
|
+
* one click from doing both. */
|
|
104
|
+
<Link
|
|
105
|
+
to={to}
|
|
106
|
+
className={level === 1 ? 'kol-doc-eyebrow' : undefined}
|
|
107
|
+
onClick={(e) => {
|
|
108
|
+
e.stopPropagation()
|
|
109
|
+
if (isCollapsed && toggle) toggle()
|
|
110
|
+
if (onNavigate) onNavigate(e)
|
|
111
|
+
}}
|
|
112
|
+
>
|
|
113
|
+
{label}
|
|
114
|
+
</Link>
|
|
115
|
+
) : (
|
|
116
|
+
<span>{label}</span>
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
const inner = (
|
|
120
|
+
<>
|
|
121
|
+
<span className="flex items-center gap-2">
|
|
122
|
+
{chevron}
|
|
123
|
+
{/* L2 rows without a caret keep the caret's width as space, so an
|
|
124
|
+
* expandable row and a childless one share a left edge. */}
|
|
125
|
+
{level === 2 && !chevron && <span aria-hidden="true" style={{ width: 12 }} />}
|
|
126
|
+
{labelNode}
|
|
127
|
+
</span>
|
|
128
|
+
{countNode}
|
|
129
|
+
</>
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
if (!collapsible) {
|
|
133
|
+
/* A rung with nothing to expand is not a section — it is a row that goes
|
|
134
|
+
* somewhere. It used to render as a header anyway: a chevron rotating over
|
|
135
|
+
* an empty body that never navigated, so "Icons" and "Components" did
|
|
136
|
+
* nothing when clicked. With `to` the WHOLE row is the link (the label
|
|
137
|
+
* alone would leave the rest of the row dead), and `inner` keeps the caret
|
|
138
|
+
* column as space so it shares a left edge with its expandable siblings. */
|
|
139
|
+
if (to) {
|
|
140
|
+
return (
|
|
141
|
+
<Link to={to} className={staticClass} onClick={onNavigate}>
|
|
142
|
+
<span className="flex items-center gap-2">
|
|
143
|
+
{level === 2 && <span aria-hidden="true" style={{ width: 12 }} />}
|
|
144
|
+
{label}
|
|
145
|
+
</span>
|
|
146
|
+
{countNode}
|
|
147
|
+
</Link>
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
return (
|
|
151
|
+
<div>
|
|
152
|
+
<div className={staticClass}>{inner}</div>
|
|
153
|
+
{children}
|
|
154
|
+
</div>
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/* A div with role=button, not a <button>, because the label may be a Link —
|
|
159
|
+
* an anchor inside a button is invalid markup. The keyboard handler restores
|
|
160
|
+
* what the removed chevron button used to provide. */
|
|
161
|
+
return (
|
|
162
|
+
<div>
|
|
163
|
+
<div
|
|
164
|
+
className={headerClass}
|
|
165
|
+
role="button"
|
|
166
|
+
tabIndex={0}
|
|
167
|
+
aria-expanded={!isCollapsed}
|
|
168
|
+
onClick={toggle}
|
|
169
|
+
onKeyDown={(e) => {
|
|
170
|
+
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (toggle) toggle() }
|
|
171
|
+
}}
|
|
172
|
+
>
|
|
173
|
+
{inner}
|
|
174
|
+
</div>
|
|
175
|
+
{!isCollapsed && children}
|
|
176
|
+
</div>
|
|
177
|
+
)
|
|
178
|
+
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { createContext, useState, useEffect, Suspense } from 'react'
|
|
2
2
|
import { Outlet, Link, useLocation, useNavigate } from 'react-router-dom'
|
|
3
|
-
import { ShellHeader } from '@kolkrabbi/kol-framework'
|
|
3
|
+
import { ShellHeader, HEADER_ICON } from '@kolkrabbi/kol-framework'
|
|
4
4
|
import ShellSidebar from './ShellSidebar.jsx'
|
|
5
5
|
import { Button, 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,7 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
|
|
|
176
222
|
<>
|
|
177
223
|
{actions}
|
|
178
224
|
<Tooltip label="Search">
|
|
179
|
-
<Button variant="ghost" quiet iconOnly="search" iconSize={
|
|
225
|
+
<Button variant="ghost" quiet iconOnly="search" iconSize={HEADER_ICON} onClick={() => setIsSearchOpen(true)} aria-label="Search" />
|
|
180
226
|
</Tooltip>
|
|
181
227
|
</>
|
|
182
228
|
)
|
|
@@ -237,10 +283,19 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
|
|
|
237
283
|
* Tailwind's 16/20/24 steps instead of the ramp's 20/32/48 — so
|
|
238
284
|
* every consumer page inherited the raw viewport (measured 2152px
|
|
239
285
|
* of frame at a 2200px window against an 1800px law). Every
|
|
240
|
-
* page-level width fix is downstream of this line.
|
|
286
|
+
* page-level width fix is downstream of this line.
|
|
287
|
+
*
|
|
288
|
+
* CORRECTED 2026-07-31: that fix capped THIS element, which holds
|
|
289
|
+
* all three columns — so the whole chrome centred and both rails
|
|
290
|
+
* were dragged inward off the viewport edge. The chrome frame
|
|
291
|
+
* takes the full available width and the rails justify to it; the
|
|
292
|
+
* cap moved one level down onto MainColumn's content, which is
|
|
293
|
+
* what the theme's law is written about. Same disease as
|
|
294
|
+
* --kol-container-max stopping at 1600 under an 1800 law: one
|
|
295
|
+
* element, two answers. */}
|
|
241
296
|
<div
|
|
242
|
-
className="
|
|
243
|
-
style={{ paddingInline: 'var(--kol-pad-
|
|
297
|
+
className="h-full w-full"
|
|
298
|
+
style={{ paddingInline: 'var(--kol-pad-chrome-x)' }}
|
|
244
299
|
>
|
|
245
300
|
{/* gap lives in .shell-content-grid (theme) — a gap-8 utility here
|
|
246
301
|
* outranks the layered theme rule at every width and killed the
|
|
@@ -261,6 +316,10 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
|
|
|
261
316
|
</div>
|
|
262
317
|
</MainColumn>
|
|
263
318
|
|
|
319
|
+
{/* Mounted on showToc alone — NOT on whether there is content
|
|
320
|
+
* to put in it. An empty TocColumn is the point: it holds
|
|
321
|
+
* the grid's third track so main keeps one width across
|
|
322
|
+
* every route (user ruling 2026-08-01). */}
|
|
264
323
|
{showToc && (
|
|
265
324
|
<TocColumn>
|
|
266
325
|
{effectiveTocContent}
|
|
@@ -292,20 +351,32 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
|
|
|
292
351
|
query={searchQuery}
|
|
293
352
|
onQueryChange={setSearchQuery}
|
|
294
353
|
results={searchResults}
|
|
354
|
+
/* THE EXPANDED BODY (user ruling 2026-08-01). The tag browser is
|
|
355
|
+
* not a sibling overlay — it is this palette's second state. Enter
|
|
356
|
+
* commits the query and swaps the result rows for the full body;
|
|
357
|
+
* committed tags ride along as chips on the same query. */
|
|
358
|
+
expanded={tagMode.isProvided && tagMode.expanded}
|
|
359
|
+
onExpand={() => tagMode.setExpanded?.(true)}
|
|
360
|
+
chips={tagMode.isProvided ? tagMode.activeTags : []}
|
|
361
|
+
onRemoveChip={tagMode.removeTag}
|
|
362
|
+
placeholder="Search…"
|
|
295
363
|
onSelect={(item) => {
|
|
364
|
+
/* `action` before `href`: not every hit is a destination. A tag
|
|
365
|
+
* row FILTERS the same query and the palette STAYS OPEN — closing
|
|
366
|
+
* it was the behaviour that forced tags into a second overlay.
|
|
367
|
+
* Only a destination dismisses. */
|
|
368
|
+
if (typeof item?.action === 'function') {
|
|
369
|
+
item.action()
|
|
370
|
+
setSearchQuery('')
|
|
371
|
+
return
|
|
372
|
+
}
|
|
296
373
|
setIsSearchOpen(false)
|
|
297
374
|
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)
|
|
375
|
+
if (item?.href) navigate(item.href)
|
|
306
376
|
}}
|
|
307
|
-
|
|
308
|
-
|
|
377
|
+
>
|
|
378
|
+
{tagMode.isProvided && tagMode.expanded ? <TagModeOverlay /> : null}
|
|
379
|
+
</ShellSearchOverlay>
|
|
309
380
|
|
|
310
381
|
{/* The `?` sheet. Rendered FROM the SHORTCUTS array above, so the help
|
|
311
382
|
* and the bindings are one source — a shortcut can't be documented
|