@kolkrabbi/kol-workshop 0.5.1 → 0.6.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 +5 -5
- package/src/shell/ShellLayout.jsx +99 -14
- package/src/tags/TagModeOverlay.jsx +24 -14
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-workshop",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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",
|
|
@@ -14,10 +14,10 @@
|
|
|
14
14
|
"dependencies": {
|
|
15
15
|
"d3": "^7.9.0",
|
|
16
16
|
"@kolkrabbi/kol-brand": "^0.1.2",
|
|
17
|
-
"@kolkrabbi/kol-
|
|
18
|
-
"@kolkrabbi/kol-framework": "0.
|
|
19
|
-
"@kolkrabbi/kol-theme": "0.13.
|
|
20
|
-
"@kolkrabbi/kol-
|
|
17
|
+
"@kolkrabbi/kol-component": "0.15.1",
|
|
18
|
+
"@kolkrabbi/kol-framework": "0.10.1",
|
|
19
|
+
"@kolkrabbi/kol-theme": "0.13.4",
|
|
20
|
+
"@kolkrabbi/kol-icons": "0.8.11"
|
|
21
21
|
},
|
|
22
22
|
"peerDependencies": {
|
|
23
23
|
"react": "^18.3.0 || ^19.0.0",
|
|
@@ -52,7 +52,11 @@ const MainColumn = ({ children, fullHeight }) => (
|
|
|
52
52
|
* decision and must not be stated twice differently. */
|
|
53
53
|
const TocColumn = ({ children }) => (
|
|
54
54
|
<aside aria-label="Table of contents" className="shell-sidebar-sticky hidden xl:block shrink-0 h-full min-h-0 overflow-y-auto overflow-x-hidden overscroll-none pt-6 md:pt-6 lg:pt-8 pb-8">
|
|
55
|
-
{
|
|
55
|
+
{/* The 224px lives HERE, not on the grid track (see gridCols). An inner
|
|
56
|
+
* wrapper with a width means a rail whose content renders null measures
|
|
57
|
+
* zero and the column collapses — the page gets the space back instead of
|
|
58
|
+
* staring at a reserved empty gutter. */}
|
|
59
|
+
<div className="w-56 empty:hidden">{children}</div>
|
|
56
60
|
</aside>
|
|
57
61
|
)
|
|
58
62
|
|
|
@@ -64,17 +68,34 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
|
|
|
64
68
|
const [searchQuery, setSearchQuery] = useState('')
|
|
65
69
|
const [tocContent, setTocContent] = useState(null)
|
|
66
70
|
const [isFullHeight, setIsFullHeight] = useState(false)
|
|
71
|
+
const [isShortcutsOpen, setIsShortcutsOpen] = useState(false)
|
|
67
72
|
const location = useLocation()
|
|
68
73
|
const navigate = useNavigate()
|
|
69
74
|
|
|
75
|
+
/* THE shortcut map. One list, rendered by the `?` overlay AND bound by the
|
|
76
|
+
* handler below — a shortcut that isn't in this array doesn't exist, so the
|
|
77
|
+
* help sheet can never drift from the bindings. Before this there was no
|
|
78
|
+
* help UI at all: ⌘K was an unlabelled icon button and Alt+B was a duplicate
|
|
79
|
+
* nobody had written down anywhere. */
|
|
80
|
+
const SHORTCUTS = [
|
|
81
|
+
{ keys: ['⌘', 'K'], label: 'Search everything', match: (e) => (e.metaKey || e.ctrlKey) && e.key === 'k' },
|
|
82
|
+
{ keys: ['?'], label: 'This list', match: (e) => e.key === '?' },
|
|
83
|
+
{ keys: ['Esc'], label: 'Close what is open', match: () => false, note: 'handled per surface' },
|
|
84
|
+
]
|
|
85
|
+
|
|
70
86
|
useEffect(() => {
|
|
71
87
|
const handleKeyDown = (e) => {
|
|
88
|
+
/* never steal a key from a field the user is typing in */
|
|
89
|
+
const t = e.target
|
|
90
|
+
if (t?.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t?.tagName ?? '')) return
|
|
72
91
|
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
|
73
92
|
e.preventDefault()
|
|
74
93
|
setIsSearchOpen(true)
|
|
75
|
-
} else if (
|
|
94
|
+
} else if (e.key === '?') {
|
|
76
95
|
e.preventDefault()
|
|
77
|
-
|
|
96
|
+
setIsShortcutsOpen((v) => !v)
|
|
97
|
+
} else if (e.key === 'Escape') {
|
|
98
|
+
setIsShortcutsOpen(false)
|
|
78
99
|
}
|
|
79
100
|
}
|
|
80
101
|
window.addEventListener('keydown', handleKeyDown)
|
|
@@ -82,26 +103,33 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
|
|
|
82
103
|
}, [])
|
|
83
104
|
|
|
84
105
|
const effectiveTocContent = tocContent ?? defaultTocContent
|
|
85
|
-
/*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
* unmounts the probe is gone and can never
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
106
|
+
/* An element is truthy even when the component inside it renders nothing, so
|
|
107
|
+
* this alone reserved a 224px rail on every route — including ones with no
|
|
108
|
+
* headings. Three fixes were rejected before this one: measuring the mounted
|
|
109
|
+
* column DEADLOCKS (once the column unmounts the probe is gone and can never
|
|
110
|
+
* report content again); calling a render-prop inline makes the consumer's
|
|
111
|
+
* hooks belong to THIS component, one refactor away from a crash; and a
|
|
112
|
+
* boolean prop puts the answer in the consumer, which doesn't know either.
|
|
113
|
+
*
|
|
114
|
+
* The column decides for itself instead. The track is `auto`, not a fixed
|
|
115
|
+
* 224px, and the width lives on the rail's CONTENT — so a rail that renders
|
|
116
|
+
* nothing collapses to zero and the main column takes the space back. No
|
|
117
|
+
* signal to keep in sync, nothing to deadlock. */
|
|
93
118
|
const hasToc = Boolean(effectiveTocContent)
|
|
94
119
|
const showNav = !navCollapsed
|
|
95
120
|
const showToc = hasToc && !tocCollapsed
|
|
96
121
|
|
|
97
122
|
const layoutType = showNav && showToc ? 'nav-toc' : showNav ? 'nav' : showToc ? 'toc' : 'none'
|
|
98
123
|
|
|
124
|
+
/* `auto`, not `224px` — see hasToc above. The width is declared on the rail's
|
|
125
|
+
* content (TocColumn's inner wrapper), so a rail with nothing in it measures
|
|
126
|
+
* zero and the main column reclaims the space. */
|
|
99
127
|
const gridCols = showNav
|
|
100
128
|
? showToc
|
|
101
|
-
? 'lg:grid-cols-[256px_minmax(0,1fr)] xl:grid-cols-[256px_minmax(0,1fr)
|
|
129
|
+
? 'lg:grid-cols-[256px_minmax(0,1fr)] xl:grid-cols-[256px_minmax(0,1fr)_auto]'
|
|
102
130
|
: 'lg:grid-cols-[256px_minmax(0,1fr)]'
|
|
103
131
|
: showToc
|
|
104
|
-
? 'xl:grid-cols-[minmax(0,1fr)
|
|
132
|
+
? 'xl:grid-cols-[minmax(0,1fr)_auto]'
|
|
105
133
|
: ''
|
|
106
134
|
|
|
107
135
|
// Adapt the old flat `routes` + callbacks to the DS ShellHeader API
|
|
@@ -162,6 +190,12 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
|
|
|
162
190
|
group: item.sectionLabel ?? item.group,
|
|
163
191
|
hint: item.matchedHeading ?? item.matchedKeyword ?? item.hint,
|
|
164
192
|
href: item.href,
|
|
193
|
+
/* `action` must survive this reshape. It didn't: the map rebuilt every row
|
|
194
|
+
* as a fixed five-field object, so a consumer's action closure was dropped
|
|
195
|
+
* between the engine and onSelect and the row silently did nothing. The
|
|
196
|
+
* engine spreads `...item` and the overlay passes rows through untouched —
|
|
197
|
+
* this projection was the only lossy step in the chain. */
|
|
198
|
+
action: item.action,
|
|
165
199
|
}))
|
|
166
200
|
|
|
167
201
|
return (
|
|
@@ -261,10 +295,61 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
|
|
|
261
295
|
onSelect={(item) => {
|
|
262
296
|
setIsSearchOpen(false)
|
|
263
297
|
setSearchQuery('')
|
|
264
|
-
|
|
298
|
+
/* `action` before `href`: not every hit is a destination. A tag
|
|
299
|
+
* row toggles state rather than navigating, and without this the
|
|
300
|
+
* palette could only ever hold things that live at a URL — which
|
|
301
|
+
* is why the tag search had to be a second, separate search box
|
|
302
|
+
* that knew nothing about this one. The consumer supplies the
|
|
303
|
+
* closure; the shell stays ignorant of what it does. */
|
|
304
|
+
if (typeof item?.action === 'function') item.action()
|
|
305
|
+
else if (item?.href) navigate(item.href)
|
|
265
306
|
}}
|
|
266
307
|
placeholder="Search…"
|
|
267
308
|
/>
|
|
309
|
+
|
|
310
|
+
{/* The `?` sheet. Rendered FROM the SHORTCUTS array above, so the help
|
|
311
|
+
* and the bindings are one source — a shortcut can't be documented
|
|
312
|
+
* and unbound, or bound and undocumented, which is how Alt+B lived
|
|
313
|
+
* for months. */}
|
|
314
|
+
{isShortcutsOpen && (
|
|
315
|
+
<div
|
|
316
|
+
className="fixed inset-0 z-50 flex items-center justify-center bg-fg-48"
|
|
317
|
+
onClick={() => setIsShortcutsOpen(false)}
|
|
318
|
+
role="presentation"
|
|
319
|
+
>
|
|
320
|
+
<div
|
|
321
|
+
className="kol-doc-figure w-[min(28rem,90vw)] bg-surface-primary"
|
|
322
|
+
onClick={(e) => e.stopPropagation()}
|
|
323
|
+
role="dialog"
|
|
324
|
+
aria-modal="true"
|
|
325
|
+
aria-label="Keyboard shortcuts"
|
|
326
|
+
>
|
|
327
|
+
<div
|
|
328
|
+
className="flex items-center justify-between border-b px-4 py-3"
|
|
329
|
+
style={{ borderBottomColor: 'var(--kol-oq-08)' }}
|
|
330
|
+
>
|
|
331
|
+
<span className="kol-doc-eyebrow text-meta">Keyboard shortcuts</span>
|
|
332
|
+
<Button variant="outline" quiet size="sm" iconOnly="x"
|
|
333
|
+
aria-label="Close" onClick={() => setIsShortcutsOpen(false)} />
|
|
334
|
+
</div>
|
|
335
|
+
<ul className="flex flex-col px-4 py-3">
|
|
336
|
+
{SHORTCUTS.map((s) => (
|
|
337
|
+
<li key={s.label} className="flex items-center justify-between gap-6 py-1.5">
|
|
338
|
+
<span className="kol-mono-14 text-body">
|
|
339
|
+
{s.label}
|
|
340
|
+
{s.note && <span className="kol-helper-12 text-subtle"> · {s.note}</span>}
|
|
341
|
+
</span>
|
|
342
|
+
<span className="flex items-center gap-1">
|
|
343
|
+
{s.keys.map((k) => (
|
|
344
|
+
<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>
|
|
345
|
+
))}
|
|
346
|
+
</span>
|
|
347
|
+
</li>
|
|
348
|
+
))}
|
|
349
|
+
</ul>
|
|
350
|
+
</div>
|
|
351
|
+
</div>
|
|
352
|
+
)}
|
|
268
353
|
</div>
|
|
269
354
|
</ShellTocCollapsedContext.Provider>
|
|
270
355
|
</ShellFullHeightContext.Provider>
|
|
@@ -38,18 +38,24 @@ const TagModeOverlay = () => {
|
|
|
38
38
|
* panel token exists to settle (kol-theme "Content widths"). */}
|
|
39
39
|
<div className="mx-auto max-w-[var(--kol-content-panel)]">
|
|
40
40
|
<div className="flex items-center justify-start gap-1 mb-3">
|
|
41
|
-
{
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
41
|
+
{/* ALWAYS present (2026-07-30). This was gated on `hasFilters`, so
|
|
42
|
+
* the graph control did not exist in the DOM until a tag was already
|
|
43
|
+
* active — you had to know it was there to make it appear. The graph
|
|
44
|
+
* is the whole point of the co-occurrence data; hiding its only
|
|
45
|
+
* affordance behind a state you reach by accident is why the user
|
|
46
|
+
* asked "where is the NODE GRAPH? how do I open it?". With no tags
|
|
47
|
+
* active the graph simply shows the full map, which is the view a
|
|
48
|
+
* first-time reader wants most. */}
|
|
49
|
+
<Tooltip label={viewMode === 'graph' ? 'List view' : 'Graph view'}>
|
|
50
|
+
<Button
|
|
51
|
+
variant="outline"
|
|
52
|
+
quiet
|
|
53
|
+
size="sm"
|
|
54
|
+
iconOnly={viewMode === 'graph' ? 'view-list' : 'polygon'}
|
|
55
|
+
onClick={() => setViewMode(viewMode === 'graph' ? 'list' : 'graph')}
|
|
56
|
+
aria-label={viewMode === 'graph' ? 'List view' : 'Graph view'}
|
|
57
|
+
/>
|
|
58
|
+
</Tooltip>
|
|
53
59
|
<Tooltip label="Close tag mode">
|
|
54
60
|
<Button
|
|
55
61
|
variant="outline"
|
|
@@ -125,10 +131,14 @@ const TagModeOverlay = () => {
|
|
|
125
131
|
</>
|
|
126
132
|
)}
|
|
127
133
|
|
|
128
|
-
{hasFilters
|
|
134
|
+
{/* `hasFilters` dropped from this condition too — with no tag
|
|
135
|
+
* active the graph renders the WHOLE map, which is the view worth
|
|
136
|
+
* opening cold. Gated, the button could be clicked and nothing
|
|
137
|
+
* changed. */}
|
|
138
|
+
{viewMode === 'graph' ? (
|
|
129
139
|
<div>
|
|
130
140
|
<TagGraph
|
|
131
|
-
docs={filteredDocs}
|
|
141
|
+
docs={hasFilters ? filteredDocs : inventory}
|
|
132
142
|
allDocs={inventory}
|
|
133
143
|
activeTag={activeTag}
|
|
134
144
|
onTagClick={(tag) => toggleTag(tag)}
|