@kolkrabbi/kol-workshop 0.36.0 → 0.37.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.36.0",
3
+ "version": "0.37.0",
4
4
  "private": false,
5
5
  "description": "Docs viewer, shell and tag system",
6
6
  "license": "MIT",
@@ -14,13 +14,13 @@
14
14
  "dependencies": {
15
15
  "d3": "^7.9.0",
16
16
  "@kolkrabbi/kol-markdown": "^0.1.2",
17
- "@kolkrabbi/kol-search": "^0.2.0",
18
- "@kolkrabbi/kol-brand": "^0.1.3"
17
+ "@kolkrabbi/kol-brand": "^0.1.3",
18
+ "@kolkrabbi/kol-search": "^0.3.0"
19
19
  },
20
20
  "peerDependencies": {
21
- "@kolkrabbi/kol-component": ">=0.235.0",
21
+ "@kolkrabbi/kol-component": ">=0.237.0",
22
22
  "@kolkrabbi/kol-framework": ">=0.45.0",
23
- "@kolkrabbi/kol-theme": ">=0.162.0",
23
+ "@kolkrabbi/kol-theme": ">=0.163.0",
24
24
  "@kolkrabbi/kol-icons": ">=0.18.0",
25
25
  "gsap": "^3.13.0",
26
26
  "react": "^18.3.0 || ^19.0.0",
@@ -28,10 +28,10 @@
28
28
  "react-router-dom": "^6.0.0 || ^7.0.0"
29
29
  },
30
30
  "devDependencies": {
31
- "@kolkrabbi/kol-component": "^0.235.0",
32
- "@kolkrabbi/kol-theme": "^0.162.0",
33
31
  "@kolkrabbi/kol-framework": "^0.48.0",
34
- "@kolkrabbi/kol-icons": "^0.32.0"
32
+ "@kolkrabbi/kol-theme": "^0.163.0",
33
+ "@kolkrabbi/kol-component": "^0.237.0",
34
+ "@kolkrabbi/kol-icons": "^0.33.0"
35
35
  },
