@kolkrabbi/kol-workshop 0.32.0 → 0.34.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.32.0",
3
+ "version": "0.34.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,12 +13,12 @@
13
13
  },
14
14
  "dependencies": {
15
15
  "d3": "^7.9.0",
16
- "@kolkrabbi/kol-brand": "^0.1.3",
17
16
  "@kolkrabbi/kol-markdown": "^0.1.2",
18
- "@kolkrabbi/kol-search": "^0.2.0"
17
+ "@kolkrabbi/kol-search": "^0.2.0",
18
+ "@kolkrabbi/kol-brand": "^0.1.3"
19
19
  },
20
20
  "peerDependencies": {
21
- "@kolkrabbi/kol-component": ">=0.228.0",
21
+ "@kolkrabbi/kol-component": ">=0.231.0",
22
22
  "@kolkrabbi/kol-framework": ">=0.45.0",
23
23
  "@kolkrabbi/kol-theme": ">=0.155.0",
24
24
  "@kolkrabbi/kol-icons": ">=0.18.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.230.0",
31
- "@kolkrabbi/kol-icons": "^0.30.0",
32
- "@kolkrabbi/kol-theme": "^0.158.0",
33
- "@kolkrabbi/kol-framework": "^0.47.0"
30
+ "@kolkrabbi/kol-icons": "^0.31.0",
31
+ "@kolkrabbi/kol-component": "^0.231.0",
32
+ "@kolkrabbi/kol-theme": "^0.159.0",
33
+ "@kolkrabbi/kol-framework": "^0.48.0"
34
34
  },
