@kolkrabbi/kol-workshop 0.30.1 → 0.32.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-workshop",
3
- "version": "0.30.1",
3
+ "version": "0.32.0",
4
4
  "private": false,
5
5
  "description": "KOL workshop/docs system — a handrolled markdown engine, search, tag system (incl. a d3 tag graph), docs viewer, and docs shell. Sits above @kolkrabbi/kol-{theme,icons,component,framework}; content is consumer-injected (the package never globs docs).",
6
6
  "license": "MIT",
@@ -13,9 +13,9 @@
13
13
  },
14
14
  "dependencies": {
15
15
  "d3": "^7.9.0",
16
- "@kolkrabbi/kol-search": "^0.2.0",
17
- "@kolkrabbi/kol-markdown": "^0.1.1",
18
- "@kolkrabbi/kol-brand": "^0.1.3"
16
+ "@kolkrabbi/kol-brand": "^0.1.3",
17
+ "@kolkrabbi/kol-markdown": "^0.1.2",
18
+ "@kolkrabbi/kol-search": "^0.2.0"
19
19
  },
20
20
  "peerDependencies": {
21
21
  "@kolkrabbi/kol-component": ">=0.228.0",
@@ -27,10 +27,10 @@
27
27
  "react-router-dom": "^6.0.0 || ^7.0.0"
28
28
  },
29
29
  "devDependencies": {
30
- "@kolkrabbi/kol-component": "^0.229.0",
31
- "@kolkrabbi/kol-icons": "^0.29.0",
32
- "@kolkrabbi/kol-theme": "^0.156.0",
33
- "@kolkrabbi/kol-framework": "^0.45.0"
30
+ "@kolkrabbi/kol-component": "^0.230.0",
31
+ "@kolkrabbi/kol-icons": "^0.30.0",
32
+ "@kolkrabbi/kol-theme": "^0.158.0",
33
+ "@kolkrabbi/kol-framework": "^0.47.0"
34
34
  },
