@kolkrabbi/kol-workshop 0.28.0 → 0.30.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 +10 -8
- package/src/docs/DocKit.jsx +1 -1
- package/src/docs/DocsFrontmatter.jsx +1 -1
- package/src/docs/DocumentationReader.jsx +7 -7
- package/src/engine/index.js +6 -19
- package/src/exhibit/ExhibitSidebar.jsx +2 -3
- package/src/index.js +1 -0
- package/src/search/SearchPage.jsx +140 -0
- package/src/search/index.js +1 -0
- package/src/shell/RailSection.jsx +23 -12
- package/src/shell/ShellLayout.jsx +227 -158
- package/src/shell/ShellSidebar.jsx +25 -15
- package/src/shell/index.js +1 -1
- package/src/tags/TagGraph.jsx +2 -2
- package/src/tags/TagModeOverlay.jsx +6 -3
- package/src/engine/__check.mjs +0 -72
- package/src/engine/build-inventory.js +0 -85
- package/src/engine/doc-helpers.js +0 -202
- package/src/engine/frontmatter.js +0 -41
- package/src/engine/parse-markdown.js +0 -374
- package/src/engine/search.js +0 -36
- package/src/engine/tags.js +0 -75
|
@@ -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 {
|
|
6
|
-
import {
|
|
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
|
-
/*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
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
|
|
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={`
|
|
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
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
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).
|
|
94
|
-
*
|
|
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
|
|
100
|
-
{/* The
|
|
101
|
-
*
|
|
102
|
-
*
|
|
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
|
-
|
|
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 [
|
|
113
|
-
const
|
|
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
|
-
/*
|
|
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
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
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
|
-
{
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
/*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
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-
|
|
212
|
-
: 'lg:grid-cols-[var(--kol-
|
|
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
|
|
244
|
-
//
|
|
245
|
-
//
|
|
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-
|
|
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
|
-
|
|
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
|
-
/*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
{/*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
<div
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
*
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
|