35
35
  "files": [
36
36
  "src",
@@ -85,6 +85,9 @@ const DocumentationReader = ({
85
85
  routes = {},
86
86
  docId: docIdProp,
87
87
  showFrontmatter = true,
88
+ /* false → render the article only and leave the right rail to the page (a HOME sits on top of
89
+ * a page whose sections the rail must list — 2026-09-30, "This page (0)" on every home) */
90
+ rail = true,
88
91
  }) => {
89
92
  const {
90
93
  docsIndex = '/docs',
@@ -309,7 +312,7 @@ const DocumentationReader = ({
309
312
  * ShellTocContext then defaults to null, so calling it unguarded threw.
310
313
  * No shell → no right rail, and the reader just renders its article. */
311
314
  useEffect(() => {
312
- if (!setTocContent) return undefined
315
+ if (!setTocContent || !rail) return undefined
313
316
  setTocContent(
314
317
  <DocReaderSidebar
315
318
  key={docId}
@@ -324,7 +327,7 @@ const DocumentationReader = ({
324
327
  />
325
328
  )
326
329
  return () => setTocContent(null)
327
- }, [setTocContent, docId, toc, allTags, topTags, related, docsIndex, components, docFilePath])
330
+ }, [rail, setTocContent, docId, toc, allTags, topTags, related, docsIndex, components, docFilePath])
328
331
 
329
332
  if (!doc) {
330
333
  return (
@@ -1,5 +1,6 @@
1
1
  import { useState } from 'react'
2
2
  import { Link } from 'react-router-dom'
3
+ import { Icon } from '@kolkrabbi/kol-component'
3
4
 
4
5
  /**
5
6
  * RailSection — THE rail ladder. One component owns every rail header, at
@@ -70,7 +71,7 @@ export default function RailSection({
70
71
  onToggle,
71
72
  defaultCollapsed = false,
72
73
  onNavigate,
73
- icon: IconComponent,
74
+ icon: IconComponent = Icon,
74
75
  children,
75
76
  }) {
76
77
  const [internal, setInternal] = useState(defaultCollapsed)
@@ -120,14 +121,16 @@ export default function RailSection({
120
121
  * the eyebrow felt expand-only. A category's door lives in the header tabs.
121
122
  * L2 keeps the split (2026-08-02 ruling): its label is a chapter's only
122
123
  * door to the landing page, and the chevron marks the toggle. */
123
- const labelNode = to && level === 2 ? (
124
+ /* L1 JOINS L2 (2026-09-30, user: every label opens its home — *"components from the top and
125
+ * components go to the same components home"*; reverses the 2026-08-09 pure-toggle eyebrow). */
126
+ const labelNode = to ? (
124
127
  /* The label navigates; the rest of the row toggles. stopPropagation keeps
125
128
  * one click from doing both. */
126
129
  <Link
127
130
  to={to}
128
131
  onClick={(e) => {
132
+ /* the label opens the page, the chevron folds (audit § Homes) */
129
133
  e.stopPropagation()
130
- if (isCollapsed && toggle) toggle()
131
134
  if (onNavigate) onNavigate(e)
132
135
  }}
133
136
  >
@@ -2,7 +2,7 @@ import { createContext, useContext, useMemo, useRef, useState, useEffect, Suspen
2
2
  import { Outlet, Link, useLocation, useNavigate } from 'react-router-dom'
3
3
  import { ShellHeader } from '@kolkrabbi/kol-framework'
4
4
  import ShellSidebar, { RAIL_FOLD_EVENT } from './ShellSidebar.jsx'
5
- import { IconFrame, ShellDrawer, ShellSearchOverlay, ShortcutsOverlay, SettingsSections, SettingsSwitch, Tooltip } from '@kolkrabbi/kol-component'
5
+ import { IconFrame, ShellDrawer, ShellSearchOverlay, ShortcutsOverlay, SettingsPanel, SettingsSections, SettingsSwitch, Tooltip, useDragResize } from '@kolkrabbi/kol-component'
6
6
  import { createIndex, search } from '@kolkrabbi/kol-search'
7
7
  import { useTagMode } from '../tags/TagModeContext.jsx'
8
8
  import TagModeOverlay from '../tags/TagModeOverlay.jsx'
@@ -79,37 +79,27 @@ 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
- /* 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
- )
82
+ /* RESIZABLE RAILS (2026-09-30) — THE DS GESTURE, not a local one: `useDragResize` (the grab edge
83
+ * SideNav and EditorShell wear — drag resizes, a click or a drag under the snap collapses, arrows
84
+ * step, release near the default snaps back) and the `.kol-rail-grab` pill. Each rail has its own
85
+ * token family (`kol-shell-nav` · `kol-shell-toc`, tokens in kol-framework.css) so the two never
86
+ * drag together; width lasts the session (persistWidth off, the 2026-09-03 ruling). A collapse
87
+ * from the grab is the shell's own "hide the rail" — the same state `[` / `]` toggle. */
88
+ const useRailGrab = (token, side, hide) => {
89
+ const ref = useRef(null)
90
+ const { collapsed, toggleCollapsed, grabProps } = useDragResize(ref, { token, side })
91
+ useEffect(() => {
92
+ if (!collapsed) return
93
+ hide()
94
+ toggleCollapsed() // un-stamp the hook's collapse — the shell's own state now holds it
95
+ document.documentElement.style.removeProperty(`--${token}-w`) // it returns at the stylesheet width
96
+ // eslint-disable-next-line react-hooks/exhaustive-deps
97
+ }, [collapsed])
98
+ return { ref, grab: <div {...grabProps} className={`kol-rail-grab kol-rail-grab--${side} hidden lg:block`} /> }
109
99
  }
110
100
 
111
- const NavColumn = ({ children, grip }) => (
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">
101
+ const NavColumn = ({ children, grip, railRef }) => (
102
+ <aside ref={railRef} 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">
113
103
  {children}
114
104
  {grip}
115
105
  </aside>
@@ -148,8 +138,8 @@ const MainColumn = ({ children, fullHeight, width = 'canvas', padStart, padEnd }
148
138
  /* `xl`, not `lg` — the grid only declares a third column at xl (gridCols
149
139
  * below). The breakpoint here and the one in gridCols are one decision and
150
140
  * must not be stated twice differently. */
151
- const TocColumn = ({ children, grip }) => (
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">
141
+ const TocColumn = ({ children, grip, railRef }) => (
142
+ <aside ref={railRef} 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
143
  {grip}
154
144
  {/* The width is the grid track (--kol-shell-toc-w). An empty rail still
155
145
  * holds its column (user ruling 2026-08-01): a rail that disappears
@@ -195,11 +185,10 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
195
185
  const [prefs, setPrefs] = useState(readSettings)
196
186
  const setPref = (key, value) => setPrefs((p) => { const next = { ...p, [key]: value }; writeSettings(next); return next })
197
187
  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 }) }
188
+
202
189
  const [tocCollapsed, setTocCollapsed] = useState(() => readSettings().tocHidden === true)
190
+ const navGrab = useRailGrab('kol-shell-nav', 'left', () => { setNavCollapsed(true); writeSettings({ ...readSettings(), navHidden: true }) })
191
+ const tocGrab = useRailGrab('kol-shell-toc', 'right', () => { setTocCollapsed(true); writeSettings({ ...readSettings(), tocHidden: true }) })
203
192
  const [isSettingsOpen, setIsSettingsOpen] = useState(false)
204
193
  /* ONE QUERY (user ruling 2026-08-01). The palette's text used to live here
205
194
  * while tags lived in TagModeContext — two states, and therefore two
@@ -254,8 +243,7 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
254
243
  * app — it was a `?` sheet of its own here. `?` still opens it. */
255
244
  const SHORTCUTS = [
256
245
  { section: 'Search', items: [
257
- { id: 'k', label: 'Search everything', combo: '⌘ K' },
258
- { id: 'slash', label: 'Search everything', combo: '/' },
246
+ { id: 'k', label: 'Search everything', combo: '⌘ K , /' },
259
247
  { id: 'enter', label: 'All results, on the search page', combo: '↵' },
260
248
  ] },
261
249
  { section: 'Shell', items: [
@@ -465,10 +453,7 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
465
453
  <ShellContentWidthContext.Provider value={setContentWidth}>
466
454
  <ShellTocCollapsedContext.Provider value={setTocCollapsed}>
467
455
  <ShellNavCollapsedContext.Provider value={setNavCollapsed}>
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
- >
456
+ <div className="fixed inset-0 flex flex-col bg-surface-primary text-auto">
472
457
  <ShellHeader
473
458
  brand={brand}
474
459
  nav={navItems}
@@ -503,7 +488,7 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
503
488
  >
504
489
  <div className={`shell-content-grid grid ${gridCols}`} data-layout={layoutType} style={{ paddingInline: 'var(--kol-pad-chrome-x)' }}>
505
490
  {showNav && (
506
- <NavColumn grip={<RailGrip side="end" width={navW ?? 256} onChange={setRailW('navW', setNavW)} />}>
491
+ <NavColumn railRef={navGrab.ref} grip={navGrab.grab}>
507
492
  {renderSidebar ? renderSidebar({ activeRoute }) : <ShellSidebar routes={routes} basePath={basePath} />}
508
493
  </NavColumn>
509
494
  )}
@@ -521,7 +506,7 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
521
506
  * the grid's third track so main keeps one width across
522
507
  * every route (user ruling 2026-08-01). */}
523
508
  {showToc && (
524
- <TocColumn grip={<RailGrip side="start" width={tocW ?? 256} onChange={setRailW('tocW', setTocW)} />}>
509
+ <TocColumn railRef={tocGrab.ref} grip={tocGrab.grab}>
525
510
  {effectiveTocContent}
526
511
  </TocColumn>
527
512
  )}
@@ -550,6 +535,8 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
550
535
  query={searchQuery}
551
536
  onQueryChange={setSearchQuery}
552
537
  results={searchResults}
538
+ /* an empty palette opens on the space table, not a blank box */
539
+ suggestions={navItems.map((n) => ({ id: `space:${n.href}`, label: n.label, href: n.href, icon: 'arrow-right', group: 'Spaces' }))}
553
540
  /* THE EXPANDED BODY (user ruling 2026-08-01). The tag browser is
554
541
  * not a sibling overlay — it is this palette's second state. Enter
555
542
  * commits the query and swaps the result rows for the full body;
@@ -588,13 +575,12 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
588
575
  * settings page or settings sidebar … to offload some functional
589
576
  * settings, search settings, sidenav settings"). A right drawer over
590
577
  * the page, the same rows the apps' settings use (SettingsSections). */}
591
- <ShellDrawer open={isSettingsOpen} onClose={() => setIsSettingsOpen(false)} side="right" width={360}>
592
- {/* the drawer draws its own close — no second one here */}
593
- <div className="flex flex-col gap-6 p-6">
594
- <span className="kol-doc-eyebrow">Settings</span>
595
- <SettingsSections sections={settingsSections} labelWidth="auto" />
596
- </div>
597
- </ShellDrawer>
578
+ {/* THE APPROVED DRAWER (2026-09-30): kol-component's `SettingsPanel` — the composition locked
579
+ * 2026-08-27 that media's Display settings and Trash already wear: title header, divided
580
+ * sections. This was a hand-built ShellDrawer + eyebrow + undivided sections. */}
581
+ <SettingsPanel open={isSettingsOpen} variant="drawer" title="Settings" onClose={() => setIsSettingsOpen(false)}>
582
+ <SettingsSections sections={settingsSections} divided />
583
+ </SettingsPanel>
598
584
  </div>
599
585
  </ShellNavCollapsedContext.Provider>
600
586
  </ShellTocCollapsedContext.Provider>
@@ -37,39 +37,26 @@ const ShellSidebar = ({ routes = [], basePath = '/', onNavigate, label = 'Naviga
37
37
  const navCollapsed = isControlled ? collapsed : internalCollapsed
38
38
  const handleToggle = isControlled ? onToggle : () => setInternalCollapsed(prev => !prev)
39
39
 
40
- const [collapsedSections, setCollapsedSections] = useState(() => {
41
- const initial = {}
42
- routes.forEach((route) => {
43
- const sectionPath = getSectionRootPath(route, basePath)
44
- const isActive =
45
- sectionPath === basePath
46
- ? normalizedPath === basePath
47
- : normalizedPath === sectionPath || normalizedPath.startsWith(sectionPath + '/')
48
- initial[route.id] = !isActive
49
- })
50
- return initial
40
+ /* THE RAIL FOLLOWS YOU — INTO A CHILD ONLY (2026-09-30, reversing the 2026-09-28 "open the
41
+ * group you are in" on its landing page too). Landing on a page INSIDE a chapter opens that
42
+ * chapter and folds the rest; a chapter's own home opens nothing. Every other chapter starts
43
+ * folded — including ones this rail has never seen (a Group-by switch hands it new ids, and an
44
+ * unknown id used to read as open, so every chapter sprang open). */
45
+ const holdsPage = (route) => (route.children ?? []).some((c) => {
46
+ const cp = getChildPath(c, basePath).replace(/\/$/, '')
47
+ return normalizedPath === cp || normalizedPath.startsWith(cp + '/')
51
48
  })
52
-
53
- /* THE RAIL FOLLOWS YOU (2026-09-28, user: "the sidebar doesnt highlight that
54
- * part, which it probably should by closing/collapsing other categories").
55
- * Arriving in a group opens it AND folds its siblings, so the open group is
56
- * the one you are in. Opening another by hand still works until you move. */
57
- const isRouteActive = (route) => {
58
- const sectionPath = getSectionRootPath(route, basePath)
59
- const own = sectionPath === basePath
60
- ? normalizedPath === basePath
61
- : normalizedPath === sectionPath || normalizedPath.startsWith(sectionPath + '/')
62
- return own || (route.children ?? []).some((c) => {
63
- const cp = getChildPath(c, basePath).replace(/\/$/, '')
64
- return normalizedPath === cp || normalizedPath.startsWith(cp + '/')
65
- })
49
+ const followed = () => {
50
+ const hit = routes.find(holdsPage)
51
+ return hit ? Object.fromEntries(routes.map((r) => [r.id, r !== hit])) : null
66
52
  }
53
+ const [collapsedSections, setCollapsedSections] = useState(() => followed() ?? {})
67
54
  useEffect(() => {
68
- const active = routes.filter(isRouteActive)
69
- if (!active.length) return
70
- setCollapsedSections(Object.fromEntries(routes.map((r) => [r.id, !active.includes(r)])))
55
+ const next = followed()
56
+ if (next) setCollapsedSections(next)
71
57
  // eslint-disable-next-line react-hooks/exhaustive-deps
72
58
  }, [normalizedPath, routes, basePath])
59
+ const isFolded = (route) => collapsedSections[route.id] ?? true
73
60
 
74
61
  /* FOLD ALL (2026-09-30, the names audit: *"shortcut collapse/expand the categories"*). The
75
62
  * shell's `C` key broadcasts one target state; every rail's chapters take it. */
@@ -80,7 +67,7 @@ const ShellSidebar = ({ routes = [], basePath = '/', onNavigate, label = 'Naviga
80
67
  }, [routes])
81
68
 
82
69
  const handleSectionClick = (route) => {
83
- setCollapsedSections((prev) => ({ ...prev, [route.id]: !prev[route.id] }))
70
+ setCollapsedSections((prev) => ({ ...prev, [route.id]: !(prev[route.id] ?? true) }))
84
71
  }
85
72
 
86
73
  /* The L1 count is every leaf row under it — RailSection shows it only while
@@ -111,27 +98,34 @@ const ShellSidebar = ({ routes = [], basePath = '/', onNavigate, label = 'Naviga
111
98
  * it should. `collapsible` is what that distinction is now. */
112
99
  const hasChildren = route.children?.length > 0
113
100
 
101
+ /* A CHILDLESS ENTRY IS A ROW, not a chapter (2026-09-30): drawn at L2 it read as an
102
+ * empty category with a blank caret slot. */
103
+ if (!hasChildren) {
104
+ return (
105
+ <nav key={route.id} className="shell-nav-items">
106
+ <RailRow to={route.path ?? getSectionRootPath(route, basePath)} onNavigate={onNavigate}>
107
+ {route.label}
108
+ </RailRow>
109
+ </nav>
110
+ )
111
+ }
112
+
114
113
  return (
115
114
  <div key={route.id} className="shell-nav-group">
116
115
  <RailSection
117
116
  level={2}
118
117
  label={route.label}
119
- count={hasChildren ? route.children.length : undefined}
120
- /* THE HEADER ALWAYS LINKS (2026-08-02). It used to link only
121
- * when the group had NO children, so a chapter with pages had
122
- * a dead header and its index had to ride as a row inside
123
- * itself. RailSection already separates the two gestures —
124
- * the label navigates, the rest of the row toggles — so a
125
- * group can open its landing page and still collapse. */
118
+ count={route.children.length}
119
+ /* THE HEADER ALWAYS LINKS (2026-08-02): the label opens the chapter's home,
120
+ * the chevron folds. */
126
121
  to={route.path ?? getSectionRootPath(route, basePath)}
127
- collapsible={hasChildren}
128
- collapsed={!!collapsedSections[route.id]}
122
+ collapsed={isFolded(route)}
129
123
  onToggle={() => handleSectionClick(route)}
130
124
  onNavigate={onNavigate}
131
125
  icon={Icon}
132
126
  >
133
127
  <nav className="shell-nav-items">
134
- {(route.children ?? []).map((child) => (
128
+ {route.children.map((child) => (
135
129
  <RailRow
136
130
  key={child.id}
137
131
  to={getChildPath(child, basePath)}