@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 +8 -8
- package/src/docs/DocsFrontmatter.jsx +3 -3
- package/src/docs/DocumentationReader.jsx +11 -3
- package/src/search/ResultRow.jsx +33 -0
- package/src/search/SearchPage.jsx +12 -9
- package/src/search/index.js +1 -0
- package/src/shell/RailRow.jsx +1 -1
- package/src/shell/RailSection.jsx +1 -1
- package/src/shell/RightRail.jsx +71 -33
- package/src/shell/ShellLayout.jsx +72 -10
- package/src/shell/ShellSidebar.jsx +10 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-workshop",
|
|
3
|
-
"version": "0.
|
|
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-
|
|
17
|
-
"@kolkrabbi/kol-markdown": "^0.1.
|
|
18
|
-
"@kolkrabbi/kol-
|
|
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.
|
|
31
|
-
"@kolkrabbi/kol-icons": "^0.
|
|
32
|
-
"@kolkrabbi/kol-theme": "^0.
|
|
33
|
-
"@kolkrabbi/kol-framework": "^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
|
-
*
|
|
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
|
|
207
|
+
* only `tag-control` has a `:hover` rule — so a colored Tag
|
|
208
208
|
* silently loses its interaction state. Right component first;
|
|
209
|
-
*
|
|
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 {
|
|
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
|
-
<
|
|
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
|
-
<
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
{r.item.
|
|
131
|
-
|
|
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>
|
package/src/search/index.js
CHANGED
package/src/shell/RailRow.jsx
CHANGED
|
@@ -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,
|
|
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
|
|
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.
|
package/src/shell/RightRail.jsx
CHANGED
|
@@ -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
|
-
/*
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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('
|
|
119
|
-
|
|
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
|
-
{
|
|
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
|
-
{
|
|
133
|
-
<
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
}
|