@kolkrabbi/kol-workshop 0.5.1 → 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 +4 -4
- 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 +181 -25
- 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 +74 -115
- 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,7 +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
|
-
{
|
|
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>
|
|
56
88
|
</aside>
|
|
57
89
|
)
|
|
58
90
|
|
|
@@ -60,21 +92,51 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
|
|
|
60
92
|
const [isNavDrawerOpen, setIsNavDrawerOpen] = useState(false)
|
|
61
93
|
const [navCollapsed, setNavCollapsed] = useState(false)
|
|
62
94
|
const [tocCollapsed, setTocCollapsed] = useState(false)
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
65
110
|
const [tocContent, setTocContent] = useState(null)
|
|
66
111
|
const [isFullHeight, setIsFullHeight] = useState(false)
|
|
112
|
+
const [isShortcutsOpen, setIsShortcutsOpen] = useState(false)
|
|
67
113
|
const location = useLocation()
|
|
68
114
|
const navigate = useNavigate()
|
|
69
115
|
|
|
116
|
+
/* THE shortcut map. One list, rendered by the `?` overlay AND bound by the
|
|
117
|
+
* handler below — a shortcut that isn't in this array doesn't exist, so the
|
|
118
|
+
* help sheet can never drift from the bindings. Before this there was no
|
|
119
|
+
* help UI at all: ⌘K was an unlabelled icon button and Alt+B was a duplicate
|
|
120
|
+
* nobody had written down anywhere. */
|
|
121
|
+
const SHORTCUTS = [
|
|
122
|
+
{ keys: ['⌘', 'K'], label: 'Search everything', match: (e) => (e.metaKey || e.ctrlKey) && e.key === 'k' },
|
|
123
|
+
{ keys: ['?'], label: 'This list', match: (e) => e.key === '?' },
|
|
124
|
+
{ keys: ['Esc'], label: 'Close what is open', match: () => false, note: 'handled per surface' },
|
|
125
|
+
]
|
|
126
|
+
|
|
70
127
|
useEffect(() => {
|
|
71
128
|
const handleKeyDown = (e) => {
|
|
129
|
+
/* never steal a key from a field the user is typing in */
|
|
130
|
+
const t = e.target
|
|
131
|
+
if (t?.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t?.tagName ?? '')) return
|
|
72
132
|
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
|
73
133
|
e.preventDefault()
|
|
74
134
|
setIsSearchOpen(true)
|
|
75
|
-
} else if (
|
|
135
|
+
} else if (e.key === '?') {
|
|
76
136
|
e.preventDefault()
|
|
77
|
-
|
|
137
|
+
setIsShortcutsOpen((v) => !v)
|
|
138
|
+
} else if (e.key === 'Escape') {
|
|
139
|
+
setIsShortcutsOpen(false)
|
|
78
140
|
}
|
|
79
141
|
}
|
|
80
142
|
window.addEventListener('keydown', handleKeyDown)
|
|
@@ -82,26 +144,38 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
|
|
|
82
144
|
}, [])
|
|
83
145
|
|
|
84
146
|
const effectiveTocContent = tocContent ?? defaultTocContent
|
|
85
|
-
/*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
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.
|
|
156
|
+
*
|
|
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. */
|
|
93
162
|
const hasToc = Boolean(effectiveTocContent)
|
|
94
163
|
const showNav = !navCollapsed
|
|
95
|
-
const showToc =
|
|
164
|
+
const showToc = !tocCollapsed
|
|
96
165
|
|
|
97
166
|
const layoutType = showNav && showToc ? 'nav-toc' : showNav ? 'nav' : showToc ? 'toc' : 'none'
|
|
98
167
|
|
|
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. */
|
|
99
173
|
const gridCols = showNav
|
|
100
174
|
? showToc
|
|
101
|
-
? 'lg:grid-cols-[
|
|
102
|
-
: '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)]'
|
|
103
177
|
: showToc
|
|
104
|
-
? 'xl:grid-cols-[minmax(0,1fr)
|
|
178
|
+
? 'xl:grid-cols-[minmax(0,1fr)_var(--kol-shell-toc-w)]'
|
|
105
179
|
: ''
|
|
106
180
|
|
|
107
181
|
// Adapt the old flat `routes` + callbacks to the DS ShellHeader API
|
|
@@ -148,7 +222,7 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
|
|
|
148
222
|
<>
|
|
149
223
|
{actions}
|
|
150
224
|
<Tooltip label="Search">
|
|
151
|
-
<Button variant="ghost" quiet iconOnly="search" iconSize={
|
|
225
|
+
<Button variant="ghost" quiet iconOnly="search" iconSize={HEADER_ICON} onClick={() => setIsSearchOpen(true)} aria-label="Search" />
|
|
152
226
|
</Tooltip>
|
|
153
227
|
</>
|
|
154
228
|
)
|
|
@@ -162,6 +236,12 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
|
|
|
162
236
|
group: item.sectionLabel ?? item.group,
|
|
163
237
|
hint: item.matchedHeading ?? item.matchedKeyword ?? item.hint,
|
|
164
238
|
href: item.href,
|
|
239
|
+
/* `action` must survive this reshape. It didn't: the map rebuilt every row
|
|
240
|
+
* as a fixed five-field object, so a consumer's action closure was dropped
|
|
241
|
+
* between the engine and onSelect and the row silently did nothing. The
|
|
242
|
+
* engine spreads `...item` and the overlay passes rows through untouched —
|
|
243
|
+
* this projection was the only lossy step in the chain. */
|
|
244
|
+
action: item.action,
|
|
165
245
|
}))
|
|
166
246
|
|
|
167
247
|
return (
|
|
@@ -203,10 +283,19 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
|
|
|
203
283
|
* Tailwind's 16/20/24 steps instead of the ramp's 20/32/48 — so
|
|
204
284
|
* every consumer page inherited the raw viewport (measured 2152px
|
|
205
285
|
* of frame at a 2200px window against an 1800px law). Every
|
|
206
|
-
* 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. */}
|
|
207
296
|
<div
|
|
208
|
-
className="
|
|
209
|
-
style={{ paddingInline: 'var(--kol-pad-
|
|
297
|
+
className="h-full w-full"
|
|
298
|
+
style={{ paddingInline: 'var(--kol-pad-chrome-x)' }}
|
|
210
299
|
>
|
|
211
300
|
{/* gap lives in .shell-content-grid (theme) — a gap-8 utility here
|
|
212
301
|
* outranks the layered theme rule at every width and killed the
|
|
@@ -227,6 +316,10 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
|
|
|
227
316
|
</div>
|
|
228
317
|
</MainColumn>
|
|
229
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). */}
|
|
230
323
|
{showToc && (
|
|
231
324
|
<TocColumn>
|
|
232
325
|
{effectiveTocContent}
|
|
@@ -258,13 +351,76 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
|
|
|
258
351
|
query={searchQuery}
|
|
259
352
|
onQueryChange={setSearchQuery}
|
|
260
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…"
|
|
261
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
|
+
}
|
|
262
373
|
setIsSearchOpen(false)
|
|
263
374
|
setSearchQuery('')
|
|
264
375
|
if (item?.href) navigate(item.href)
|
|
265
376
|
}}
|
|
266
|
-
|
|
267
|
-
|
|
377
|
+
>
|
|
378
|
+
{tagMode.isProvided && tagMode.expanded ? <TagModeOverlay /> : null}
|
|
379
|
+
</ShellSearchOverlay>
|
|
380
|
+
|
|
381
|
+
{/* The `?` sheet. Rendered FROM the SHORTCUTS array above, so the help
|
|
382
|
+
* and the bindings are one source — a shortcut can't be documented
|
|
383
|
+
* and unbound, or bound and undocumented, which is how Alt+B lived
|
|
384
|
+
* for months. */}
|
|
385
|
+
{isShortcutsOpen && (
|
|
386
|
+
<div
|
|
387
|
+
className="fixed inset-0 z-50 flex items-center justify-center bg-fg-48"
|
|
388
|
+
onClick={() => setIsShortcutsOpen(false)}
|
|
389
|
+
role="presentation"
|
|
390
|
+
>
|
|
391
|
+
<div
|
|
392
|
+
className="kol-doc-figure w-[min(28rem,90vw)] bg-surface-primary"
|
|
393
|
+
onClick={(e) => e.stopPropagation()}
|
|
394
|
+
role="dialog"
|
|
395
|
+
aria-modal="true"
|
|
396
|
+
aria-label="Keyboard shortcuts"
|
|
397
|
+
>
|
|
398
|
+
<div
|
|
399
|
+
className="flex items-center justify-between border-b px-4 py-3"
|
|
400
|
+
style={{ borderBottomColor: 'var(--kol-oq-08)' }}
|
|
401
|
+
>
|
|
402
|
+
<span className="kol-doc-eyebrow text-meta">Keyboard shortcuts</span>
|
|
403
|
+
<Button variant="outline" quiet size="sm" iconOnly="x"
|
|
404
|
+
aria-label="Close" onClick={() => setIsShortcutsOpen(false)} />
|
|
405
|
+
</div>
|
|
406
|
+
<ul className="flex flex-col px-4 py-3">
|
|
407
|
+
{SHORTCUTS.map((s) => (
|
|
408
|
+
<li key={s.label} className="flex items-center justify-between gap-6 py-1.5">
|
|
409
|
+
<span className="kol-mono-14 text-body">
|
|
410
|
+
{s.label}
|
|
411
|
+
{s.note && <span className="kol-helper-12 text-subtle"> · {s.note}</span>}
|
|
412
|
+
</span>
|
|
413
|
+
<span className="flex items-center gap-1">
|
|
414
|
+
{s.keys.map((k) => (
|
|
415
|
+
<kbd key={k} className="kol-helper-12 rounded-[var(--kol-radius-sm)] bg-fg-08 px-1.5 py-0.5 text-emphasis">{k}</kbd>
|
|
416
|
+
))}
|
|
417
|
+
</span>
|
|
418
|
+
</li>
|
|
419
|
+
))}
|
|
420
|
+
</ul>
|
|
421
|
+
</div>
|
|
422
|
+
</div>
|
|
423
|
+
)}
|
|
268
424
|
</div>
|
|
269
425
|
</ShellTocCollapsedContext.Provider>
|
|
270
426
|
</ShellFullHeightContext.Provider>
|