@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.
@@ -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
- const processInlineMarkdown = (text) => {
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 224px lives HERE, not on the grid track (see gridCols). An inner
56
- * wrapper with a width means a rail whose content renders null measures
57
- * zero and the column collapses — the page gets the space back instead of
58
- * staring at a reserved empty gutter. */}
59
- <div className="w-56 empty:hidden">{children}</div>
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
- const [isSearchOpen, setIsSearchOpen] = useState(false)
68
- const [searchQuery, setSearchQuery] = useState('')
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
- /* An element is truthy even when the component inside it renders nothing, so
107
- * this alone reserved a 224px rail on every route — including ones with no
108
- * headings. Three fixes were rejected before this one: measuring the mounted
109
- * column DEADLOCKS (once the column unmounts the probe is gone and can never
110
- * report content again); calling a render-prop inline makes the consumer's
111
- * hooks belong to THIS component, one refactor away from a crash; and a
112
- * boolean prop puts the answer in the consumer, which doesn't know either.
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
- * The column decides for itself instead. The track is `auto`, not a fixed
115
- * 224px, and the width lives on the rail's CONTENT — so a rail that renders
116
- * nothing collapses to zero and the main column takes the space back. No
117
- * signal to keep in sync, nothing to deadlock. */
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 = hasToc && !tocCollapsed
164
+ const showToc = !tocCollapsed
121
165
 
122
166
  const layoutType = showNav && showToc ? 'nav-toc' : showNav ? 'nav' : showToc ? 'toc' : 'none'
123
167
 
124
- /* `auto`, not `224px` — see hasToc above. The width is declared on the rail's
125
- * content (TocColumn's inner wrapper), so a rail with nothing in it measures
126
- * zero and the main column reclaims the space. */
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-[256px_minmax(0,1fr)] xl:grid-cols-[256px_minmax(0,1fr)_auto]'
130
- : 'lg:grid-cols-[256px_minmax(0,1fr)]'
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)_auto]'
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={18} onClick={() => setIsSearchOpen(true)} aria-label="Search" />
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="mx-auto h-full w-full max-w-[var(--kol-content-shell)]"
243
- style={{ paddingInline: 'var(--kol-pad-section-x)' }}
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
- /* `action` before `href`: not every hit is a destination. A tag
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
- placeholder="Search…"
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