@kolkrabbi/kol-workshop 0.29.0 → 0.30.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,9 @@
1
- import { createContext, useState, useEffect, Suspense } from 'react'
1
+ import { createContext, useContext, useMemo, useState, useEffect, Suspense } from 'react'
2
2
  import { Outlet, Link, useLocation, useNavigate } from 'react-router-dom'
3
3
  import { ShellHeader } from '@kolkrabbi/kol-framework'
4
4
  import ShellSidebar from './ShellSidebar.jsx'
5
- import { Button, IconFrame, ShellDrawer, ShellSearchOverlay, Tooltip, CloseButton } from '@kolkrabbi/kol-component'
6
- import { matchSearchItems } from '../engine/search.js'
5
+ import { IconFrame, ShellDrawer, ShellSearchOverlay, ShortcutsOverlay, SettingsSections, SettingsSwitch, Tooltip } from '@kolkrabbi/kol-component'
6
+ import { createIndex, search } from '@kolkrabbi/kol-search'
7
7
  import { useTagMode } from '../tags/TagModeContext.jsx'
8
8
  import TagModeOverlay from '../tags/TagModeOverlay.jsx'
9
9
  import { Asset } from '@kolkrabbi/kol-brand/svg'
@@ -45,17 +45,47 @@ export const ShellTocCollapsedContext = createContext(null)
45
45
  // useLayoutEffect(() => { setNavCollapsed(true) ; return () => setNavCollapsed(false) }, [setNavCollapsed])
46
46
  export const ShellNavCollapsedContext = createContext(null)
47
47
 
48
- /* overflow-x-hidden (2026-07-30): long tree rows (component names + counters)
49
- * overflowed the 256px rail into an internal horizontal scroll — with the
50
- * scrollbar hidden it read as content silently walking off, and the pan
51
- * gesture shoved the whole grid sideways exposing the outer padding. */
48
+ /* THE PAGE TELLS THE RAIL WHAT IT IS ABOUT (2026-09-28, user: "the purpose of
49
+ * the right sidebar is to list that pages content and context"). The right
50
+ * rail was handed `tags={[]}` and `related={[]}` on every route, so it showed
51
+ * the same global block everywhere. A page now publishes its own context and
52
+ * the rail reads it:
53
+ * usePageMeta({ tags, related: [{ label, to }] }) — in a page
54
+ * const meta = usePageMetaValue() — in a rail
55
+ * Cleared on unmount, so a page that says nothing gets an empty context, never
56
+ * the previous page's. */
57
+ const ShellPageMetaContext = createContext({ meta: null, setMeta: null })
58
+
59
+ export function usePageMeta(meta) {
60
+ const { setMeta } = useContext(ShellPageMetaContext)
61
+ const key = JSON.stringify(meta ?? null)
62
+ useEffect(() => {
63
+ if (!setMeta) return undefined
64
+ setMeta(meta ?? null)
65
+ return () => setMeta(null)
66
+ // eslint-disable-next-line react-hooks/exhaustive-deps
67
+ }, [setMeta, key])
68
+ }
69
+
70
+ export const usePageMetaValue = () => useContext(ShellPageMetaContext).meta
71
+
72
+ /* THE SCROLL ROOT. The region below the header scrolls as ONE, edge to edge
73
+ * (2026-09-28) — it used to be #main, inset by the chrome padding, so its
74
+ * scrollbar sat inside the frame. Anything that observes or resets scroll
75
+ * inside the shell (useScrollSpy's `root`, a jump-to-top) reads this id. */
76
+ export const SHELL_SCROLL_ROOT = '#shell-scroll'
77
+
78
+ /* The rails are sticky inside the one scroll region and scroll their own
79
+ * overflow (`.shell-rail`, kol-components-workshop.css): exactly one region
80
+ * tall, a seam toward the page, the chrome inset inside the seam.
81
+ * overflow-x-hidden (2026-07-30): long tree rows must not scroll sideways. */
52
82
  const NavColumn = ({ children }) => (
53
- <aside aria-label="Navigation" className="shell-sidebar-sticky hidden lg: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">
83
+ <aside aria-label="Navigation" className="shell-rail shell-rail--nav shell-sidebar-sticky hidden lg:block pt-6 md:pt-6 lg:pt-8 pb-8">
54
84
  {children}
55
85
  </aside>
56
86
  )
57
87
 
