@kolkrabbi/kol-workshop 0.5.0 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-workshop",
3
- "version": "0.5.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",
@@ -13,11 +13,11 @@
13
13
  },
14
14
  "dependencies": {
15
15
  "d3": "^7.9.0",
16
- "@kolkrabbi/kol-icons": "0.8.11",
17
- "@kolkrabbi/kol-component": "0.14.3",
18
16
  "@kolkrabbi/kol-brand": "^0.1.2",
19
- "@kolkrabbi/kol-theme": "0.13.3",
20
- "@kolkrabbi/kol-framework": "0.9.1"
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
- {children}
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 ((e.altKey) && e.key === 'b') {
94
+ } else if (e.key === '?') {
76
95
  e.preventDefault()
77
- setIsSearchOpen(true)
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
- /* KNOWN, deliberately not fixed here: an element is always truthy, so
86
- * `<AutoToc/>` reserves a rail even on pages where it renders null. The
87
- * obvious fix — measure the mounted column — deadlocks: once the column
88
- * unmounts the probe is gone and can never report content again. The real
89
- * fix is a consumer-side signal (a render-prop returning null, or an
90
- * explicit `hasToc` prop), which is an API change. Filed, not improvised.
91
- * The bug this caused — a half-height main column at 1024–1279px — was the
92
- * `lg` vs `xl` mismatch on TocColumn above, and that IS fixed. */
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)_224px]'
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)_224px]'
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
- if (item?.href) navigate(item.href)
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
- {hasFilters && (
42
- <Tooltip label={viewMode === 'graph' ? 'List view' : 'Graph view'}>
43
- <Button
44
- variant="outline"
45
- quiet
46
- size="sm"
47
- iconOnly={viewMode === 'graph' ? 'view-list' : 'polygon'}
48
- onClick={() => setViewMode(viewMode === 'graph' ? 'list' : 'graph')}
49
- aria-label={viewMode === 'graph' ? 'List view' : 'Graph view'}
50
- />
51
- </Tooltip>
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 && viewMode === 'graph' ? (
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)}
@@ -157,9 +167,15 @@ const TagModeOverlay = () => {
157
167
  {hasFilters && filteredDocs.length > 0 && (
158
168
  <div className="flex flex-col pt-4 border-t border-fg-08">
159
169
  {filteredDocs.map((d) => (
170
+ /* `d.href` wins over `docHref(d.id)` so ONE tag system can
171
+ * span content types that live in different URL spaces —
172
+ * a vault doc at /documentation/:id and a component page
173
+ * at /components/:slug. Without it the inventory could
174
+ * only ever hold one kind of thing, which is why the
175
+ * graph saw the vault's 46 docs and nothing else. */
160
176
  <Link
161
177
  key={d.id}
162
- to={docHref(d.id)}
178
+ to={d.href ?? docHref(d.id)}
163
179
  className="tag-list-item"
164
180
  onClick={closeTagMode}
165
181
  >