@kolkrabbi/kol-workshop 0.30.0 → 0.31.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 +28 -5
- package/src/shell/ShellSidebar.jsx +10 -0
- package/src/tags/TagGraph.jsx +3 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-workshop",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.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-
|
|
18
|
-
"@kolkrabbi/kol-
|
|
16
|
+
"@kolkrabbi/kol-search": "^0.2.0",
|
|
17
|
+
"@kolkrabbi/kol-brand": "^0.1.3",
|
|
18
|
+
"@kolkrabbi/kol-markdown": "^0.1.2"
|
|
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-
|
|
31
|
-
"@kolkrabbi/kol-framework": "^0.
|
|
32
|
-
"@kolkrabbi/kol-theme": "^0.
|
|
33
|
-
"@kolkrabbi/kol-
|
|
30
|
+
"@kolkrabbi/kol-component": "^0.230.0",
|
|
31
|
+
"@kolkrabbi/kol-framework": "^0.46.0",
|
|
32
|
+
"@kolkrabbi/kol-theme": "^0.157.0",
|
|
33
|
+
"@kolkrabbi/kol-icons": "^0.29.1"
|
|
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'
|
|
@@ -156,8 +156,10 @@ const toSearchItem = (item, i) => ({
|
|
|
156
156
|
* @param {string|Function} [brandLabel] a TYPED label in the second wordmark slot instead of
|
|
157
157
|
* the drawn WORKSHOP mark — a string, or `({ activeRoute }) => string`
|
|
158
158
|
* so it names the space you are in
|
|
159
|
+
* @param {Array} [shortcuts] the consumer's own keys, `{ id, label, combo, key, run }` — listed
|
|
160
|
+
* in the `S` sheet and bound by the same handler (2026-09-30)
|
|
159
161
|
*/
|
|
160
|
-
const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoSrc, brandLogoAlt = '', renderSidebar, searchItems, searchPath, settings = [], brandLabel, defaultTocContent, isActive: isActiveProp, actions }) => {
|
|
162
|
+
const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoSrc, brandLogoAlt = '', renderSidebar, searchItems, searchPath, settings = [], brandLabel, defaultTocContent, isActive: isActiveProp, actions, shortcuts = [] }) => {
|
|
161
163
|
const [isNavDrawerOpen, setIsNavDrawerOpen] = useState(false)
|
|
162
164
|
const [prefs, setPrefs] = useState(readSettings)
|
|
163
165
|
const setPref = (key, value) => setPrefs((p) => { const next = { ...p, [key]: value }; writeSettings(next); return next })
|
|
@@ -224,9 +226,17 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
|
|
|
224
226
|
{ section: 'Shell', items: [
|
|
225
227
|
{ id: 'sheet', label: 'This sheet', combo: 'S' },
|
|
226
228
|
{ id: 'settings', label: 'Settings', combo: ',' },
|
|
229
|
+
{ id: 'nav', label: 'Left rail', combo: '[' },
|
|
230
|
+
{ id: 'toc', label: 'Right rail', combo: ']' },
|
|
231
|
+
{ id: 'fold', label: 'Fold or open every chapter', combo: 'C' },
|
|
227
232
|
{ id: 'esc', label: 'Close what is open', combo: 'Esc' },
|
|
228
233
|
] },
|
|
234
|
+
...(shortcuts.length ? [{ section: 'Page', items: shortcuts.map(({ id, label, combo }) => ({ id, label, combo })) }] : []),
|
|
229
235
|
]
|
|
236
|
+
/* the handler binds once; the consumer's list and the fold state are read through refs */
|
|
237
|
+
const shortcutsRef = useRef(shortcuts)
|
|
238
|
+
shortcutsRef.current = shortcuts
|
|
239
|
+
const foldedRef = useRef(false)
|
|
230
240
|
|
|
231
241
|
useEffect(() => {
|
|
232
242
|
const handleKeyDown = (e) => {
|
|
@@ -249,6 +259,19 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
|
|
|
249
259
|
} else if (e.key === ',') {
|
|
250
260
|
e.preventDefault()
|
|
251
261
|
setIsSettingsOpen((v) => !v)
|
|
262
|
+
} else if (e.key === '[') {
|
|
263
|
+
e.preventDefault()
|
|
264
|
+
setNavCollapsed((v) => { writeSettings({ ...readSettings(), navHidden: !v }); return !v })
|
|
265
|
+
} else if (e.key === ']') {
|
|
266
|
+
e.preventDefault()
|
|
267
|
+
setTocCollapsed((v) => { writeSettings({ ...readSettings(), tocHidden: !v }); return !v })
|
|
268
|
+
} else if (e.key === 'c' || e.key === 'C') {
|
|
269
|
+
e.preventDefault()
|
|
270
|
+
foldedRef.current = !foldedRef.current
|
|
271
|
+
window.dispatchEvent(new CustomEvent(RAIL_FOLD_EVENT, { detail: { collapsed: foldedRef.current } }))
|
|
272
|
+
} else if (shortcutsRef.current.some((s) => s.key === e.key)) {
|
|
273
|
+
e.preventDefault()
|
|
274
|
+
shortcutsRef.current.find((s) => s.key === e.key).run()
|
|
252
275
|
} else if (e.key === 'Escape') {
|
|
253
276
|
setIsShortcutsOpen(false)
|
|
254
277
|
}
|
|
@@ -344,13 +367,13 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
|
|
|
344
367
|
const searchTrigger = (
|
|
345
368
|
<>
|
|
346
369
|
{actions}
|
|
347
|
-
<Tooltip label="Search
|
|
370
|
+
<Tooltip label="Search">
|
|
348
371
|
{/* `md` + full-ink nav (user re-rule 2026-08-09; repeals the 2026-08-01
|
|
349
372
|
* lg ruling). The row law is unchanged: every header glyph on ONE
|
|
350
373
|
* rung, one variant — the rung is just md now. */}
|
|
351
374
|
<IconFrame name="search" variant="nav" size="md" onClick={() => setIsSearchOpen(true)} aria-label="Search" />
|
|
352
375
|
</Tooltip>
|
|
353
|
-
<Tooltip label="Settings
|
|
376
|
+
<Tooltip label="Settings">
|
|
354
377
|
<IconFrame name="settings-01" variant="nav" size="md" onClick={() => setIsSettingsOpen(true)} aria-label="Settings" />
|
|
355
378
|
</Tooltip>
|
|
356
379
|
</>
|
|
@@ -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
|
}
|
package/src/tags/TagGraph.jsx
CHANGED
|
@@ -2,7 +2,7 @@ import { useEffect, useRef, useMemo, useState } from 'react'
|
|
|
2
2
|
import * as d3 from 'd3'
|
|
3
3
|
import { useNavigate } from 'react-router-dom'
|
|
4
4
|
import { getTagColor } from '@kolkrabbi/kol-markdown'
|
|
5
|
-
import {
|
|
5
|
+
import { tagGraph } from '@kolkrabbi/kol-search'
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* TagGraph - Obsidian-style tag graph
|
|
@@ -26,7 +26,8 @@ const TagGraph = ({ docs, activeTag, onTagClick, allDocs, tagHref = defaultTagHr
|
|
|
26
26
|
const sourceDocs = allDocs || docs
|
|
27
27
|
|
|
28
28
|
// Compute graph data: ALL tags with connections (pure — see engine/tags.js)
|
|
29
|
-
|
|
29
|
+
/* the engine's graph (kol-search `tagGraph`, 2026-09-29) over the docs' tags */
|
|
30
|
+
const graphData = useMemo(() => tagGraph(sourceDocs.map((d) => ({ id: d.id, tags: d.metadata?.tags ?? [] }))), [sourceDocs])
|
|
30
31
|
|
|
31
32
|
// Update dimensions on resize
|
|
32
33
|
useEffect(() => {
|