58
- const MainColumn = ({ children, fullHeight, width = 'canvas' }) => {
88
+ const MainColumn = ({ children, fullHeight, width = 'canvas', padStart, padEnd }) => {
59
89
  /* the map lives INSIDE MainColumn on purpose — validate:width W1 reads the
60
90
  * cap off this block and refuses a --kol-content-* cap anywhere else */
61
91
  const cap = {
@@ -63,24 +93,20 @@ const MainColumn = ({ children, fullHeight, width = 'canvas' }) => {
63
93
  shell: 'w-full mx-auto max-w-[var(--kol-content-shell)]',
64
94
  none: 'w-full',
65
95
  }[width] ?? 'w-full max-w-[var(--kol-content-canvas)]'
96
+ /* ONE space between a rail and the page: main pads the page ladder from a
97
+ * rail's seam, only on a side that HAS a rail at this width. Pages never pad
98
+ * themselves on x. */
99
+ const pad = `${padStart ? 'lg:pl-[var(--kol-pad-section-x)]' : ''} ${padEnd ? 'xl:pr-[var(--kol-pad-section-x)]' : ''}`
66
100
  return (
67
101
  <main
68
102
  id="main"
69
- className={`w-full min-w-0 h-full min-h-0 ${fullHeight ? 'overflow-hidden flex flex-col' : 'overflow-y-auto overscroll-none'}`}
70
- style={fullHeight ? undefined : { scrollbarGutter: 'stable' }}
103
+ className={`shell-main w-full ${pad} ${fullHeight ? 'h-[100cqh] overflow-hidden flex flex-col' : ''}`}
71
104
  >
72
105
  {/* The cap lives HERE, on the CONTENT — not on the grid that holds the
73
- * rails (2026-07-31: capping the grid centred all three columns and pulled
74
- * both rails off the viewport edge). WHICH cap is the page's call — see
75
- * ShellContentWidthContext. The default is canvas, left against the nav:
76
- * that was a call made for doc pages (2026-07-31 / 2026-08-01, narrow
77
- * content keeping a constant gap to the rail) and it was written here as
78
- * a law for every page — the home page's centred hero then centred on the
79
- * cap instead of the track, so closing the TOC moved nothing and closing
80
- * the nav moved everything. It is a default now, not a rule. Canvas stays
81
- * left-anchored (never mx-auto — W4); shell is the frame rung and centres;
82
- * none spans the track. `fullHeight` stays uncapped — it IS the
83
- * fill-the-viewport escape hatch (iframe embeds). */}
106
+ * rails. WHICH cap is the page's call — see ShellContentWidthContext.
107
+ * Canvas stays left-anchored (never mx-auto — W4); shell is the frame rung
108
+ * and centres; none spans the track. `fullHeight` stays uncapped — it IS
109
+ * the fill-the-viewport escape hatch (iframe embeds). */}
84
110
  {fullHeight
85
111
  ? children
86
112
  : <div className={`${cap} pt-6 md:pt-6 lg:pt-8 pb-16`}>{children}</div>
@@ -90,27 +116,54 @@ const MainColumn = ({ children, fullHeight, width = 'canvas' }) => {
90
116
  }
91
117
 
92
118
  /* `xl`, not `lg` — the grid only declares a third column at xl (gridCols
93
- * below). Rendering this at lg put THREE children in a TWO-column grid between
94
- * 1024 and 1279px: the rail wrapped to an implicit second row and `h-full`
95
- * split the height between them, so main got ~373px of a 900px window and the
96
- * page read as empty. The breakpoint here and the one in gridCols are one
97
- * decision and must not be stated twice differently. */
119
+ * below). The breakpoint here and the one in gridCols are one decision and
120
+ * must not be stated twice differently. */
98
121
  const TocColumn = ({ children }) => (
99
- <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">
100
- {/* The WIDTH IS THE GRID TRACK now (--kol-shell-toc-w, see gridCols), not
101
- * an inner wrapper. It used to be `w-56 empty:hidden` here precisely so a
102
- * rail whose content rendered null would measure zero and let the main
103
- * column reclaim the space — REVERSED by user ruling 2026-08-01: an empty
104
- * rail still holds its column, because a rail that disappears re-flows
105
- * main and the same page ends up at two different widths. */}
122
+ <aside aria-label="Table of contents" className="shell-rail shell-rail--toc shell-sidebar-sticky hidden xl:block pt-6 md:pt-6 lg:pt-8 pb-8">
123
+ {/* The width is the grid track (--kol-shell-toc-w). An empty rail still
124
+ * holds its column (user ruling 2026-08-01): a rail that disappears
125
+ * re-flows main and the same page ends up at two widths. */}
106
126
  <div className="w-full">{children}</div>
107
127
  </aside>
108
128
  )
109
129
 
110
- const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoSrc, brandLogoAlt = '', renderSidebar, searchItems, defaultTocContent, isActive: isActiveProp, actions }) => {
130
+ /* THE SHELL'S OWN SETTINGS, remembered per viewer (a convenience — a blocked
131
+ * store just means the defaults). */
132
+ const SETTINGS_KEY = 'kol-workshop-settings'
133
+ const readSettings = () => {
134
+ try { return JSON.parse(localStorage.getItem(SETTINGS_KEY) ?? '{}') ?? {} } catch { return {} }
135
+ }
136
+ const writeSettings = (next) => {
137
+ try { localStorage.setItem(SETTINGS_KEY, JSON.stringify(next)) } catch { /* private window */ }
138
+ }
139
+
140
+ /* A search item in either shape — the kol-search item (`title · kind · space ·
141
+ * category · tags · headings · keywords · description · href`) or the palette's
142
+ * older row (`label · sectionLabel · …`). Both keep working. */
143
+ const toSearchItem = (item, i) => ({
144
+ ...item,
145
+ id: item.id ?? item.href ?? `${item.label ?? item.title}-${i}`,
146
+ title: item.title ?? item.label ?? '',
147
+ category: item.category ?? item.sectionLabel ?? item.group,
148
+ })
149
+
150
+ /**
151
+ * @param {string} [searchPath] where Enter takes the query (`${searchPath}?q=…`) — the
152
+ * shared results page (kol-workshop `SearchPage`). Omitted,
153
+ * Enter keeps the older behaviour: the tag browser in place.
154
+ * @param {Array} [settings] extra settings sections (`SettingsSections` shape) the
155
+ * consumer adds to the shell's settings drawer
156
+ * @param {string|Function} [brandLabel] a TYPED label in the second wordmark slot instead of
157
+ * the drawn WORKSHOP mark — a string, or `({ activeRoute }) => string`
158
+ * so it names the space you are in
159
+ */
160
+ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoSrc, brandLogoAlt = '', renderSidebar, searchItems, searchPath, settings = [], brandLabel, defaultTocContent, isActive: isActiveProp, actions }) => {
111
161
  const [isNavDrawerOpen, setIsNavDrawerOpen] = useState(false)
112
- const [navCollapsed, setNavCollapsed] = useState(false)
113
- const [tocCollapsed, setTocCollapsed] = useState(false)
162
+ const [prefs, setPrefs] = useState(readSettings)
163
+ const setPref = (key, value) => setPrefs((p) => { const next = { ...p, [key]: value }; writeSettings(next); return next })
164
+ const [navCollapsed, setNavCollapsed] = useState(() => readSettings().navHidden === true)
165
+ const [tocCollapsed, setTocCollapsed] = useState(() => readSettings().tocHidden === true)
166
+ const [isSettingsOpen, setIsSettingsOpen] = useState(false)
114
167
  /* ONE QUERY (user ruling 2026-08-01). The palette's text used to live here
115
168
  * while tags lived in TagModeContext — two states, and therefore two
116
169
  * surfaces. Both facets are the context's now. The local pair survives ONLY
@@ -143,21 +196,36 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
143
196
  }
144
197
  }
145
198
  const [tocContent, setTocContent] = useState(null)
199
+ const [pageMeta, setPageMeta] = useState(null)
146
200
  const [isFullHeight, setIsFullHeight] = useState(false)
147
201
  const [contentWidth, setContentWidth] = useState('canvas')
148
202
  const [isShortcutsOpen, setIsShortcutsOpen] = useState(false)
149
203
  const location = useLocation()
150
204
  const navigate = useNavigate()
151
205
 
152
- /* THE shortcut map. One list, rendered by the `?` overlay AND bound by the
206
+ /* a new page starts at the top of the one scroll region; a hash is the
207
+ * browser's to resolve */
208
+ useEffect(() => {
209
+ if (location.hash) return
210
+ document.getElementById('shell-scroll')?.scrollTo(0, 0)
211
+ }, [location.pathname, location.hash])
212
+
213
+ /* THE shortcut map. One list, rendered by the `S` sheet AND bound by the
153
214
  * handler below — a shortcut that isn't in this array doesn't exist, so the
154
- * help sheet can never drift from the bindings. Before this there was no
155
- * help UI at all: ⌘K was an unlabelled icon button and Alt+B was a duplicate
156
- * nobody had written down anywhere. */
215
+ * sheet can never drift from the bindings. The sheet is kol-component's
216
+ * ShortcutsOverlay (2026-09-28): the same sheet, on the same key, as every
217
+ * app — it was a `?` sheet of its own here. `?` still opens it. */
157
218
  const SHORTCUTS = [
158
- { keys: ['⌘', 'K'], label: 'Search everything', match: (e) => (e.metaKey || e.ctrlKey) && e.key === 'k' },
159
- { keys: ['?'], label: 'This list', match: (e) => e.key === '?' },
160
- { keys: ['Esc'], label: 'Close what is open', match: () => false, note: 'handled per surface' },
219
+ { section: 'Search', items: [
220
+ { id: 'k', label: 'Search everything', combo: '⌘ K' },
221
+ { id: 'slash', label: 'Search everything', combo: '/' },
222
+ { id: 'enter', label: 'All results, on the search page', combo: '↵' },
223
+ ] },
224
+ { section: 'Shell', items: [
225
+ { id: 'sheet', label: 'This sheet', combo: 'S' },
226
+ { id: 'settings', label: 'Settings', combo: ',' },
227
+ { id: 'esc', label: 'Close what is open', combo: 'Esc' },
228
+ ] },
161
229
  ]
162
230
 
163
231
  useEffect(() => {
@@ -168,9 +236,19 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
168
236
  if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
169
237
  e.preventDefault()
170
238
  setIsSearchOpen(true)
171
- } else if (e.key === '?') {
239
+ return
240
+ }
241
+ if (e.metaKey || e.ctrlKey || e.altKey) return
242
+ /* `/` opens search — it used to fall through to the browser's find */
243
+ if (e.key === '/') {
244
+ e.preventDefault()
245
+ setIsSearchOpen(true)
246
+ } else if (e.key === 's' || e.key === 'S' || e.key === '?') {
172
247
  e.preventDefault()
173
248
  setIsShortcutsOpen((v) => !v)
249
+ } else if (e.key === ',') {
250
+ e.preventDefault()
251
+ setIsSettingsOpen((v) => !v)
174
252
  } else if (e.key === 'Escape') {
175
253
  setIsShortcutsOpen(false)
176
254
  }
@@ -179,7 +257,14 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
179
257
  return () => window.removeEventListener('keydown', handleKeyDown)
180
258
  }, [])
