@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.
@@ -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,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
- {children}
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
- const [isSearchOpen, setIsSearchOpen] = useState(false)
64
- 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
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 ((e.altKey) && e.key === 'b') {
135
+ } else if (e.key === '?') {
76
136
  e.preventDefault()
77
- setIsSearchOpen(true)
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
- /* KNOWN, deliberately not fixed here: an element is always truthy, so
86
- * `<AutoToc/>` reserves a rail even on pages where it renders null. The
87
- * obvious fix — measure the mounted column — deadlocks: once the column
88
- * unmounts the probe is gone and can never report content again. The real
89
- * fix is a consumer-side signal (a render-prop returning null, or an
90
- * explicit `hasToc` prop), which is an API change. Filed, not improvised.
91
- * The bug this caused — a half-height main column at 1024–1279px — was the
92
- * `lg` vs `xl` mismatch on TocColumn above, and that IS fixed. */
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 = hasToc && !tocCollapsed
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-[256px_minmax(0,1fr)] xl:grid-cols-[256px_minmax(0,1fr)_224px]'
102
- : '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)]'
103
177
  : showToc
104
- ? 'xl:grid-cols-[minmax(0,1fr)_224px]'
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={18} onClick={() => setIsSearchOpen(true)} aria-label="Search" />
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="mx-auto h-full w-full max-w-[var(--kol-content-shell)]"
209
- style={{ paddingInline: 'var(--kol-pad-section-x)' }}
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
- placeholder="Search…"
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>