35
35
  "files": [
36
36
  "src",
@@ -200,13 +200,13 @@ const DocsFrontmatter = ({ metadata, docId }) => {
200
200
  /* `naked`, the SAME Tag rendering the rail's own tag list uses
201
201
  * (DocReaderSidebar). This was the default filled variant, so
202
202
  * the one concept rendered as a solid pill here and as plain
203
- * coloured text six inches to the right.
203
+ * colored text six inches to the right.
204
204
  *
205
205
  * NO `color` (user ruling 2026-08-01): passing one swaps the
206
206
  * base class from `tag-control` to `tag tag--{color}`, and
207
- * only `tag-control` has a `:hover` rule — so a coloured Tag
207
+ * only `tag-control` has a `:hover` rule — so a colored Tag
208
208
  * silently loses its interaction state. Right component first;
209
- * colour is its own decision, later.
209
+ * color is its own decision, later.
210
210
  * `size` is omitted: `sm` is the default and the only size. */
211
211
  <span className="flex flex-wrap gap-1.5">
212
212
  {value.map((tag) => (
@@ -72,12 +72,19 @@ const DocReaderSidebar = ({ toc, allTags, related, docId, docsIndexHref, compone
72
72
  * (default `/docs?tag=…`).
73
73
  * @param {Function} routes.docFilePath (id) => on-disk path copied to clipboard
74
74
  * (default `docs/documentation/${id}.md`).
75
+ * @param {string} [props.docId] render this doc instead of the route's `:docId` — a
76
+ * HOME renders its markdown at the space's own URL
77
+ * (2026-09-30, the names audit: every level has a home).
78
+ * @param {boolean} [props.showFrontmatter=true] the frontmatter panel; a home hides it by
79
+ * default and the consumer toggles it.
75
80
  */
76
81
  const DocumentationReader = ({
77
82
  inventory = [],
78
83
  modules = {},
79
84
  docHref = (id) => `/docs/${id}`,
80
- routes = {}
85
+ routes = {},
86
+ docId: docIdProp,
87
+ showFrontmatter = true,
81
88
  }) => {
82
89
  const {
83
90
  docsIndex = '/docs',
@@ -86,7 +93,8 @@ const DocumentationReader = ({
86
93
  docFilePath = (id) => `docs/documentation/${id}.md`
87
94
  } = routes
88
95
 
89
- const { docId } = useParams()
96
+ const { docId: routeDocId } = useParams()
97
+ const docId = docIdProp ?? routeDocId
90
98
  const setTocContent = useContext(ShellTocContext)
91
99
  const { openTagMode } = useTagMode()
92
100
 
@@ -337,7 +345,7 @@ const DocumentationReader = ({
337
345
 
338
346
  return (
339
347
  <DocsArticle>
340
- <DocsFrontmatter metadata={doc.metadata} docId={docId} />
348
+ {showFrontmatter && <DocsFrontmatter metadata={doc.metadata} docId={docId} />}
341
349
  {docTitle && (
342
350
  <h1 className="kol-doc-heading">{docTitle}</h1>
343
351
  )}
@@ -0,0 +1,33 @@
1
+ import { Link } from 'react-router-dom'
2
+
3
+ /**
4
+ * ResultRow — one search result: a title, a meta line, an optional description, the whole row a
5
+ * link (2026-09-30, open questions Round 2 — the user: *"I like both, is this a component? if it
6
+ * is I would just have it available as variants … underline is more appealing currently"*).
7
+ *
8
+ * Two variants, each a complete pair — the wash needs its x padding, the underline must not have
9
+ * one (Round 1: x padding without a wash was drawn wrong):
10
+ * `underline` (default) — no ground; the title underlines and the row inks up on hover
11
+ * `wash` — x padding and a ground wash on hover
12
+ *
13
+ * @param {string} to the route the row opens
14
+ * @param {ReactNode} title the result's name (a highlighted node is fine)
15
+ * @param {string} [meta] kind · category · space · date
16
+ * @param {ReactNode} [description]
17
+ * @param {'underline'|'wash'} [variant='underline']
18
+ */
19
+ const VARIANTS = {
20
+ underline: { row: 'group flex flex-col gap-1 py-3 opacity-80 hover:opacity-100 focus-visible:opacity-100', title: 'group-hover:underline underline-offset-4' },
21
+ wash: { row: 'flex flex-col gap-1 px-3 py-3 hover:bg-fg-04', title: '' },
22
+ }
23
+
24
+ export default function ResultRow({ to, title, meta, description, variant = 'underline' }) {
25
+ const v = VARIANTS[variant] ?? VARIANTS.underline
26
+ return (
27
+ <Link to={to} className={v.row}>
28
+ <span className={`kol-doc-body text-emphasis ${v.title}`.trim()}>{title}</span>
29
+ {meta && <span className="kol-helper-12 text-subtle">{meta}</span>}
30
+ {description && <span className="kol-doc-body">{description}</span>}
31
+ </Link>
32
+ )
33
+ }
@@ -1,6 +1,7 @@
1
1
  import { Fragment, useMemo } from 'react'
2
- import { Link, useSearchParams } from 'react-router-dom'
3
- import { Button, Input, Pill } from '@kolkrabbi/kol-component'
2
+ import { useSearchParams } from 'react-router-dom'
3
+ import { Button, EmptyState, Input, Pill } from '@kolkrabbi/kol-component'
4
+ import ResultRow from './ResultRow.jsx'
4
5
  import { createIndex, search, parseQuery } from '@kolkrabbi/kol-search'
5
6
  import { DocHeader, DocSection } from '../docs/DocKit.jsx'
6
7
 
@@ -44,7 +45,7 @@ const readToken = (t) => (t.kind === 'term'
44
45
  ? `${t.field} ${t.value}`
45
46
  : `${t.negate ? 'not ' : ''}${t.field === 'tags' ? 'tag' : t.field}: ${t.value}${t.via === 'smart' ? ' (from the word)' : ''}`)
46
47
 
47
- export default function SearchPage({ items = [], spaces = [], facets = DEFAULT_FACETS, limit = 60 }) {
48
+ export default function SearchPage({ items = [], spaces = [], facets = DEFAULT_FACETS, limit = 60, rowVariant = 'underline' }) {
48
49
  const [params, setParams] = useSearchParams()
49
50
  const q = params.get('q') ?? ''
50
51
  const index = useMemo(() => createIndex(items), [items])
@@ -119,16 +120,18 @@ export default function SearchPage({ items = [], spaces = [], facets = DEFAULT_F
119
120
 
120
121
  <DocSection id="results" title={`Results (${out.total})`}>
121
122
  {out.results.length === 0 ? (
122
- <p className="kol-doc-body">{q ? 'Nothing matches. Remove a token above, or try fewer words.' : 'Type to search.'}</p>
123
+ <EmptyState eyebrow={q ? 'No results' : 'Search'} title={q ? 'Nothing matches.' : 'Type to search.'} body={q ? 'Remove a token above, or try fewer words.' : undefined} />
123
124
  ) : (
124
125
  <ol className="flex flex-col">
125
126
  {out.results.map((r) => (
126
127
  <li key={r.item.id} className="border-t border-fg-08 first:border-t-0">
127
- <Link to={r.item.href} className="flex flex-col gap-1 py-3 hover:bg-fg-04">
128
- <span className="kol-doc-body text-emphasis"><Highlight text={r.item.title} ranges={r.highlights.title} /></span>
129
- <span className="kol-helper-12 text-subtle">{[r.item.kind, r.item.category, r.item.space, r.item.date].filter(Boolean).join(' · ')}</span>
130
- {r.item.description && <span className="kol-doc-body">{r.item.description}</span>}
131
- </Link>
128
+ <ResultRow
129
+ to={r.item.href}
130
+ variant={rowVariant}
131
+ title={<Highlight text={r.item.title} ranges={r.highlights.title} />}
132
+ meta={[r.item.kind, r.item.category, r.item.space, r.item.date].filter(Boolean).join(' · ')}
133
+ description={r.item.description}
134
+ />
132
135
  </li>
133
136
  ))}
134
137
  </ol>
@@ -1 +1,2 @@
1
1
  export { default as SearchPage } from './SearchPage.jsx'
2
+ export { default as ResultRow } from './ResultRow.jsx'
@@ -19,7 +19,7 @@ import { NavLink } from 'react-router-dom'
19
19
  * eyebrow-box failure exactly, one rung further down, which is why the rails
20
20
  * kept drifting after RailSection fixed L1 and L2.
21
21
  *
22
- * The look now lives in `.shell-nav-item` (layout, colour, hover, focus,
22
+ * The look now lives in `.shell-nav-item` (layout, color, hover, focus,
23
23
  * active); this component owns the markup. `validate:rails` R4 fails a
24
24
  * hand-written `shell-nav-item` anywhere but here.
25
25
  *
@@ -21,7 +21,7 @@ import { Link } from 'react-router-dom'
21
21
  * L1 section `.shell-sidebar-toggle` + kol-doc-eyebrow quiet, state glyph at the end
22
22
  * L2 group `.shell-nav-group-header` + kol-mono-14 louder, chevron
23
23
  *
24
- * L2 carries NO colour class: `.shell-nav-group-header` owns weight 500 and
24
+ * L2 carries NO color class: `.shell-nav-group-header` owns weight 500 and
25
25
  * `--kol-fg-shout` itself (user ruling 2026-08-01). It used to carry
26
26
  * `text-body` — a utility that resolved to nothing at the time, and would now
27
27
  * fight the rule for the same property. One owner.
@@ -1,4 +1,5 @@
1
1
  import { useState } from 'react'
2
+ import { useLocation } from 'react-router-dom'
2
3
  import RailSection from './RailSection.jsx'
3
4
  import RailRow from './RailRow.jsx'
4
5
 
@@ -63,6 +64,14 @@ import RailRow from './RailRow.jsx'
63
64
  * this package does not reach into kol-component
64
65
  * @param {Function} onTagClick (tag) => void
65
66
  */
67
+ const PINS_KEY = 'kol-rail-pins'
68
+ const readPins = () => {
69
+ try { return JSON.parse(localStorage.getItem(PINS_KEY) || '[]') } catch { return [] }
70
+ }
71
+ const writePins = (pins) => {
72
+ try { localStorage.setItem(PINS_KEY, JSON.stringify(pins)) } catch { /* private mode */ }
73
+ }
74
+
66
75
  export default function RightRail({
67
76
  toc = [],
68
77
  activeId,
@@ -77,16 +86,46 @@ export default function RightRail({
77
86
  /* One collapse map, not a useState per section — a section added later must
78
87
  * not need a new hook at the top of this function. */
79
88
  const [collapsed, setCollapsed] = useState({})
89
+
90
+ /* PINNED (2026-09-30, the names audit: *"add to quicklook or favorites … in the right sidebar
91
+ * … maintain context-important documents during development … accessible until they dont need
92
+ * to be"*). Any page can be pinned from Quick actions; the pins show on every page until
93
+ * unpinned. Kept in this browser (localStorage) — a working set, not shared state. */
94
+ const { pathname } = useLocation()
95
+ const [pins, setPins] = useState(readPins)
96
+ const isPinned = pins.some((p) => p.path === pathname)
97
+ const togglePin = () => {
98
+ const label = document.querySelector('#main h1, main h1, h1')?.textContent?.trim() || pathname
99
+ const next = isPinned ? pins.filter((p) => p.path !== pathname) : [...pins, { path: pathname, label }]
100
+ setPins(next)
101
+ writePins(next)
102
+ }
103
+ const allActions = [
104
+ ...actions,
105
+ { id: 'pin', label: isPinned ? 'Unpin' : 'Pin', icon: IconComponent ? <IconComponent name="pushpin" size={14} /> : null, onClick: togglePin },
106
+ ]
80
107
  const toggle = (key) => setCollapsed((c) => ({ ...c, [key]: !c[key] }))
81
108
 
82
- /* A tag shown as "this page's" must not repeat below as "the system's". */
83
- const own = new Set(tags)
84
- const otherTags = topTags.filter(({ tag }) => !own.has(tag))
109
+ /* OWN TAGS ONLY, GROUPED BY NAMESPACE (2026-09-30, the names audit — ruled on the
110
+ * recommendation). The rail used to follow a page's own tags with the space's top tags, so a
111
+ * page with none (/docs/menus) still listed ten, none of them its own. The system's most-used
112
+ * tags are a way IN, and belong on a home and the search page; `topTags` is accepted and
113
+ * ignored so no consumer breaks. The namespace law stays in the data — the rail prints it once
114
+ * as a group label and the leaf under it, instead of truncating the full path on every row. */
115
+ const byNamespace = tags.reduce((m, tag) => {
116
+ const [ns, ...rest] = String(tag).split('/')
117
+ const leaf = rest.join('/') || ns
118
+ ;(m[rest.length ? ns : ''] ||= []).push({ tag, leaf })
119
+ return m
120
+ }, {})
121
+ void topTags
122
+ void renderTag
85
123
 
86
124
  /* Every group is described here rather than spelled out in JSX below, so a
87
125
  * section cannot be present in one branch and missing in another — the exact
88
126
  * defect that split the two rails. */
89
- const group = (key, label, count, children) => (
127
+ /* A chapter with nothing in it does not render (2026-09-30 — `Related (0)`). */
128
+ const group = (key, label, count, children) => count === 0 ? null : (
90
129
  <RailSection
91
130
  level={2}
92
131
  label={label}
@@ -101,7 +140,8 @@ export default function RightRail({
101
140
 
102
141
  return (
103
142
  <div className="shell-rail-stack">
104
- <RailSection level={1} label="This page">
143
+ {/* A folded category shows its count, the same as the left rail (2026-09-30) */}
144
+ <RailSection level={1} label="This page" count={toc.length}>
105
145
  <div className="shell-rail-stack-inner">
106
146
  {group('toc', 'Contents', toc.length,
107
147
  toc.map((h) => (
@@ -113,42 +153,40 @@ export default function RightRail({
113
153
  </div>
114
154
  </RailSection>
115
155
 
116
- <RailSection level={1} label="Links">
156
+ <RailSection level={1} label="Links" count={pins.length + allActions.length + tags.length + related.length}>
117
157
  <div className="shell-rail-stack-inner">
118
- {group('actions', 'Quick actions', actions.length,
119
- actions.map((a) => (
158
+ {group('pinned', 'Pinned', pins.length,
159
+ pins.map((p) => (
160
+ <RailRow key={p.path} to={p.path} icon={IconComponent ? <IconComponent name="pushpin" size={14} /> : null}>
161
+ {p.label}
162
+ </RailRow>
163
+ ))
164
+ )}
165
+
166
+ {group('actions', 'Quick actions', allActions.length,
167
+ allActions.map((a) => (
120
168
  <RailRow key={a.id} to={a.to} onClick={a.onClick} icon={a.icon}>
121
169
  {a.label}
122
170
  </RailRow>
123
171
  ))
124
172
  )}
125
173
 
126
- {/* ONE list, two ranks. This page's tags lead and carry `emphasis`;
127
- * the system's most-used follow at the resting stop with their
128
- * count. `otherTags` filters out anything already shown above, so a
129
- * tag cannot appear twice in one list. */}
130
- {group('tags', 'Tags', tags.length + otherTags.length, (
174
+ {group('tags', 'Tags', tags.length, (
131
175
  <>
132
- {tags.map((tag) => (
133
- <RailRow
134
- key={`own-${tag}`}
135
- onClick={() => onTagClick?.(tag)}
136
- icon={IconComponent ? <IconComponent name="hash-02" size={14} /> : null}
137
- className="shell-nav-item--own"
138
- >
139
- {renderTag ? renderTag(tag) : tag}
140
- </RailRow>
141
- ))}
142
- {otherTags.map(({ tag, count }) => (
143
- <RailRow
144
- key={`top-${tag}`}
145
- onClick={() => onTagClick?.(tag)}
146
- icon={IconComponent ? <IconComponent name="hash-02" size={14} /> : null}
147
- trailing={count}
148
- className="shell-nav-item--muted"
149
- >
150
- {renderTag ? renderTag(tag) : tag}
151
- </RailRow>
176
+ {Object.entries(byNamespace).map(([ns, list]) => (
177
+ <div key={`ns-${ns}`}>
178
+ {ns && <RailRow className="shell-nav-item--muted">{ns.charAt(0).toUpperCase() + ns.slice(1)}</RailRow>}
179
+ {list.map(({ tag, leaf }) => (
180
+ <RailRow
181
+ key={`own-${tag}`}
182
+ sub={!!ns}
183
+ onClick={() => onTagClick?.(tag)}
184
+ icon={IconComponent ? <IconComponent name="hash-02" size={14} /> : null}
185
+ >
186
+ {leaf}
187
+ </RailRow>
188
+ ))}
189
+ </div>
152
190
  ))}
153
191
  </>
154
192
  ))}
@@ -1,7 +1,7 @@
1
- import { createContext, useContext, useMemo, useState, useEffect, Suspense } from 'react'
1
+ import { createContext, useContext, useMemo, useRef, 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
- import ShellSidebar from './ShellSidebar.jsx'
4
+ import ShellSidebar, { RAIL_FOLD_EVENT } from './ShellSidebar.jsx'
5
5
  import { IconFrame, ShellDrawer, ShellSearchOverlay, ShortcutsOverlay, SettingsSections, SettingsSwitch, Tooltip } from '@kolkrabbi/kol-component'
6
6
  import { createIndex, search } from '@kolkrabbi/kol-search'
7
7
  import { useTagMode } from '../tags/TagModeContext.jsx'
@@ -79,9 +79,39 @@ export const SHELL_SCROLL_ROOT = '#shell-scroll'
79
79
  * overflow (`.shell-rail`, kol-components-workshop.css): exactly one region
80
80
  * tall, a seam toward the page, the chrome inset inside the seam.
81
81
  * overflow-x-hidden (2026-07-30): long tree rows must not scroll sideways. */
82
- const NavColumn = ({ children }) => (
82
+ /* RESIZABLE RAILS (2026-09-30, the names audit; Round 3 Q3 — decided on the recommendation, the
83
+ * user asleep, for review). A 6px grab strip on the edge facing the page: drag to set the width
84
+ * (200–420, remembered), double-click to reset to the shell's 256. The width is the shell's own
85
+ * token, `--kol-shell-nav-w` / `--kol-shell-toc-w`, set on the shell root, so the grid tracks and
86
+ * the rail's own box follow it. */
87
+ const RAIL_MIN = 200
88
+ const RAIL_MAX = 420
89
+ const RailGrip = ({ side, width, onChange }) => {
90
+ const start = (e) => {
91
+ e.preventDefault()
92
+ const x0 = e.clientX
93
+ const w0 = width
94
+ const move = (m) => onChange(Math.round(Math.min(RAIL_MAX, Math.max(RAIL_MIN, w0 + (side === 'end' ? m.clientX - x0 : x0 - m.clientX)))))
95
+ const up = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up) }
96
+ window.addEventListener('pointermove', move)
97
+ window.addEventListener('pointerup', up)
98
+ }
99
+ return (
100
+ <div
101
+ role="separator"
102
+ aria-orientation="vertical"
103
+ aria-label="Resize rail"
104
+ onPointerDown={start}
105
+ onDoubleClick={() => onChange(null)}
106
+ className={`absolute top-0 hidden h-full w-1.5 cursor-col-resize hover:bg-oq-08 lg:block ${side === 'end' ? 'right-0' : 'left-0'}`}
107
+ />
108
+ )
109
+ }
110
+
111
+ const NavColumn = ({ children, grip }) => (
83
112
  <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">
84
113
  {children}
114
+ {grip}
85
115
  </aside>
86
116
  )
87
117
 
@@ -118,8 +148,9 @@ const MainColumn = ({ children, fullHeight, width = 'canvas', padStart, padEnd }
118
148
  /* `xl`, not `lg` — the grid only declares a third column at xl (gridCols
119
149
  * below). The breakpoint here and the one in gridCols are one decision and
120
150
  * must not be stated twice differently. */
121
- const TocColumn = ({ children }) => (
151
+ const TocColumn = ({ children, grip }) => (
122
152
  <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">
153
+ {grip}
123
154
  {/* The width is the grid track (--kol-shell-toc-w). An empty rail still
124
155
  * holds its column (user ruling 2026-08-01): a rail that disappears
125
156
  * re-flows main and the same page ends up at two widths. */}
@@ -156,12 +187,18 @@ const toSearchItem = (item, i) => ({
156
187
  * @param {string|Function} [brandLabel] a TYPED label in the second wordmark slot instead of
157
188
  * the drawn WORKSHOP mark — a string, or `({ activeRoute }) => string`
158
189
  * so it names the space you are in
190
+ * @param {Array} [shortcuts] the consumer's own keys, `{ id, label, combo, key, run }` — listed
191
+ * in the `S` sheet and bound by the same handler (2026-09-30)
159
192
  */
160
- const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoSrc, brandLogoAlt = '', renderSidebar, searchItems, searchPath, settings = [], brandLabel, defaultTocContent, isActive: isActiveProp, actions }) => {
193
+ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoSrc, brandLogoAlt = '', renderSidebar, searchItems, searchPath, settings = [], brandLabel, defaultTocContent, isActive: isActiveProp, actions, shortcuts = [] }) => {
161
194
  const [isNavDrawerOpen, setIsNavDrawerOpen] = useState(false)
162
195
  const [prefs, setPrefs] = useState(readSettings)
163
196
  const setPref = (key, value) => setPrefs((p) => { const next = { ...p, [key]: value }; writeSettings(next); return next })
164
197
  const [navCollapsed, setNavCollapsed] = useState(() => readSettings().navHidden === true)
198
+ /* rail widths — null = the shell's token; a number is the viewer's own (remembered) */
199
+ const [navW, setNavW] = useState(() => readSettings().navW ?? null)
200
+ const [tocW, setTocW] = useState(() => readSettings().tocW ?? null)
201
+ const setRailW = (key, set) => (w) => { set(w); writeSettings({ ...readSettings(), [key]: w }) }
165
202
  const [tocCollapsed, setTocCollapsed] = useState(() => readSettings().tocHidden === true)
166
203
  const [isSettingsOpen, setIsSettingsOpen] = useState(false)
167
204
  /* ONE QUERY (user ruling 2026-08-01). The palette's text used to live here
@@ -224,9 +261,17 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
224
261
  { section: 'Shell', items: [
225
262
  { id: 'sheet', label: 'This sheet', combo: 'S' },
226
263
  { id: 'settings', label: 'Settings', combo: ',' },
264
+ { id: 'nav', label: 'Left rail', combo: '[' },
265
+ { id: 'toc', label: 'Right rail', combo: ']' },
266
+ { id: 'fold', label: 'Fold or open every chapter', combo: 'C' },
227
267
  { id: 'esc', label: 'Close what is open', combo: 'Esc' },
228
268
  ] },
269
+ ...(shortcuts.length ? [{ section: 'Page', items: shortcuts.map(({ id, label, combo }) => ({ id, label, combo })) }] : []),
229
270
  ]
271
+ /* the handler binds once; the consumer's list and the fold state are read through refs */
272
+ const shortcutsRef = useRef(shortcuts)
273
+ shortcutsRef.current = shortcuts
274
+ const foldedRef = useRef(false)
230
275
 
231
276
  useEffect(() => {
232
277
  const handleKeyDown = (e) => {
@@ -249,6 +294,19 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
249
294
  } else if (e.key === ',') {
250
295
  e.preventDefault()
251
296
  setIsSettingsOpen((v) => !v)
297
+ } else if (e.key === '[') {
298
+ e.preventDefault()
299
+ setNavCollapsed((v) => { writeSettings({ ...readSettings(), navHidden: !v }); return !v })
300
+ } else if (e.key === ']') {
301
+ e.preventDefault()
302
+ setTocCollapsed((v) => { writeSettings({ ...readSettings(), tocHidden: !v }); return !v })
303
+ } else if (e.key === 'c' || e.key === 'C') {
304
+ e.preventDefault()
305
+ foldedRef.current = !foldedRef.current
306
+ window.dispatchEvent(new CustomEvent(RAIL_FOLD_EVENT, { detail: { collapsed: foldedRef.current } }))
307
+ } else if (shortcutsRef.current.some((s) => s.key === e.key)) {
308
+ e.preventDefault()
309
+ shortcutsRef.current.find((s) => s.key === e.key).run()
252
310
  } else if (e.key === 'Escape') {
253
311
  setIsShortcutsOpen(false)
254
312
  }
@@ -344,13 +402,13 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
344
402
  const searchTrigger = (
345
403
  <>
346
404
  {actions}
347
- <Tooltip label="Search · ⌘K or /">
405
+ <Tooltip label="Search">
348
406
  {/* `md` + full-ink nav (user re-rule 2026-08-09; repeals the 2026-08-01
349
407
  * lg ruling). The row law is unchanged: every header glyph on ONE
350
408
  * rung, one variant — the rung is just md now. */}
351
409
  <IconFrame name="search" variant="nav" size="md" onClick={() => setIsSearchOpen(true)} aria-label="Search" />
352
410
  </Tooltip>
353
- <Tooltip label="Settings · ,">
411
+ <Tooltip label="Settings">
354
412
  <IconFrame name="settings-01" variant="nav" size="md" onClick={() => setIsSettingsOpen(true)} aria-label="Settings" />
355
413
  </Tooltip>
356
414
  </>
@@ -407,13 +465,17 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
407
465
  <ShellContentWidthContext.Provider value={setContentWidth}>
408
466
  <ShellTocCollapsedContext.Provider value={setTocCollapsed}>
409
467
  <ShellNavCollapsedContext.Provider value={setNavCollapsed}>
410
- <div className="fixed inset-0 flex flex-col bg-surface-primary text-auto">
468
+ <div
469
+ className="fixed inset-0 flex flex-col bg-surface-primary text-auto"
470
+ style={{ ...(navW ? { '--kol-shell-nav-w': `${navW}px` } : {}), ...(tocW ? { '--kol-shell-toc-w': `${tocW}px` } : {}) }}
471
+ >
411
472
  <ShellHeader
412
473
  brand={brand}
413
474
  nav={navItems}
414
475
  isActive={isActive}
415
476
  onNavigate={handleNavigate}
416
477
  actions={searchTrigger}
478
+ menuBelowLg
417
479
  onMenuClick={() => {
418
480
  if (window.matchMedia('(min-width: 1024px)').matches) {
419
481
  /* Both rails to ONE target state — independent `!p` flips made
@@ -441,7 +503,7 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
441
503
  >
442
504
  <div className={`shell-content-grid grid ${gridCols}`} data-layout={layoutType} style={{ paddingInline: 'var(--kol-pad-chrome-x)' }}>
443
505
  {showNav && (
444
- <NavColumn>
506
+ <NavColumn grip={<RailGrip side="end" width={navW ?? 256} onChange={setRailW('navW', setNavW)} />}>
445
507
  {renderSidebar ? renderSidebar({ activeRoute }) : <ShellSidebar routes={routes} basePath={basePath} />}
446
508
  </NavColumn>
447
509
  )}
@@ -459,7 +521,7 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
459
521
  * the grid's third track so main keeps one width across
460
522
  * every route (user ruling 2026-08-01). */}
461
523
  {showToc && (
462
- <TocColumn>
524
+ <TocColumn grip={<RailGrip side="start" width={tocW ?? 256} onChange={setRailW('tocW', setTocW)} />}>
463
525
  {effectiveTocContent}
464
526
  </TocColumn>
465
527
  )}
@@ -24,6 +24,8 @@ const getChildPath = (child, basePath) => {
24
24
  return p.startsWith('/') ? p : `${basePath}/${p}`
25
25
  }
26
26
 
27
+ export const RAIL_FOLD_EVENT = 'kol-rail-fold'
28
+
27
29
  const ShellSidebar = ({ routes = [], basePath = '/', onNavigate, label = 'Navigation', labelTo, collapsed, onToggle, defaultCollapsed = false }) => {
28
30
  const location = useLocation()
29
31
  const normalizedPath = location.pathname.replace(/\/$/, '')
@@ -69,6 +71,14 @@ const ShellSidebar = ({ routes = [], basePath = '/', onNavigate, label = 'Naviga
69
71
  // eslint-disable-next-line react-hooks/exhaustive-deps
70
72
  }, [normalizedPath, routes, basePath])
71
73
 
74
+ /* FOLD ALL (2026-09-30, the names audit: *"shortcut collapse/expand the categories"*). The
75
+ * shell's `C` key broadcasts one target state; every rail's chapters take it. */
76
+ useEffect(() => {
77
+ const onFold = (e) => setCollapsedSections(Object.fromEntries(routes.map((r) => [r.id, !!e.detail?.collapsed])))
78
+ window.addEventListener(RAIL_FOLD_EVENT, onFold)
79
+ return () => window.removeEventListener(RAIL_FOLD_EVENT, onFold)
80
+ }, [routes])
81
+
72
82
  const handleSectionClick = (route) => {
73
83
  setCollapsedSections((prev) => ({ ...prev, [route.id]: !prev[route.id] }))
74
84
  }