181
259
 
182
- const effectiveTocContent = tocContent ?? defaultTocContent
260
+ const isActive = isActiveProp ?? ((href) => location.pathname === href || location.pathname.startsWith(`${href}/`))
261
+ const joinPath = (p) => `${basePath.replace(/\/$/, '')}/${String(p ?? '').replace(/^\//, '')}`
262
+ /* THE SPACE YOU ARE IN (2026-09-28). The rails are per space now — the left
263
+ * rail used to render one stack on every route. The consumer gets the active
264
+ * route and draws that space's rail; the same object reaches the default
265
+ * right rail when it is a function. */
266
+ const activeRoute = routes.find((r) => r.path && isActive(joinPath(r.path))) ?? null
267
+ const effectiveTocContent = tocContent ?? (typeof defaultTocContent === 'function' ? defaultTocContent({ activeRoute }) : defaultTocContent)
183
268
  /* THE RAIL HOLDS ITS COLUMN, EMPTY OR NOT (user ruling 2026-08-01).
184
269
  *
185
270
  * This used to hunt for a way to tell whether the rail had real content, so
@@ -201,22 +286,20 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
201
286
 
202
287
  const layoutType = showNav && showToc ? 'nav-toc' : showNav ? 'nav' : showToc ? 'toc' : 'none'
203
288
 
204
- /* A FIXED track (--kol-shell-toc-w), not `auto`. `auto` sized the column to
205
- * its content, which is exactly what let an empty rail measure zero. Both
206
- * rail widths are tokens: the left is --kol-sidenav-w (the one number every
207
- * grid track that lines up with it already reads), the right is
208
- * --kol-shell-toc-w. */
289
+ /* FIXED tracks, not `auto` — `auto` let an empty rail measure zero. Both
290
+ * rails are the workshop shell's own pair (--kol-shell-nav-w ·
291
+ * --kol-shell-toc-w, 256px each since 2026-09-28): the left used to read
292
+ * --kol-sidenav-w, the draggable app sidenav's 264/320 ladder. */
209
293
  const gridCols = showNav