36
36
  "files": [
37
37
  "src",
@@ -27,7 +27,11 @@ export function DocHeader({ eyebrow, title, lede, children }) {
27
27
 
28
28
  export function DocSection({ id, title, lede, children }) {
29
29
  return (
30
- <section id={id} className="flex flex-col gap-4 border-t border-fg-08 pt-8 scroll-mt-20">
30
+ /* THE SECTION KEEPS ITS OWN DISTANCE (2026-10-01 — user, on the tokens page: the rule sat flush
31
+ * under the last swatch's caption). It drew a rule with nothing above it and left the gap to the
32
+ * page; ten pages stack sections in a fragment and supplied none. A page that already spaces its
33
+ * children with a flex gap keeps that gap — the margin only lands where the parent is not one. */
34
+ <section id={id} className="flex flex-col gap-4 border-t border-fg-08 pt-8 scroll-mt-20 [:not(.flex)>*+&]:mt-10">
31
35
  {title && <h2 className="kol-doc-section-title">{title}</h2>}
32
36
  {lede && <p className="kol-doc-body max-w-[var(--kol-content-measure)]">{lede}</p>}
33
37
  {children}
@@ -16,7 +16,7 @@ import useExhibitToc from './useExhibitToc.js'
16
16
  * label="Scope: Dashboard — Overview"
17
17
  * title="Dashboard"
18
18
  * body="Modular dashboards, charts and KPI components…"
19
- * action={<Button variant="primary" href="https://kolkrabbi.io/metrics">Open metrics</Button>}
19
+ * action={<Button tone="primary" href="https://kolkrabbi.io/metrics">Open metrics</Button>}
20
20
  * cards={[{ id: 'setup', label: 'Setup', subtitle: '…', icon: 'book-open', href: '/workshop/dashboard/setup' }]}
21
21
  * />
22
22
  * ```
@@ -13,6 +13,7 @@ import { Link } from 'react-router-dom'
13
13
  * @param {string} to the route the row opens
14
14
  * @param {ReactNode} title the result's name (a highlighted node is fine)
15
15
  * @param {string} [meta] kind · category · space · date
16
+ * @param {ReactNode} [icon] a glyph for the kind, before the title (Round 6, 2026-10-01)
16
17
  * @param {ReactNode} [description]
17
18
  * @param {'underline'|'wash'} [variant='underline']
18
19
  */
@@ -23,11 +24,11 @@ const VARIANTS = {
23
24
  wash: { row: 'flex flex-col gap-1 px-3 py-3 hover:bg-fg-04', title: '' },
24
25
  }
25
26
 
26
- export default function ResultRow({ to, title, meta, description, variant = 'underline' }) {
27
+ export default function ResultRow({ to, title, meta, description, icon, variant = 'underline' }) {
27
28
  const v = VARIANTS[variant] ?? VARIANTS.underline
28
29
  return (
29
30
  <Link to={to} className={v.row}>
30
- <span className={`kol-doc-body text-emphasis ${v.title}`.trim()}>{title}</span>
31
+ <span className={`kol-doc-body text-emphasis ${icon ? 'inline-flex items-center gap-2' : ''} ${v.title}`.replace(/\s+/g, ' ').trim()}>{icon}{title}</span>
31
32
  {meta && <span className="kol-helper-12 text-meta">{meta}</span>}
32
33
  {description && <span className="kol-doc-body text-body">{description}</span>}
33
34
  </Link>
@@ -3,17 +3,18 @@ import { getTagColor } from '@kolkrabbi/kol-markdown'
3
3
  import { useNavigate, useSearchParams } from 'react-router-dom'
4
4
  import { EmptyState, Input, Tag } from '@kolkrabbi/kol-component'
5
5
  import ResultRow from './ResultRow.jsx'
6
+ import { Icon } from '@kolkrabbi/kol-icons'
6
7
  import { createIndex, search, parseQuery } from '@kolkrabbi/kol-search'
7
8
 
8
9
  /**
9
- * SearchPage — The search results page. THE results page (2026-09-28). Enter in the shell's palette
10
+ * SearchPage — The search results page. THE results page (2026-09-28). Enter in the shell's search modal
10
11
  * lands here with `?q=`; so does a tag clicked anywhere (`#tag`). The WHOLE
11
12
  * state is the query string in the URL — scope and facet picks are written back
12
13
  * into it as `in:` · `kind:` · `cat:` · `tag:` tokens — so Back returns to the
13
14
  * same results, a result link is an ordinary navigation, and a search can be
14
15
  * shared as a link.
15
16
  *
16
- * It replaced two surfaces that each did half the job: the palette's in-place
17
+ * It replaced two surfaces that each did half the job: the search modal's in-place
17
18
  * tag browser and a page whose groups were whatever `sectionLabel` each
18
19
  * source happened to emit.
19
20
  *
@@ -47,7 +48,7 @@ const readToken = (t) => (t.kind === 'term'
47
48
  ? `${t.field} ${t.value}`
48
49
  : `${t.negate ? 'not ' : ''}${t.field === 'tags' ? 'tag' : t.field}: ${t.value}${t.via === 'smart' ? ' (from the word)' : ''}`)
49
50
 
50
- export default function SearchPage({ items = [], spaces = [], facets = DEFAULT_FACETS, limit = 60, rowVariant = 'underline', resultsPath }) {
51
+ export default function SearchPage({ items = [], spaces = [], facets = DEFAULT_FACETS, limit = 60, rowVariant = 'underline', rowMeta = 'facts', kindIcons, resultsPath }) {
51
52
  const [params, setParams] = useSearchParams()
52
53
  const navigate = useNavigate()
53
54
  const q = params.get('q') ?? ''
@@ -163,7 +164,9 @@ export default function SearchPage({ items = [], spaces = [], facets = DEFAULT_F
163
164
  to={r.item.href}
164
165
  variant={rowVariant}
165
166
  title={<Highlight text={r.item.title} ranges={r.highlights.title} />}
166
- meta={[r.item.kind, r.item.category, r.item.space, r.item.date].filter(Boolean).join(' · ')}
167
+ /* Round 6 (2026-10-01): `kindIcons` puts a glyph for the kind on the row, `rowMeta="path"` prints the route as the second line */
168
+ icon={kindIcons?.[r.item.kind] && <Icon name={kindIcons[r.item.kind]} size={14} />}
169
+ meta={rowMeta === 'path' ? r.item.href : [r.item.kind, r.item.category, r.item.space, r.item.date].filter(Boolean).join(' · ')}
167
170
  description={r.item.description}
168
171
  />
169
172
  </li>
@@ -1,4 +1,4 @@
1
- import { NavLink } from 'react-router-dom'
1
+ import { Link, NavLink } from 'react-router-dom'
2
2
 
3
3
  /**
4
4
  * RailRow — THE L3 rail row. The ladder's bottom rung, owned like the other two.
@@ -28,7 +28,9 @@ import { NavLink } from 'react-router-dom'
28
28
  * `NavLink` drives it for routes; `active` drives it for hash links and
29
29
  * buttons, which react-router cannot resolve.
30
30
  *
31
- * @param {string} [to] route → NavLink, active resolved by the router
31
+ * @param {string} [to] route → NavLink, active resolved by the router. A `to` carrying a `#` is a
32
+ * SECTION of a page (2026-10-01): the router would light every section of
33
+ * the open page at once, so it is a plain Link and `active` decides
32
34
  * @param {string} [href] plain anchor (hash links: on-this-page)
33
35
  * @param {Function} [onClick] no `to`/`href` → a <button>
34
36
  * @param {boolean} [active] forced active state (hash links, buttons)
@@ -63,6 +65,13 @@ export default function RailRow({
63
65
  </>
64
66
  )
65
67
 
68
+ if (to && to.includes('#')) {
69
+ return (
70
+ <Link to={to} className={cls(active)} onClick={onNavigate}>
71
+ {inner}
72
+ </Link>
73
+ )
74
+ }
66
75
  if (to) {
67
76
  return (
68
77
  <NavLink to={to} end className={({ isActive }) => cls(isActive || active)} onClick={onNavigate}>
@@ -72,6 +72,8 @@ export default function RailSection({
72
72
  defaultCollapsed = false,
73
73
  onNavigate,
74
74
  icon: IconComponent = Icon,
75
+ /* a leading glyph for the group, between the caret and the label (rail icons, 2026-10-01) */
76
+ glyph,
75
77
  children,
76
78
  }) {
77
79
  const [internal, setInternal] = useState(defaultCollapsed)
@@ -149,6 +151,7 @@ export default function RailSection({
149
151
  {/* L2 rows without a caret keep the caret's width as space, so an
150
152
  * expandable row and a childless one share a left edge. */}
151
153
  {level === 2 && !chevron && <span aria-hidden="true" style={{ width: 12 }} />}
154
+ {glyph}
152
155
  {labelNode}
153
156
  </span>
154
157
  <span className="flex items-center gap-2 shrink-0">
@@ -1,7 +1,7 @@
1
1
  import { createContext, useContext, useMemo, useRef, useState, useEffect, Suspense } from 'react'
2
2
  import { Outlet, Link, useLocation, useNavigate, useNavigationType } from 'react-router-dom'
3
3
  import { ShellHeader } from '@kolkrabbi/kol-framework'
4
- import ShellSidebar, { RAIL_FOLD_EVENT } from './ShellSidebar.jsx'
4
+ import ShellSidebar, { RAIL_FOLD_EVENT, ShellRailModeContext } from './ShellSidebar.jsx'
5
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'
@@ -98,12 +98,42 @@ const useRailGrab = (token, side, hide) => {
98
98
  return { ref, grab: <div {...grabProps} className={`kol-rail-grab kol-rail-grab--${side} hidden lg:block`} /> }
99
99
  }
100
100
 
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-14">
103
- {children}
104
- {grip}
105
- </aside>
106
- )
101
+ /**
102
+ * ShellNavColumn — The left rail's column. the frame the shell's left rail sits in: sticky, one
103
+ * scroll region tall, a seam toward the page. It owns the rail's THREE STATES (user ruling
104
+ * 2026-10-02) — `open`, `icons` and hidden, which is simply not mounting it.
105
+ *
106
+ * In `icons` the column is a narrow strip (the track is the framework's collapsed-rail width,
107
+ * `--kol-sidenav-w-collapsed`) and every `ShellSidebar` inside draws one glyph per group. Hover
108
+ * opens the whole rail OVER the page at its full width — the track does not move, so the page
109
+ * never reflows — and leaving folds it back. A touch has no hover: there the glyphs are the nav.
110
+ *
111
+ * @param {'open'|'icons'} mode
112
+ * @param {ReactNode|Function} children the rail; a function gets the state it is drawn in
113
+ * (`open` while an icon rail is hovered), so a consumer can
114
+ * drop what does not fit a strip
115
+ * @param {ReactNode} grip the resize handle — not drawn on an icon rail
116
+ * @param {Object} railRef
117
+ */
118
+ export function ShellNavColumn({ mode = 'open', children, grip, railRef }) {
119
+ const [peek, setPeek] = useState(false)
120
+ const icons = mode === 'icons'
121
+ const shown = icons && !peek ? 'icons' : 'open'
122
+ return (
123
+ <aside
124
+ ref={railRef}
125
+ aria-label="Navigation"
126
+ className={`shell-rail shell-rail--nav shell-sidebar-sticky hidden lg:block pt-6 md:pt-6 lg:pt-8 pb-14 ${icons ? `shell-rail--icons ${peek ? 'is-peek' : ''}` : ''}`.trim()}
127
+ onMouseEnter={icons ? () => setPeek(true) : undefined}
128
+ onMouseLeave={icons ? () => setPeek(false) : undefined}
129
+ >
130
+ <ShellRailModeContext.Provider value={shown}>
131
+ {typeof children === 'function' ? children(shown) : children}
132
+ </ShellRailModeContext.Provider>
133
+ {!icons && grip}
134
+ </aside>
135
+ )
136
+ }
107
137
 
108
138
  const MainColumn = ({ children, fullHeight, width = 'canvas', padStart, padEnd }) => {
109
139
  /* the map lives INSIDE MainColumn on purpose — validate:width W1 reads the
@@ -159,7 +189,7 @@ const writeSettings = (next) => {
159
189
  }
160
190
 
161
191
  /* A search item in either shape — the kol-search item (`title · kind · space ·
162
- * category · tags · headings · keywords · description · href`) or the palette's
192
+ * category · tags · headings · keywords · description · href`) or the search modal's
163
193
  * older row (`label · sectionLabel · …`). Both keep working. */
164
194
  const toSearchItem = (item, i) => ({
165
195
  ...item,
@@ -179,18 +209,24 @@ const toSearchItem = (item, i) => ({
179
209
  * so it names the space you are in
180
210
  * @param {Array} [shortcuts] the consumer's own keys, `{ id, label, combo, key, run }` — listed
181
211
  * in the `S` sheet and bound by the same handler (2026-09-30)
212
+ * @param {Function} [renderSidebar] `({ activeRoute, onNavigate, mode }) => node` — the space's
213
+ * rail. `mode` is `open` or `icons` (the left rail's third state,
214
+ * 2026-10-02): every `ShellSidebar` reads it by itself, so `mode`
215
+ * is only for what else the consumer puts in the rail
182
216
  */
183
217
  const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoSrc, brandLogoAlt = '', renderSidebar, searchItems, searchPath, settings = [], brandLabel, defaultTocContent, isActive: isActiveProp, actions, shortcuts = [] }) => {
184
218
  const [isNavDrawerOpen, setIsNavDrawerOpen] = useState(false)
185
219
  const [prefs, setPrefs] = useState(readSettings)
186
220
  const setPref = (key, value) => setPrefs((p) => { const next = { ...p, [key]: value }; writeSettings(next); return next })
187
221
  const [navCollapsed, setNavCollapsed] = useState(() => readSettings().navHidden === true)
222
+ /* the left rail's third state — a strip of icons that opens on hover (see ShellNavColumn) */
223
+ const navIcons = prefs.navIcons === true
188
224
 
189
225
  const [tocCollapsed, setTocCollapsed] = useState(() => readSettings().tocHidden === true)
190
226
  const navGrab = useRailGrab('kol-shell-nav', 'left', () => { setNavCollapsed(true); writeSettings({ ...readSettings(), navHidden: true }) })
191
227
  const tocGrab = useRailGrab('kol-shell-toc', 'right', () => { setTocCollapsed(true); writeSettings({ ...readSettings(), tocHidden: true }) })
192
228
  const [isSettingsOpen, setIsSettingsOpen] = useState(false)
193
- /* ONE QUERY (user ruling 2026-08-01). The palette's text used to live here
229
+ /* ONE QUERY (user ruling 2026-08-01). The search modal's text used to live here
194
230
  * while tags lived in TagModeContext — two states, and therefore two
195
231
  * surfaces. Both facets are the context's now. The local pair survives ONLY
196
232
  * for a consumer mounting the shell without a TagModeProvider, where the
@@ -281,7 +317,7 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
281
317
  { id: 'nav', label: 'Left rail', combo: '[' },
282
318
  { id: 'toc', label: 'Right rail', combo: ']' },
283
319
  { id: 'rails', label: 'Both rails', combo: '\\' },
284
- { id: 'fold', label: 'Fold all', combo: 'C' },
320
+ { id: 'fold', label: 'Fold / expand all', combo: 'C' },
285
321
  { id: 'esc', label: 'Close', combo: 'Esc' },
286
322
  ] },
287
323
  ...(shortcuts.length ? [{ section: 'Page', items: shortcuts.map(({ id, label, combo }) => ({ id, label, combo })) }] : []),
@@ -374,7 +410,12 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
374
410
  * rails are the workshop shell's own pair (--kol-shell-nav-w ·
375
411
  * --kol-shell-toc-w, 256px each since 2026-09-28): the left used to read
376
412
  * --kol-sidenav-w, the draggable app sidenav's 264/320 ladder. */
377
- const gridCols = showNav
413
+ /* an icon rail's track is the framework's collapsed-rail width — the same strip, one token */
414
+ const gridCols = showNav && navIcons
415
+ ? showToc
416
+ ? 'lg:grid-cols-[var(--kol-sidenav-w-collapsed)_minmax(0,1fr)] xl:grid-cols-[var(--kol-sidenav-w-collapsed)_minmax(0,1fr)_var(--kol-shell-toc-w)]'
417
+ : 'lg:grid-cols-[var(--kol-sidenav-w-collapsed)_minmax(0,1fr)]'
418
+ : showNav
378
419
  ? showToc
379
420
  ? 'lg:grid-cols-[var(--kol-shell-nav-w)_minmax(0,1fr)] xl:grid-cols-[var(--kol-shell-nav-w)_minmax(0,1fr)_var(--kol-shell-toc-w)]'
380
421
  : 'lg:grid-cols-[var(--kol-shell-nav-w)_minmax(0,1fr)]'
@@ -440,7 +481,7 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
440
481
  </>
441
482
  )
442
483
 
443
- /* THE ENGINE IS kol-search (2026-09-28) — the palette ran a first-substring
484
+ /* THE ENGINE IS kol-search (2026-09-28) — the search modal ran a first-substring
444
485
  * match, so `atom` ranked AppHub first on a word in its description and only
445
486
  * one row lit its match. Ranked now, every hit highlighted, and a word that
446
487
  * names a category (`atom`) filters by it. `limitToSpace` (settings) scopes
@@ -472,6 +513,7 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
472
513
  const settingsSections = [
473
514
  { label: 'Layout', rows: [
474
515
  { id: 'nav', label: 'Left rail', render: () => <SettingsSwitch on={!navCollapsed} onChange={(on) => { setNavCollapsed(!on); setPref('navHidden', !on) }} /> },
516
+ { id: 'nav-icons', label: 'Icons only', render: () => <SettingsSwitch on={navIcons} onChange={(on) => setPref('navIcons', on)} /> },
475
517
  { id: 'toc', label: 'Right rail', render: () => <SettingsSwitch on={!tocCollapsed} onChange={(on) => { setTocCollapsed(!on); setPref('tocHidden', !on) }} /> },
476
518
  ] },
477
519
  { label: 'Search', rows: [
@@ -526,9 +568,9 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
526
568
  >
527
569
  <div className={`shell-content-grid grid ${gridCols}`} data-layout={layoutType} style={{ paddingInline: 'var(--kol-pad-chrome-x)' }}>
528
570
  {showNav && (
529
- <NavColumn railRef={navGrab.ref} grip={navGrab.grab}>
530
- {renderSidebar ? renderSidebar({ activeRoute }) : <ShellSidebar routes={routes} basePath={basePath} />}
531
- </NavColumn>
571
+ <ShellNavColumn mode={navIcons ? 'icons' : 'open'} railRef={navGrab.ref} grip={navGrab.grab}>
572
+ {(mode) => (renderSidebar ? renderSidebar({ activeRoute, mode }) : <ShellSidebar routes={routes} basePath={basePath} />)}
573
+ </ShellNavColumn>
532
574
  )}
533
575
 
534
576
  <MainColumn fullHeight={isFullHeight} width={contentWidth} padStart={showNav} padEnd={showToc}>
@@ -573,10 +615,10 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
573
615
  query={searchQuery}
574
616
  onQueryChange={setSearchQuery}
575
617
  results={searchResults}
576
- /* an empty palette opens on the space table, not a blank box */
618
+ /* an empty search modal opens on the space table, not a blank box */
577
619
  suggestions={navItems.map((n) => ({ id: `space:${n.href}`, label: n.label, href: n.href, icon: 'arrow-right', group: 'Spaces' }))}
578
620
  /* THE EXPANDED BODY (user ruling 2026-08-01). The tag browser is
579
- * not a sibling overlay — it is this palette's second state. Enter
621
+ * not a sibling overlay — it is this search modal's second state. Enter
580
622
  * commits the query and swaps the result rows for the full body;
581
623
  * committed tags ride along as chips on the same query. */
582
624
  expanded={tagMode.isProvided && tagMode.expanded}
@@ -596,7 +638,7 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
596
638
  placeholder="Search…"
597
639
  onSelect={(item) => {
598
640
  /* `action` before `href`: not every hit is a destination. A tag
599
- * row FILTERS the same query and the palette STAYS OPEN — closing
641
+ * row FILTERS the same query and the search modal STAYS OPEN — closing
600
642
  * it was the behaviour that forced tags into a second overlay.
601
643
  * Only a destination dismisses. */
602
644
  if (typeof item?.action === 'function') {
@@ -1,6 +1,6 @@
1
- import { useState, useEffect } from 'react'
2
- import { useLocation } from 'react-router-dom'
3
- import { Icon } from '@kolkrabbi/kol-component'
1
+ import { createContext, useContext, useState, useEffect } from 'react'
2
+ import { useLocation, useNavigate } from 'react-router-dom'
3
+ import { Button, Icon, Tooltip } from '@kolkrabbi/kol-component'
4
4
  import RailSection from './RailSection.jsx'
5
5
  import RailRow from './RailRow.jsx'
6
6
 
@@ -26,8 +26,17 @@ const getChildPath = (child, basePath) => {
26
26
 
27
27
  export const RAIL_FOLD_EVENT = 'kol-rail-fold'
28
28
 
29
+ /* THE RAIL'S THIRD STATE (user ruling 2026-10-02: *"I wanted that icon state on the rails. even if
30
+ * we dont use it, I want to see it"*). Beside visible and hidden: `icons` — the rail is a narrow
31
+ * strip, one glyph per group, and opens on hover. The column that holds the rail (`ShellNavColumn`)
32
+ * provides the state; every ShellSidebar inside it reads it, so a consumer's rail takes the state
33
+ * without passing a prop. Outside a provider a rail is `open`, as it always was. */
34
+ export const ShellRailModeContext = createContext('open')
35
+
29
36
  const ShellSidebar = ({ routes = [], basePath = '/', onNavigate, label = 'Navigation', labelTo, collapsed, onToggle, defaultCollapsed = false }) => {
30
37
  const location = useLocation()
38
+ const navigate = useNavigate()
39
+ const mode = useContext(ShellRailModeContext)
31
40
  const normalizedPath = location.pathname.replace(/\/$/, '')
32
41
 
33
42
  // Controlled mode: collapsed + onToggle from parent
@@ -100,12 +109,18 @@ const ShellSidebar = ({ routes = [], basePath = '/', onNavigate, label = 'Naviga
100
109
 
101
110
  /* The L1 count is every leaf row under it — RailSection shows it only while
102
111
  * the section is folded (2026-09-28). At any depth: leaves, not groups. */
103
- const leavesOf = (list) => list.reduce((n, r) => n + (r.children?.length ? leavesOf(r.children) : 1), 0)
112
+ /* A PAGE THAT LISTS ITS SECTIONS IS STILL ONE PAGE (2026-10-01): `section: true` children are
113
+ * `#` anchors on their parent's page, not leaves of the tree. */
114
+ const isPage = (r) => !r.children?.length || r.children.every((c) => c.section)
115
+ const leavesOf = (list) => list.reduce((n, r) => n + (isPage(r) ? 1 : leavesOf(r.children)), 0)
104
116
  const leafCount = leavesOf(routes)
105
117
 
106
118
  /* ONE GROUP, AT ANY DEPTH. A chapter's child with children of its own is a chapter again — the
107
119
  * same RailSection rung, the same count and chevron, stepped in one row-indent by `.shell-nav-nest`
108
120
  * so its caret sits on its siblings' text edge. A leaf is a RailRow wherever it lands. */
121
+ /* A ROUTE MAY CARRY A GLYPH (rail icons, open-questions Round 6, ruled 2026-10-01): `icon` is an
122
+ * icon name, drawn on the row or beside the group's label. The consumer decides which routes. */
123
+ const glyphOf = (route) => (route.icon ? <Icon name={route.icon} size={14} className="shrink-0" /> : undefined)
109
124
  const renderGroup = (route) => (
110
125
  <RailSection
111
126
  level={2}
@@ -118,61 +133,91 @@ const ShellSidebar = ({ routes = [], basePath = '/', onNavigate, label = 'Naviga
118
133
  onToggle={() => handleSectionClick(route)}
119
134
  onNavigate={onNavigate}
120
135
  icon={Icon}
136
+ glyph={glyphOf(route)}
121
137
  >
122
- <nav className="shell-nav-items">
138
+ {/* a group that wears a glyph steps its children in by the glyph (14 + the 8 gap), so they
139
+ * still hang under its LABEL — without it they sat left of the word they belong to */}
140
+ <nav className={`shell-nav-items ${route.icon ? 'pl-[22px]' : ''}`.trim()}>
123
141
  {route.children.map((child) => (child.children?.length ? (
124
142
  <div key={child.id} className="shell-nav-nest">{renderGroup(child)}</div>
125
143
  ) : (
126
- <RailRow key={child.id} to={getChildPath(child, basePath)} onNavigate={onNavigate}>
144
+ <RailRow key={child.id} to={getChildPath(child, basePath)} onNavigate={onNavigate}
145
+ icon={glyphOf(child)}
146
+ active={child.section ? `${location.pathname}${location.hash}` === child.path : undefined}>
127
147
  {child.label}
128
148
  </RailRow>
129
149
  )))}
130
150
  </nav>
131
151
  </RailSection>
132
152
  )
153
+ /* ICONS ONLY — one glyph per group (a group's own, else the folder; a page's, else the file), no
154
+ * eyebrow, no rows. The DS nav button on the control ladder's `md` rung, the same rung the header
155
+ * glyphs stand on; the group you are in holds the rail's active wash (`.shell-rail-icons`). */
156
+ if (mode === 'icons') {
157
+ const holds = (r) => isHere(r) || (r.children ?? []).some(holds)
158
+ return (
159
+ <nav className="shell-rail-icons" aria-label={label ?? undefined}>
160
+ {routes.map((route) => (
161
+ <Tooltip key={route.id} label={route.label} placement="right">
162
+ <Button variant="nav" size="md" iconOnly={route.icon ?? (route.children?.length ? 'folder' : 'file')}
163
+ aria-label={route.label} aria-current={holds(route) ? 'page' : undefined}
164
+ onClick={(e) => { navigate(getSectionRootPath(route, basePath)); onNavigate?.(e) }} />
165
+ </Tooltip>
166
+ ))}
167
+ </nav>
168
+ )
169
+ }
170
+
171
+ /* `label={null}` draws the tree with no L1 section over it (2026-10-01) — a space whose rail is
172
+ * its pages, not a category of them (Search: user ruling, the space does not list itself). */
173
+ const body = (
174
+ <div className="shell-rail-stack-inner">
175
+ {/* LOOSE ROWS SHARE ONE LIST (the showcase review W12, 2026-09-30 — user: "the spaces are
176
+ * incorrect in the left sidebar"). Each childless entry rendered its own <nav>, and the
177
+ * stack's 16px gap landed between every pair, so a rail of four rows (Search) read 46px
178
+ * apart instead of a list's 26. Consecutive rows are one run now; a chapter breaks it. */}
179
+ {routes.reduce((runs, route) => {
180
+ const last = runs[runs.length - 1]
181
+ if (!route.children?.length && last && !last.group) last.rows.push(route)
182
+ else runs.push(route.children?.length ? { group: route } : { rows: [route] })
183
+ return runs
184
+ }, []).map((run) => (run.group ? (
185
+ <div key={run.group.id} className="shell-nav-group">
186
+ {renderGroup(run.group)}
187
+ </div>
188
+ ) : (
189
+ /* A CHILDLESS ENTRY IS A ROW, not a chapter (2026-09-30): drawn at L2 it read as an
190
+ * empty category with a blank caret slot. */
191
+ <nav key={run.rows[0].id} className="shell-nav-items">
192
+ {run.rows.map((route) => (
193
+ <RailRow key={route.id} to={route.path ?? getSectionRootPath(route, basePath)} onNavigate={onNavigate} icon={glyphOf(route)}>
194
+ {route.label}
195
+ </RailRow>
196
+ ))}
197
+ </nav>
198
+ )))}
199
+ </div>
200
+ )
133
201
  return (
134
202
  /* One rail layout, one class (2026-08-01). This was `space-y-4` against the
135
203
  * right rail's `space-y-6` against the outer `flex flex-col gap-6` — three
136
204
  * spellings of the same stack, and `space-y` fights any child that owns a
137
205
  * margin, which the eyebrow box does. */
138
206
  <div className="shell-rail-stack-inner">
139
- <RailSection
140
- level={1}
141
- label={label}
142
- to={labelTo}
143
- count={leafCount}
144
- collapsed={navCollapsed}
145
- onToggle={handleToggle}
146
- onNavigate={onNavigate}
147
- icon={Icon}
148
- >
149
- <div className="shell-rail-stack-inner">
150
- {/* LOOSE ROWS SHARE ONE LIST (the showcase review W12, 2026-09-30 — user: "the spaces are
151
- * incorrect in the left sidebar"). Each childless entry rendered its own <nav>, and the
152
- * stack's 16px gap landed between every pair, so a rail of four rows (Search) read 46px
153
- * apart instead of a list's 26. Consecutive rows are one run now; a chapter breaks it. */}
154
- {routes.reduce((runs, route) => {
155
- const last = runs[runs.length - 1]
156
- if (!route.children?.length && last && !last.group) last.rows.push(route)
157
- else runs.push(route.children?.length ? { group: route } : { rows: [route] })
158
- return runs
159
- }, []).map((run) => (run.group ? (
160
- <div key={run.group.id} className="shell-nav-group">
161
- {renderGroup(run.group)}
162
- </div>
163
- ) : (
164
- /* A CHILDLESS ENTRY IS A ROW, not a chapter (2026-09-30): drawn at L2 it read as an
165
- * empty category with a blank caret slot. */
166
- <nav key={run.rows[0].id} className="shell-nav-items">
167
- {run.rows.map((route) => (
168
- <RailRow key={route.id} to={route.path ?? getSectionRootPath(route, basePath)} onNavigate={onNavigate}>
169
- {route.label}
170
- </RailRow>
171
- ))}
172
- </nav>
173
- )))}
174
- </div>
175
- </RailSection>
207
+ {label === null ? body : (
208
+ <RailSection
209
+ level={1}
210
+ label={label}
211
+ to={labelTo}
212
+ count={leafCount}
213
+ collapsed={navCollapsed}
214
+ onToggle={handleToggle}
215
+ onNavigate={onNavigate}
216
+ icon={Icon}
217
+ >
218
+ {body}
219
+ </RailSection>
220
+ )}
176
221
  </div>
177
222
  )
178
223
  }
@@ -9,6 +9,9 @@
9
9
  export { default as ShellLayout } from './ShellLayout.jsx'
10
10
  export { ShellTocContext, ShellFullHeightContext, ShellTocCollapsedContext, ShellNavCollapsedContext, ShellContentWidthContext, SHELL_SCROLL_ROOT, usePageMeta, usePageMetaValue } from './ShellLayout.jsx'
11
11
  export { default as ShellSidebar } from './ShellSidebar.jsx'
12
+ /* the left rail's column and its three states — open · icons · hidden (2026-10-02) */
13
+ export { ShellNavColumn } from './ShellLayout.jsx'
14
+ export { ShellRailModeContext } from './ShellSidebar.jsx'
12
15
  /* RailSection — THE rail ladder. Every rail header at every rung comes from
13
16
  * here, in both rails; see the file header for why a class alone was not
14
17
  * enough. `pnpm validate:rails` R4 asserts it. */
@@ -3,7 +3,7 @@ import * as d3 from 'd3'
3
3
  import { gsap } from 'gsap'
4
4
  import { useNavigate } from 'react-router-dom'
5
5
  import { getTagColor } from '@kolkrabbi/kol-markdown'
6
- import { tagGraph } from '@kolkrabbi/kol-search'
6
+ import { indexGraph } from '@kolkrabbi/kol-search'
7
7
  import { EASE, DURATION, s } from '@kolkrabbi/kol-component/utilities/motion'
8
8
 
9
9
  /**
@@ -30,11 +30,25 @@ import { EASE, DURATION, s } from '@kolkrabbi/kol-component/utilities/motion'
30
30
  * curves are kol-component's motion constants; reduced motion skips the tweens.
31
31
  * - LABELS WHERE THEY HELP. Every node printed its name; now the larger tags carry a label at rest
32
32
  * and the rest show theirs when focused.
33
+ *
34
+ * FILES, ORPHANS AND SETTINGS (2026-10-01 — user ruling: files and orphans beside tags, with filter
35
+ * and display settings; no force controls). The data is kol-search's `indexGraph`:
36
+ *
37
+ * `files` a node per tagged doc, joined to its tags (the tags then join through their docs)
38
+ * `orphans` a node per doc with no tags
39
+ * `filter` text — keeps the nodes it matches
40
+ * `labels` 'auto' (the eight biggest tags, the rest on focus) · 'all' · 'none'
41
+ * `nodeScale` · `linkScale` multipliers on the drawn radius and the edge width (default 1)
42
+ *
43
+ * A doc node is a small neutral dot, named by its title; clicking one calls `onFileClick(node)`,
44
+ * else opens its `href`.
33
45
  */
34
46
  const defaultTagHref = (tag) => `/workshop/design-system/documentation?tag=${encodeURIComponent(tag)}`
35
47
 
36
48
  const radiusOf = (d) => Math.max(5, Math.min(14, 4 + Math.sqrt(d.count) * 2.2))
37
49
  const fillOf = (d) => {
50
+ /* a doc is not a tag: one neutral ink, an orphan fainter still */
51
+ if (d.type === 'file') return d.orphan ? 'var(--kol-oq-24)' : 'var(--kol-oq-48)'
38
52
  /* palette tokens, not copies; `dark` is a surface role with no palette entry */
39
53
  const key = getTagColor(d.id)
40
54
  return key === 'dark' ? 'var(--kol-surface-on-primary)' : `var(--kol-palette-${key}, var(--kol-palette-teal))`
@@ -42,7 +56,7 @@ const fillOf = (d) => {
42
56
  const EDGE = 'color-mix(in srgb, var(--kol-surface-on-primary) 24%, transparent)'
43
57
  const ACCENT = 'var(--kol-accent-primary)'
44
58
 
45
- const TagGraph = ({ docs, activeTag, onTagClick, allDocs, tagHref = defaultTagHref }) => {
59
+ const TagGraph = ({ docs, activeTag, onTagClick, allDocs, tagHref = defaultTagHref, files = false, orphans = false, filter = '', labels = 'auto', nodeScale = 1, linkScale = 1, onFileClick }) => {
46
60
  const svgRef = useRef(null)
47
61
  const containerRef = useRef(null)
48
62
  const sceneRef = useRef(null)
@@ -54,7 +68,10 @@ const TagGraph = ({ docs, activeTag, onTagClick, allDocs, tagHref = defaultTagHr
54
68
 
55
69
  const sourceDocs = allDocs || docs
56
70
  /* the engine's graph (kol-search `tagGraph`, 2026-09-29) over the docs' tags */
57
- const graphData = useMemo(() => tagGraph(sourceDocs.map((d) => ({ id: d.id, tags: d.metadata?.tags ?? [] }))), [sourceDocs])
71
+ const graphData = useMemo(() => indexGraph(
72
+ sourceDocs.map((d) => ({ id: d.id, tags: d.metadata?.tags ?? [], title: d.metadata?.title ?? d.title, href: d.href })),
73
+ { files, orphans, filter },
74
+ ), [sourceDocs, files, orphans, filter])
58
75
 
59
76
  useEffect(() => {
60
77
  const update = () => {
@@ -82,10 +99,12 @@ const TagGraph = ({ docs, activeTag, onTagClick, allDocs, tagHref = defaultTagHr
82
99
  const zoom = d3.zoom().scaleExtent([0.3, 4]).on('zoom', (e) => g.attr('transform', e.transform))
83
100
  svg.call(zoom).on('dblclick.zoom', null)
84
101
 
102
+ /* the drawn radius: a tag by its count, a doc small and fixed, both by the display scale */
103
+ const R = (d) => (d.type === 'file' ? 4 : radiusOf(d)) * nodeScale
85
104
  const nodes = graphData.nodes.map((d) => ({ ...d }))
86
105
  const links = graphData.edges.map((d) => ({ ...d }))
87
106
  const maxW = d3.max(links, (l) => l.weight) || 1
88
- const counts = nodes.map((d) => d.count).sort((a, b) => b - a)
107
+ const counts = nodes.filter((d) => d.type !== 'file').map((d) => d.count).sort((a, b) => b - a)
89
108
  /* the eight biggest tags keep their label at rest; the rest show on focus */
90
109
  const labelFloor = counts[Math.min(7, counts.length - 1)] ?? 0
91
110
 
@@ -108,10 +127,10 @@ const TagGraph = ({ docs, activeTag, onTagClick, allDocs, tagHref = defaultTagHr
108
127
  .force('link', d3.forceLink(links).id((d) => d.id)
109
128
  .distance((l) => 45 + 95 * (1 - l.weight / maxW))
110
129
  .strength((l) => 0.08 + 0.4 * (l.weight / maxW)))
111
- .force('charge', d3.forceManyBody().strength((d) => -70 - radiusOf(d) * 16).distanceMax(span * 0.8))
130
+ .force('charge', d3.forceManyBody().strength((d) => -70 - R(d) * 16).distanceMax(span * 0.8))
112
131
  .force('x', d3.forceX(cx).strength(0.07))
113
132
  .force('y', d3.forceY(cy).strength(0.07))
114
- .force('collide', d3.forceCollide((d) => radiusOf(d) + 9).iterations(2))
133
+ .force('collide', d3.forceCollide((d) => R(d) + 9).iterations(2))
115
134
  .velocityDecay(0.38)
116
135
  .alphaDecay(0.035)
117
136
  .stop()
@@ -120,7 +139,7 @@ const TagGraph = ({ docs, activeTag, onTagClick, allDocs, tagHref = defaultTagHr
120
139
 
121
140
  const link = g.append('g').attr('class', 'links').selectAll('line').data(links).enter().append('line')
122
141
  .attr('stroke', EDGE)
123
- .attr('stroke-width', (l) => 0.6 + 1.6 * (l.weight / maxW))
142
+ .attr('stroke-width', (l) => (0.6 + 1.6 * (l.weight / maxW)) * linkScale)
124
143
  .attr('stroke-linecap', 'round')
125
144
  .attr('opacity', 0)
126
145
 
@@ -135,8 +154,8 @@ const TagGraph = ({ docs, activeTag, onTagClick, allDocs, tagHref = defaultTagHr
135
154
  .attr('stroke-width', 1.5)
136
155
 
137
156
  const label = node.append('text')
138
- .text((d) => `#${d.id}`)
139
- .attr('dy', (d) => radiusOf(d) + 12)
157
+ .text((d) => (d.type === 'file' ? d.label : `#${d.id}`))
158
+ .attr('dy', (d) => R(d) + 12)
140
159
  .attr('text-anchor', 'middle')
141
160
  .attr('fill', 'var(--kol-fg-72)')
142
161
  .attr('font-family', 'var(--kol-font-family-mono)')
@@ -152,7 +171,7 @@ const TagGraph = ({ docs, activeTag, onTagClick, allDocs, tagHref = defaultTagHr
152
171
  sim.on('tick', place)
153
172
 
154
173
  /* ── the resting look, and a focus — both tweened ── */
155
- const restLabel = (d) => (d.count >= labelFloor ? 1 : 0)
174
+ const restLabel = (d) => (labels === 'all' ? 1 : labels === 'none' ? 0 : d.type !== 'file' && d.count >= labelFloor ? 1 : 0)
156
175
  const paint = (focusId) => {
157
176
  const lit = focusId ? near.get(focusId) : null
158
177
  const act = activeRef.current
@@ -160,7 +179,7 @@ const TagGraph = ({ docs, activeTag, onTagClick, allDocs, tagHref = defaultTagHr
160
179
  duration: t(DURATION.slow),
161
180
  ease: EASE.houseGsap,
162
181
  opacity: (i) => (lit ? (lit.has(nodes[i].id) ? 1 : 0.15) : act && nodes[i].id !== act ? 0.45 : 0.95),
163
- attr: { r: (i) => radiusOf(nodes[i]) * (nodes[i].id === focusId ? 1.3 : 1) },
182
+ attr: { r: (i) => R(nodes[i]) * (nodes[i].id === focusId ? 1.3 : 1) },
164
183
  overwrite: 'auto',
165
184
  })
166
185
  gsap.to(label.nodes(), {
@@ -196,7 +215,7 @@ const TagGraph = ({ docs, activeTag, onTagClick, allDocs, tagHref = defaultTagHr
196
215
  gsap.to(circle.nodes(), {
197
216
  duration: t(DURATION.spring),
198
217
  ease: 'back.out(1.6)',
199
- attr: { r: (i) => radiusOf(nodes[i]) },
218
+ attr: { r: (i) => R(nodes[i]) },
200
219
  opacity: 0.95,
201
220
  delay: (i) => (reduced ? 0 : (dist(nodes[i]) / maxDist) * 0.35),
202
221
  onComplete: () => paint(null),
@@ -214,7 +233,8 @@ const TagGraph = ({ docs, activeTag, onTagClick, allDocs, tagHref = defaultTagHr
214
233
  node
215
234
  .on('click', (event, d) => {
216
235
  event.stopPropagation()
217
- if (onTagClick) onTagClick(d.id)
236
+ if (d.type === 'file') { if (onFileClick) onFileClick(d); else if (d.href) navigate(d.href) }
237
+ else if (onTagClick) onTagClick(d.id)
218
238
  else navigate(tagHref(d.id))
219
239
  })
220
240
  .on('mouseenter', (event, d) => { setHoveredNode(d); paint(d.id) })
@@ -231,7 +251,7 @@ const TagGraph = ({ docs, activeTag, onTagClick, allDocs, tagHref = defaultTagHr
231
251
  svg.interrupt()
232
252
  sceneRef.current = null
233
253
  }
234
- }, [graphData, dimensions, navigate, onTagClick, tagHref])
254
+ }, [graphData, dimensions, navigate, onTagClick, onFileClick, tagHref, labels, nodeScale, linkScale])
235
255
 
236
256
  /* ── an active-tag change re-paints; it never rebuilds the layout ── */
237
257
  useEffect(() => { sceneRef.current?.paint(null) }, [activeTag])
@@ -239,7 +259,7 @@ const TagGraph = ({ docs, activeTag, onTagClick, allDocs, tagHref = defaultTagHr
239
259
  if (graphData.nodes.length === 0) {
240
260
  return (
241
261
  <div className="text-fg-48 kol-mono-12 py-4 px-2">
242
- No tags found.
262
+ Nothing to draw.
243
263
  </div>
244
264
  )
245
265
  }
@@ -249,7 +269,7 @@ const TagGraph = ({ docs, activeTag, onTagClick, allDocs, tagHref = defaultTagHr
249
269
  <svg ref={svgRef} width={dimensions.width} height={dimensions.height} className="tag-graph-svg" />
250
270
  {hoveredNode && (
251
271
  <div className="tag-graph-tooltip kol-helper-10 text-emphasis">
252
- #{hoveredNode.id} ({hoveredNode.count} {hoveredNode.count === 1 ? 'doc' : 'docs'})
272
+ {hoveredNode.type === 'file' ? hoveredNode.label : `#${hoveredNode.id} (${hoveredNode.count} ${hoveredNode.count === 1 ? 'doc' : 'docs'})`}
253
273
  </div>
254
274
  )}
255
275
  </div>
@@ -33,7 +33,7 @@ export const TagModeProvider = ({
33
33
  * keywords"). `text` + `activeTags` are two facets of the SAME query, not
34
34
  * two searches — ShellLayout used to hold `searchQuery` in its own local
35
35
  * state while tags lived here, which is the whole reason the app had two
36
- * surfaces. `expanded` is the palette's second state: Enter commits the
36
+ * surfaces. `expanded` is the search modal's second state: Enter commits the
37
37
  * query and opens the results body, Escape collapses it. */
38
38
  const openTagMode = useCallback((tag = null, { view = 'list', expanded = false } = {}) => {
39
39
  setState((prev) => ({
@@ -109,7 +109,7 @@ export const TagModeProvider = ({
109
109
 
110
110
  const value = useMemo(() => ({
111
111
  /* Lets a consumer tell a real provider from the inert fallback, so
112
- * ShellLayout can own its own palette state when mounted without one
112
+ * ShellLayout can own its own search modal state when mounted without one
113
113
  * instead of silently no-op'ing every keystroke. */
114
114
  isProvided: true,
115
115
  isOpen: state.isOpen,
@@ -105,10 +105,10 @@ const TagModeOverlay = () => {
105
105
  <div className="flex items-center justify-between pl-5 min-[1600px]:pl-6">
106
106
  {/* PRIMARY (user 2026-08-01). `outline quiet` is the
107
107
  * recessive rung — this is the one action in the body. */}
108
- <Button variant="primary" size="sm" onClick={clearTags}>
108
+ <Button tone="primary" size="sm" onClick={clearTags}>
109
109
  Clear filters
110
110
  </Button>
111
- {/* NO CHIPS HERE (2026-08-01). The palette renders the
111
+ {/* NO CHIPS HERE (2026-08-01). The search modal renders the
112
112
  * active tags in its input row — this block repeated them
113
113
  * inside the body, so one filter showed as two chips. The
114
114
  * query's facets belong to the query's input. */}