210
294
  ? showToc
211
- ? '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)]'
212
- : 'lg:grid-cols-[var(--kol-sidenav-w)_minmax(0,1fr)]'
295
+ ? 'lg:grid-cols-[var(--kol-shell-nav-w)_minmax(0,1fr)] xl:grid-cols-[var(--kol-shell-nav-w)_minmax(0,1fr)_var(--kol-shell-toc-w)]'
296
+ : 'lg:grid-cols-[var(--kol-shell-nav-w)_minmax(0,1fr)]'
213
297
  : showToc
214
298
  ? 'xl:grid-cols-[minmax(0,1fr)_var(--kol-shell-toc-w)]'
215
299
  : ''
216
300
 
217
301
  // Adapt the old flat `routes` + callbacks to the DS ShellHeader API
218
302
  // (brand node · nav[{label,href,icon}] · isActive · onNavigate · actions slot).
219
- const joinPath = (p) => `${basePath.replace(/\/$/, '')}/${String(p ?? '').replace(/^\//, '')}`
220
303
  const navItems = routes.map((r) => ({
221
304
  label: r.label,
222
305
  href: r.path ? joinPath(r.path) : basePath,
@@ -225,7 +308,6 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
225
308
  /* Prefix matching is the default, but a consumer can override it: a tab whose
226
309
  * href targets a CHILD page (Docs → /docs/shell-and-layout) must still light
227
310
  * up across the whole /docs prefix, which self-matching can't express. */
228
- const isActive = isActiveProp ?? ((href) => location.pathname === href || location.pathname.startsWith(`${href}/`))
229
311
  const handleNavigate = (event, item) => {
230
312
  if (item.href) {
231
313
  event.preventDefault()
@@ -240,15 +322,20 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
240
322
  <img src={brandLogoSrc} alt={brandLogoAlt} className="h-6 w-auto" />
241
323
  </Link>
242
324
  ) : (
243
- // Two separate wordmarks: KOLKRABBI holds the logo slot (reserves the 256px
244
- // nav column at lg+) and links to the SITE home; WORKSHOP falls at the
245
- // content-column edge and links to the shell root. Both h-6.
325
+ // Two wordmarks: KOLKRABBI holds the logo slot and links to the SITE home;
326
+ // the second falls where the PAGE TEXT starts (the nav track + the page's
327
+ // pad from the seam, less the header's 32px gap) and links to the shell root. The second is the drawn
328
+ // WORKSHOP mark, or — with `brandLabel` (2026-09-28, user: "we might as well
329
+ // just use right grotesk tight … when you land at ui.kolkrabbi.io … then it
330
+ // could change with the site's navigation") — typed, naming the space.
246
331
  <>
247
- <Link to="/" className="shell-header-logo hidden md:flex shrink-0 items-center text-emphasis lg:w-64">
332
+ <Link to="/" className="shell-header-logo hidden md:flex shrink-0 items-center text-emphasis lg:w-[calc(var(--kol-shell-nav-w)_+_var(--kol-pad-section-x)_-_32px)]">
248
333
  <Asset name="kol-wordmark" title="Kolkrabbi" className="inline-flex [&>svg]:h-6 [&>svg]:w-auto" />
249
334
  </Link>
250
335
  <Link to={basePath} className="shell-header-logo flex items-center text-emphasis">
251
- <Asset name="wordmark-workshop" title="Workshop" className="inline-flex [&>svg]:h-6 [&>svg]:w-auto" />
336
+ {brandLabel
337
+ ? <span className="shell-wordmark-label">{typeof brandLabel === 'function' ? brandLabel({ activeRoute }) : brandLabel}</span>
338
+ : <Asset name="wordmark-workshop" title="Workshop" className="inline-flex [&>svg]:h-6 [&>svg]:w-auto" />}
252
339
  </Link>
253
340
  </>
254
341
  ))
@@ -257,33 +344,64 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
257
344
  const searchTrigger = (
258
345
  <>
259
346
  {actions}
260
- <Tooltip label="Search">
347
+ <Tooltip label="Search · ⌘K or /">
261
348
  {/* `md` + full-ink nav (user re-rule 2026-08-09; repeals the 2026-08-01
262
349
  * lg ruling). The row law is unchanged: every header glyph on ONE
263
350
  * rung, one variant — the rung is just md now. */}
264
351
  <IconFrame name="search" variant="nav" size="md" onClick={() => setIsSearchOpen(true)} aria-label="Search" />
265
352
  </Tooltip>
353
+ <Tooltip label="Settings · ,">
354
+ <IconFrame name="settings-01" variant="nav" size="md" onClick={() => setIsSettingsOpen(true)} aria-label="Settings" />
355
+ </Tooltip>
266
356
  </>
267
357
  )
268
358
 
269
- /* searchItems ({ id, label, href, sectionLabel?, tags?, headings?, keywords? })
270
- * → the overlay's row shape ({ id, label, group?, hint? }), matched by the
271
- * engine. hint surfaces WHY a row matched when the label didn't. */
272
- const searchResults = matchSearchItems(searchItems ?? [], searchQuery).map((item, i) => ({
273
- id: item.id ?? item.href ?? `${item.label}-${i}`,
274
- label: item.label,
275
- group: item.sectionLabel ?? item.group,
276
- hint: item.matchedHeading ?? item.matchedKeyword ?? item.hint,
277
- href: item.href,
278
- /* `action` must survive this reshape. It didn't: the map rebuilt every row
279
- * as a fixed five-field object, so a consumer's action closure was dropped
280
- * between the engine and onSelect and the row silently did nothing. The
281
- * engine spreads `...item` and the overlay passes rows through untouched —
282
- * this projection was the only lossy step in the chain. */
283
- action: item.action,
284
- }))
359
+ /* THE ENGINE IS kol-search (2026-09-28) — the palette ran a first-substring
360
+ * match, so `atom` ranked AppHub first on a word in its description and only
361
+ * one row lit its match. Ranked now, every hit highlighted, and a word that
362
+ * names a category (`atom`) filters by it. `limitToSpace` (settings) scopes
363
+ * quick search to the space you are in. */
364
+ const searchIndex = useMemo(() => createIndex((searchItems ?? []).map(toSearchItem)), [searchItems])
365
+ const quickScope = prefs.limitToSpace && activeRoute ? { space: activeRoute.id } : {}
366
+ const searchResults = searchQuery
367
+ ? search(searchIndex, searchQuery, { scope: quickScope, limit: 12 }).results.map(({ item, reasons, highlights }) => {
368
+ const titleHit = reasons.some((r) => r.field === 'title')
369
+ const first = reasons.find((r) => r.hit)
370
+ return {
371
+ id: item.id,
372
+ label: item.title,
373
+ group: item.category,
374
+ hint: titleHit ? item.hint : (first?.hit ?? item.description ?? item.hint),
375
+ href: item.href,
376
+ highlights: highlights.title,
377
+ /* `action` survives the reshape — a row may run a closure instead of going somewhere */
378
+ action: item.action,
379
+ }
380
+ })
381
+ : []
382
+ const openResultsPage = () => {
383
+ const q = searchQuery.trim()
384
+ closeSearch()
385
+ navigate(q ? `${searchPath}?q=${encodeURIComponent(q)}` : searchPath)
386
+ }
387
+
388
+ const settingsSections = [
389
+ { label: 'Layout', rows: [
390
+ { id: 'nav', label: 'Left rail', render: () => <SettingsSwitch on={!navCollapsed} onChange={(on) => { setNavCollapsed(!on); setPref('navHidden', !on) }} /> },
391
+ { id: 'toc', label: 'Right rail', render: () => <SettingsSwitch on={!tocCollapsed} onChange={(on) => { setTocCollapsed(!on); setPref('tocHidden', !on) }} /> },
392
+ ] },
393
+ { label: 'Search', rows: [
394
+ { id: 'scope', label: 'Quick search in this space only', render: () => <SettingsSwitch on={!!prefs.limitToSpace} onChange={(on) => setPref('limitToSpace', on)} /> },
395
+ ] },
396
+ ...settings,
397
+ { label: 'Keys', rowGap: 1, rows: SHORTCUTS.flatMap((sec) => sec.items).map((k) => ({
398
+ id: `key-${k.id}`, label: k.label, labelWidth: 'auto', align: 'fill',
399
+ value: <span className="kol-helper-12 text-fg-48 whitespace-nowrap">{k.combo}</span>,
400
+ })) },
401
+ ]
285
402
 
286
403
  return (
404
+ <ShellPageMetaContext.Provider value={{ meta: pageMeta, setMeta: setPageMeta }}>
287
405
  <ShellTocContext.Provider value={setTocContent}>
288
406
  <ShellFullHeightContext.Provider value={setIsFullHeight}>
289
407
  <ShellContentWidthContext.Provider value={setContentWidth}>
@@ -314,42 +432,21 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
314
432
  tocCollapsed={tocCollapsed}
315
433
  />
316
434
 
317
- {/* Three independent scroll regions: each rail scrolls its own overflow,
318
- * main scrolls unless a page locks it (ShellFullHeightContext — embeds).
319
- * overscroll-none stops chaining between regions and the edge bounce. */}
320
- <div className="flex-1 overflow-hidden">
321
- {/* THE frame (kol-theme "Content widths"): ONE cap at the shell
322
- * token, centred, on the framework padding ramp. This wrapper
323
- * carried `w-full px-4 md:px-5 lg:px-6` — no cap at all, and
324
- * Tailwind's 16/20/24 steps instead of the ramp's 20/32/48 — so
325
- * every consumer page inherited the raw viewport (measured 2152px
326
- * of frame at a 2200px window against an 1800px law). Every
327
- * page-level width fix is downstream of this line.
328
- *
329
- * CORRECTED 2026-07-31: that fix capped THIS element, which holds
330
- * all three columns — so the whole chrome centred and both rails
331
- * were dragged inward off the viewport edge. The chrome frame
332
- * takes the full available width and the rails justify to it; the
333
- * cap moved one level down onto MainColumn's content, which is
334
- * what the theme's law is written about. Same disease as
335
- * --kol-container-max stopping at 1600 under an 1800 law: one
336
- * element, two answers. */}
337
- <div
338
- className="h-full w-full"
339
- style={{ paddingInline: 'var(--kol-pad-chrome-x)' }}
340
- >
341
- {/* gap lives in .shell-content-grid (theme) — a gap-8 utility here
342
- * outranks the layered theme rule at every width and killed the
343
- * 48px wide step (ARCHITECTURE §5: component geometry in its own
344
- * rule, never a utility racing it). */}
345
- <div className={`shell-content-grid grid ${gridCols} h-full min-h-0`} data-layout={layoutType}>
435
+ {/* ONE scroll region, edge to edge (see SHELL_SCROLL_ROOT). The chrome
436
+ * inset pads the grid INSIDE it, so the scrollbar sits at the window
437
+ * edge. A full-height page (embeds) locks it and fills it instead. */}
438
+ <div
439
+ id="shell-scroll"
440
+ className={`shell-scroll flex-1 min-h-0 ${isFullHeight ? 'overflow-hidden' : 'overflow-y-auto'}`}
441
+ >
442
+ <div className={`shell-content-grid grid ${gridCols}`} data-layout={layoutType} style={{ paddingInline: 'var(--kol-pad-chrome-x)' }}>
346
443
  {showNav && (
347
444
  <NavColumn>
348
- {renderSidebar ? renderSidebar({}) : <ShellSidebar routes={routes} basePath={basePath} />}
445
+ {renderSidebar ? renderSidebar({ activeRoute }) : <ShellSidebar routes={routes} basePath={basePath} />}
349
446
  </NavColumn>
350
447
  )}
351
448
 
352
- <MainColumn fullHeight={isFullHeight} width={contentWidth}>
449
+ <MainColumn fullHeight={isFullHeight} width={contentWidth} padStart={showNav} padEnd={showToc}>
353
450
  <div className={isFullHeight ? 'flex flex-col flex-1 min-h-0 [&>*]:flex-1 [&>*]:flex [&>*]:flex-col [&>*]:min-h-0' : ''}>
354
451
  <Suspense fallback={<div className="flex items-center justify-center p-12 text-fg-48">Loading…</div>}>
355
452
  <Outlet />
@@ -366,9 +463,8 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
366
463
  {effectiveTocContent}
367
464
  </TocColumn>
368
465
  )}
369
- </div>
370
466
  </div>
371
- </div>
467
+ </div>
372
468
 
373
469
  {/* `open`, not `isOpen` — the old prop name silently kept the drawer
374
470
  * shut, so there was no navigation at all below lg. */}
@@ -377,7 +473,7 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
377
473
  onClose={() => setIsNavDrawerOpen(false)}
378
474
  >
379
475
  {renderSidebar
380
- ? renderSidebar({ onNavigate: () => setIsNavDrawerOpen(false) })
476
+ ? renderSidebar({ activeRoute, onNavigate: () => setIsNavDrawerOpen(false) })
381
477
  : <ShellSidebar routes={routes} basePath={basePath} onNavigate={() => setIsNavDrawerOpen(false)} />
382
478
  }
383
479
  </ShellDrawer>
@@ -397,7 +493,13 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
397
493
  * commits the query and swaps the result rows for the full body;
398
494
  * committed tags ride along as chips on the same query. */
399
495
  expanded={tagMode.isProvided && tagMode.expanded}
400
- onExpand={() => tagMode.setExpanded?.(true)}
496
+ /* ENTER OPENS THE RESULTS PAGE (2026-09-28, user: "you would then have
497
+ * the option of pressing enter … which would take you to the shared
498
+ * index search results page … instead of another weird nested
499
+ * overlay"). The page keeps the query in its URL, so Back returns to
500
+ * it. Without a `searchPath` Enter keeps the older in-place browser. */
501
+ onExpand={searchPath ? openResultsPage : () => tagMode.setExpanded?.(true)}
502
+ enterLabel={searchPath ? `All results for “${searchQuery.trim()}”` : undefined}
401
503
  chips={tagMode.isProvided ? tagMode.activeTags : []}
402
504
  onRemoveChip={tagMode.removeTag}
403
505
  placeholder="Search…"
@@ -418,59 +520,26 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
418
520
  {tagMode.isProvided && tagMode.expanded ? <TagModeOverlay /> : null}
419
521
  </ShellSearchOverlay>
420
522
 
421
- {/* The `?` sheet. Rendered FROM the SHORTCUTS array above, so the help
422
- * and the bindings are one source — a shortcut can't be documented
423
- * and unbound, or bound and undocumented, which is how Alt+B lived
424
- * for months. */}
425
- {isShortcutsOpen && (
426
- <div
427
- /* THE scrim (overlay-scrim-outliers sweep, 2026-09-03): was a hand
428
- `bg-fg-48` — the ink wash, not the class's ab-black 48 */
429
- className="fixed inset-0 z-50 flex items-center justify-center kol-overlay-scrim"
430
- onClick={() => setIsShortcutsOpen(false)}
431
- role="presentation"
432
- >
433
- <div
434
- className="kol-doc-figure w-[min(28rem,90vw)] bg-surface-primary"
435
- onClick={(e) => e.stopPropagation()}
436
- role="dialog"
437
- aria-modal="true"
438
- aria-label="Keyboard shortcuts"
439
- >
440
- <div
441
- className="flex items-center justify-between border-b px-4 py-3"
442
- style={{ borderBottomColor: 'var(--kol-oq-08)' }}
443
- >
444
- <span className="kol-doc-eyebrow text-meta">Keyboard shortcuts</span>
445
- {/* was `Button variant="outline" quiet` — the boxed treatment the
446
- * 2026-09-01 one-idiom ruling retired, still shipping here because
447
- * the idiom was a comment rather than a component (2026-09-03) */}
448
- <CloseButton onClick={() => setIsShortcutsOpen(false)} />
449
- </div>
450
- <ul className="flex flex-col px-4 py-3">
451
- {SHORTCUTS.map((s) => (
452
- <li key={s.label} className="flex items-center justify-between gap-6 py-1.5">
453
- <span className="kol-mono-14 text-body">
454
- {s.label}
455
- {s.note && <span className="kol-helper-12 text-subtle"> · {s.note}</span>}
456
- </span>
457
- <span className="flex items-center gap-1">
458
- {s.keys.map((k) => (
459
- <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>
460
- ))}
461
- </span>
462
- </li>
463
- ))}
464
- </ul>
465
- </div>
523
+ {isShortcutsOpen && <ShortcutsOverlay shortcuts={SHORTCUTS} onClose={() => setIsShortcutsOpen(false)} />}
524
+
525
+ {/* THE SHELL'S SETTINGS (2026-09-28, user: "might we also want to utilize a
526
+ * settings page or settings sidebar … to offload some functional
527
+ * settings, search settings, sidenav settings"). A right drawer over
528
+ * the page, the same rows the apps' settings use (SettingsSections). */}
529
+ <ShellDrawer open={isSettingsOpen} onClose={() => setIsSettingsOpen(false)} side="right" width={360}>
530
+ {/* the drawer draws its own close — no second one here */}
531
+ <div className="flex flex-col gap-6 p-6">
532
+ <span className="kol-doc-eyebrow">Settings</span>
533
+ <SettingsSections sections={settingsSections} labelWidth="auto" />
466
534
  </div>
467
- )}
535
+ </ShellDrawer>
468
536
  </div>
469
537
  </ShellNavCollapsedContext.Provider>
470
538
  </ShellTocCollapsedContext.Provider>
471
539
  </ShellContentWidthContext.Provider>
472
540
  </ShellFullHeightContext.Provider>
473
541
  </ShellTocContext.Provider>
542
+ </ShellPageMetaContext.Provider>
474
543
  )
475
544
  }
476
545