@kolkrabbi/kol-workshop 0.36.0 → 0.38.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.38.0",
4
4
  "private": false,
5
5
  "description": "Docs viewer, shell and tag system",
6
6
  "license": "MIT",
@@ -13,14 +13,14 @@
13
13
  },
14
14
  "dependencies": {
15
15
  "d3": "^7.9.0",
16
- "@kolkrabbi/kol-markdown": "^0.1.2",
17
- "@kolkrabbi/kol-search": "^0.2.0",
16
+ "@kolkrabbi/kol-markdown": "^0.1.3",
17
+ "@kolkrabbi/kol-search": "^0.3.0",
18
18
  "@kolkrabbi/kol-brand": "^0.1.3"
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.165.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
- "@kolkrabbi/kol-framework": "^0.48.0",
34
- "@kolkrabbi/kol-icons": "^0.32.0"
31
+ "@kolkrabbi/kol-component": "^0.239.0",
32
+ "@kolkrabbi/kol-icons": "^0.33.0",
33
+ "@kolkrabbi/kol-theme": "^0.165.0",
34
+ "@kolkrabbi/kol-framework": "^0.49.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}
@@ -63,7 +63,8 @@ export const renderInlineTokens = (
63
63
  return token.text
64
64
  }
65
65
  return (
66
- <a key={tokenKey} href={token.url} className={linkCls}>
66
+ /* a link that leaves the site opens a new tab (2026-10-02); a relative or `#` link stays */
67
+ <a key={tokenKey} href={token.url} className={linkCls} {...(/^https?:/.test(token.url) ? { target: '_blank', rel: 'noreferrer' } : null)}>
67
68
  {token.text}
68
69
  </a>
69
70
  )
@@ -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,8 +1,8 @@
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'
5
- import { IconFrame, ShellDrawer, ShellSearchOverlay, ShortcutsOverlay, SettingsPanel, SettingsSections, SettingsSwitch, Tooltip, useDragResize } from '@kolkrabbi/kol-component'
4
+ import ShellSidebar, { RAIL_FOLD_EVENT, ShellRailModeContext } from './ShellSidebar.jsx'
5
+ import { FullscreenOverlay, 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'
@@ -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
@@ -226,6 +262,7 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
226
262
  const [isFullHeight, setIsFullHeight] = useState(false)
227
263
  const [contentWidth, setContentWidth] = useState('canvas')
228
264
  const [isShortcutsOpen, setIsShortcutsOpen] = useState(false)
265
+ const [isTreeOpen, setIsTreeOpen] = useState(false)
229
266
  const location = useLocation()
230
267
  const navigate = useNavigate()
231
268
 
@@ -281,7 +318,8 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
281
318
  { id: 'nav', label: 'Left rail', combo: '[' },
282
319
  { id: 'toc', label: 'Right rail', combo: ']' },
283
320
  { id: 'rails', label: 'Both rails', combo: '\\' },
284
- { id: 'fold', label: 'Fold all', combo: 'C' },
321
+ { id: 'fold', label: 'Fold / expand all', combo: 'C' },
322
+ { id: 'tree', label: 'Whole tree', combo: 'T' },
285
323
  { id: 'esc', label: 'Close', combo: 'Esc' },
286
324
  ] },
287
325
  ...(shortcuts.length ? [{ section: 'Page', items: shortcuts.map(({ id, label, combo }) => ({ id, label, combo })) }] : []),
@@ -330,6 +368,11 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
330
368
  e.preventDefault()
331
369
  foldedRef.current = !foldedRef.current
332
370
  window.dispatchEvent(new CustomEvent(RAIL_FOLD_EVENT, { detail: { collapsed: foldedRef.current } }))
371
+ } else if (e.key === 't' || e.key === 'T') {
372
+ /* THE WHOLE TREE (user 2026-10-02: *"C … is just about the visual tree per space, I was
373
+ * talking about a shortcut to get the entire tree in any given view"*) */
374
+ e.preventDefault()
375
+ setIsTreeOpen((v) => !v)
333
376
  } else if (shortcutsRef.current.some((s) => s.key === e.key)) {
334
377
  e.preventDefault()
335
378
  shortcutsRef.current.find((s) => s.key === e.key).run()
@@ -374,7 +417,12 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
374
417
  * rails are the workshop shell's own pair (--kol-shell-nav-w ·
375
418
  * --kol-shell-toc-w, 256px each since 2026-09-28): the left used to read
376
419
  * --kol-sidenav-w, the draggable app sidenav's 264/320 ladder. */
377
- const gridCols = showNav
420
+ /* an icon rail's track is the framework's collapsed-rail width — the same strip, one token */
421
+ const gridCols = showNav && navIcons
422
+ ? showToc
423
+ ? '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)]'
424
+ : 'lg:grid-cols-[var(--kol-sidenav-w-collapsed)_minmax(0,1fr)]'
425
+ : showNav
378
426
  ? showToc
379
427
  ? '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
428
  : 'lg:grid-cols-[var(--kol-shell-nav-w)_minmax(0,1fr)]'
@@ -440,7 +488,7 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
440
488
  </>
441
489
  )
442
490
 
443
- /* THE ENGINE IS kol-search (2026-09-28) — the palette ran a first-substring
491
+ /* THE ENGINE IS kol-search (2026-09-28) — the search modal ran a first-substring
444
492
  * match, so `atom` ranked AppHub first on a word in its description and only
445
493
  * one row lit its match. Ranked now, every hit highlighted, and a word that
446
494
  * names a category (`atom`) filters by it. `limitToSpace` (settings) scopes
@@ -472,6 +520,7 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
472
520
  const settingsSections = [
473
521
  { label: 'Layout', rows: [
474
522
  { id: 'nav', label: 'Left rail', render: () => <SettingsSwitch on={!navCollapsed} onChange={(on) => { setNavCollapsed(!on); setPref('navHidden', !on) }} /> },
523
+ { id: 'nav-icons', label: 'Icons only', render: () => <SettingsSwitch on={navIcons} onChange={(on) => setPref('navIcons', on)} /> },
475
524
  { id: 'toc', label: 'Right rail', render: () => <SettingsSwitch on={!tocCollapsed} onChange={(on) => { setTocCollapsed(!on); setPref('tocHidden', !on) }} /> },
476
525
  ] },
477
526
  { label: 'Search', rows: [
@@ -526,9 +575,9 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
526
575
  >
527
576
  <div className={`shell-content-grid grid ${gridCols}`} data-layout={layoutType} style={{ paddingInline: 'var(--kol-pad-chrome-x)' }}>
528
577
  {showNav && (
529
- <NavColumn railRef={navGrab.ref} grip={navGrab.grab}>
530
- {renderSidebar ? renderSidebar({ activeRoute }) : <ShellSidebar routes={routes} basePath={basePath} />}
531
- </NavColumn>
578
+ <ShellNavColumn mode={navIcons ? 'icons' : 'open'} railRef={navGrab.ref} grip={navGrab.grab}>
579
+ {(mode) => (renderSidebar ? renderSidebar({ activeRoute, mode }) : <ShellSidebar routes={routes} basePath={basePath} />)}
580
+ </ShellNavColumn>
532
581
  )}
533
582
 
534
583
  <MainColumn fullHeight={isFullHeight} width={contentWidth} padStart={showNav} padEnd={showToc}>
@@ -573,10 +622,10 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
573
622
  query={searchQuery}
574
623
  onQueryChange={setSearchQuery}
575
624
  results={searchResults}
576
- /* an empty palette opens on the space table, not a blank box */
625
+ /* an empty search modal opens on the space table, not a blank box */
577
626
  suggestions={navItems.map((n) => ({ id: `space:${n.href}`, label: n.label, href: n.href, icon: 'arrow-right', group: 'Spaces' }))}
578
627
  /* 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
628
+ * not a sibling overlay — it is this search modal's second state. Enter
580
629
  * commits the query and swaps the result rows for the full body;
581
630
  * committed tags ride along as chips on the same query. */
582
631
  expanded={tagMode.isProvided && tagMode.expanded}
@@ -596,7 +645,7 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
596
645
  placeholder="Search…"
597
646
  onSelect={(item) => {
598
647
  /* `action` before `href`: not every hit is a destination. A tag
599
- * row FILTERS the same query and the palette STAYS OPEN — closing
648
+ * row FILTERS the same query and the search modal STAYS OPEN — closing
600
649
  * it was the behaviour that forced tags into a second overlay.
601
650
  * Only a destination dismisses. */
602
651
  if (typeof item?.action === 'function') {
@@ -613,6 +662,25 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
613
662
 
614
663
  {isShortcutsOpen && <ShortcutsOverlay shortcuts={SHORTCUTS} onClose={() => setIsShortcutsOpen(false)} />}
615
664
 
665
+ {/* THE WHOLE TREE, FROM ANY PAGE (`T`, 2026-10-02): every space's rail side by side, every
666
+ * fold open, over the page like the search modal. Each column is the consumer's own rail
667
+ * for that space (`renderSidebar`) drawn in the `full` rail mode, so it cannot list
668
+ * anything the rails do not. A row closes the sheet and goes there. */}
669
+ <FullscreenOverlay open={isTreeOpen} onClose={() => setIsTreeOpen(false)} scrim>
670
+ <div className="shell-tree bg-surface-primary">
671
+ {routes.map((route) => (
672
+ <section key={route.id ?? route.path} className="shell-tree-space">
673
+ <Link to={route.path?.startsWith('/') ? route.path : `${basePath}/${route.path ?? ''}`} className="kol-doc-eyebrow text-emphasis" onClick={() => setIsTreeOpen(false)}>{route.label}</Link>
674
+ <ShellRailModeContext.Provider value="full">
675
+ {renderSidebar
676
+ ? renderSidebar({ activeRoute: route, onNavigate: () => setIsTreeOpen(false), mode: 'full' })
677
+ : <ShellSidebar routes={route.children ?? []} basePath={basePath} label={null} onNavigate={() => setIsTreeOpen(false)} />}
678
+ </ShellRailModeContext.Provider>
679
+ </section>
680
+ ))}
681
+ </div>
682
+ </FullscreenOverlay>
683
+
616
684
  {/* THE SHELL'S SETTINGS (2026-09-28, user: "might we also want to utilize a
617
685
  * settings page or settings sidebar … to offload some functional
618
686
  * settings, search settings, sidenav settings"). A right drawer over
@@ -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,18 @@ 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. A third value,
34
+ * `full`, is the whole-tree sheet's: an open rail with every fold open. */
35
+ export const ShellRailModeContext = createContext('open')
36
+
29
37
  const ShellSidebar = ({ routes = [], basePath = '/', onNavigate, label = 'Navigation', labelTo, collapsed, onToggle, defaultCollapsed = false }) => {
30
38
  const location = useLocation()
39
+ const navigate = useNavigate()
40
+ const mode = useContext(ShellRailModeContext)
31
41
  const normalizedPath = location.pathname.replace(/\/$/, '')
32
42
 
33
43
  // Controlled mode: collapsed + onToggle from parent
@@ -39,8 +49,8 @@ const ShellSidebar = ({ routes = [], basePath = '/', onNavigate, label = 'Naviga
39
49
 
40
50
  /* THE RAIL FOLLOWS YOU — INTO A CHILD ONLY (2026-09-30, reversing the 2026-09-28 "open the
41
51
  * 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 keeps that chapter open (2026-10-01, see
43
- * `chainTo`). Every other chapter starts
52
+ * chapter and folds the rest; a chapter's own home leaves that chapter's fold alone (2026-10-02,
53
+ * see `chainTo`). Every other chapter starts
44
54
  * folded — including ones this rail has never seen (a Group-by switch hands it new ids, and an
45
55
  * unknown id used to read as open, so every chapter sprang open).
46
56
  *
@@ -63,28 +73,32 @@ const ShellSidebar = ({ routes = [], basePath = '/', onNavigate, label = 'Naviga
63
73
  /* deepest first: `/sets` holds `/sets/app-shell` by prefix, but the set's own row is the place */
64
74
  const deeper = chainTo(r.children, [...trail, r])
65
75
  if (deeper) return deeper
66
- if (r.children.some(isHere)) return [...trail, r]
67
- /* A GROUP'S OWN HOME KEEPS IT OPEN (user 2026-10-01: *"I dont want to collapse the list
68
- * like that when I just want to go to atoms home and still see the atoms list"* — reverses
69
- * the 2026-09-30 "a chapter's own home opens nothing"). The label is a link; only the
70
- * chevron folds. */
71
- if (r.path && getChildPath(r, basePath).replace(/\/$/, '') === normalizedPath) return [...trail, r]
76
+ /* A GROUP'S OWN HOME LEAVES ITS FOLD AS IT WAS (user 2026-10-02, said many times: *"just
77
+ * clicking a title opens the home, if I want to expand then I fucking click the expand
78
+ * chevron"*). The 2026-10-01 reading opened the group on its own home, so a title click
79
+ * expanded its children; the 2026-09-30 one folded it. Neither: the way down to it opens,
80
+ * the group itself stays as the chevron left it — open stays open, folded stays folded.
81
+ * Checked BEFORE the children: a vault chapter's home is also its `About` row, and reading
82
+ * that as "a child is here" opened every Docs chapter on a title click.
83
+ * `validate:rail-pages` P5 holds it. */
84
+ if (r.path && getChildPath(r, basePath).replace(/\/$/, '') === normalizedPath) return { open: trail, keep: r.id }
85
+ if (r.children.some(isHere)) return { open: [...trail, r] }
72
86
  }
73
87
  return null
74
88
  }
75
- const followed = () => {
76
- const chain = chainTo(routes)
77
- if (!chain) return null
78
- const open = new Set(chain.map((g) => g.id))
79
- return Object.fromEntries(groupsOf(routes).map((g) => [g.id, !open.has(g.id)]))
89
+ const followed = (prev = {}) => {
90
+ const at = chainTo(routes)
91
+ if (!at) return null
92
+ const open = new Set(at.open.map((g) => g.id))
93
+ return Object.fromEntries(groupsOf(routes).map((g) => [g.id, g.id === at.keep ? (prev[g.id] ?? true) : !open.has(g.id)]))
80
94
  }
81
95
  const [collapsedSections, setCollapsedSections] = useState(() => followed() ?? {})
82
96
  useEffect(() => {
83
- const next = followed()
84
- if (next) setCollapsedSections(next)
97
+ setCollapsedSections((prev) => followed(prev) ?? prev)
85
98
  // eslint-disable-next-line react-hooks/exhaustive-deps
86
99
  }, [normalizedPath, routes, basePath])
87
- const isFolded = (route) => collapsedSections[route.id] ?? true
100
+ /* `full` — the whole-tree sheet (`T`, 2026-10-02): every fold open, nothing to follow */
101
+ const isFolded = (route) => (mode === 'full' ? false : collapsedSections[route.id] ?? true)
88
102
 
89
103
  /* FOLD ALL (2026-09-30, the names audit: *"shortcut collapse/expand the categories"*). The
90
104
  * shell's `C` key broadcasts one target state; every rail's chapters take it. */
@@ -100,12 +114,18 @@ const ShellSidebar = ({ routes = [], basePath = '/', onNavigate, label = 'Naviga
100
114
 
101
115
  /* The L1 count is every leaf row under it — RailSection shows it only while
102
116
  * 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)
117
+ /* A PAGE THAT LISTS ITS SECTIONS IS STILL ONE PAGE (2026-10-01): `section: true` children are
118
+ * `#` anchors on their parent's page, not leaves of the tree. */
119
+ const isPage = (r) => !r.children?.length || r.children.every((c) => c.section)
120
+ const leavesOf = (list) => list.reduce((n, r) => n + (isPage(r) ? 1 : leavesOf(r.children)), 0)
104
121
  const leafCount = leavesOf(routes)
105
122
 
106
123
  /* ONE GROUP, AT ANY DEPTH. A chapter's child with children of its own is a chapter again — the
107
124
  * same RailSection rung, the same count and chevron, stepped in one row-indent by `.shell-nav-nest`
108
125
  * so its caret sits on its siblings' text edge. A leaf is a RailRow wherever it lands. */
126
+ /* A ROUTE MAY CARRY A GLYPH (rail icons, open-questions Round 6, ruled 2026-10-01): `icon` is an
127
+ * icon name, drawn on the row or beside the group's label. The consumer decides which routes. */
128
+ const glyphOf = (route) => (route.icon ? <Icon name={route.icon} size={14} className="shrink-0" /> : undefined)
109
129
  const renderGroup = (route) => (
110
130
  <RailSection
111
131
  level={2}
@@ -118,61 +138,91 @@ const ShellSidebar = ({ routes = [], basePath = '/', onNavigate, label = 'Naviga
118
138
  onToggle={() => handleSectionClick(route)}
119
139
  onNavigate={onNavigate}
120
140
  icon={Icon}
141
+ glyph={glyphOf(route)}
121
142
  >
122
- <nav className="shell-nav-items">
143
+ {/* a group that wears a glyph steps its children in by the glyph (14 + the 8 gap), so they
144
+ * still hang under its LABEL — without it they sat left of the word they belong to */}
145
+ <nav className={`shell-nav-items ${route.icon ? 'pl-[22px]' : ''}`.trim()}>
123
146
  {route.children.map((child) => (child.children?.length ? (
124
147
  <div key={child.id} className="shell-nav-nest">{renderGroup(child)}</div>
125
148
  ) : (
126
- <RailRow key={child.id} to={getChildPath(child, basePath)} onNavigate={onNavigate}>
149
+ <RailRow key={child.id} to={getChildPath(child, basePath)} onNavigate={onNavigate}
150
+ icon={glyphOf(child)}
151
+ active={child.section ? `${location.pathname}${location.hash}` === child.path : undefined}>
127
152
  {child.label}
128
153
  </RailRow>
129
154
  )))}
130
155
  </nav>
131
156
  </RailSection>
132
157
  )
158
+ /* ICONS ONLY — one glyph per group (a group's own, else the folder; a page's, else the file), no
159
+ * eyebrow, no rows. The DS nav button on the control ladder's `md` rung, the same rung the header
160
+ * glyphs stand on; the group you are in holds the rail's active wash (`.shell-rail-icons`). */
161
+ if (mode === 'icons') {
162
+ const holds = (r) => isHere(r) || (r.children ?? []).some(holds)
163
+ return (
164
+ <nav className="shell-rail-icons" aria-label={label ?? undefined}>
165
+ {routes.map((route) => (
166
+ <Tooltip key={route.id} label={route.label} placement="right">
167
+ <Button variant="nav" size="md" iconOnly={route.icon ?? (route.children?.length ? 'folder' : 'file')}
168
+ aria-label={route.label} aria-current={holds(route) ? 'page' : undefined}
169
+ onClick={(e) => { navigate(getSectionRootPath(route, basePath)); onNavigate?.(e) }} />
170
+ </Tooltip>
171
+ ))}
172
+ </nav>
173
+ )
174
+ }
175
+
176
+ /* `label={null}` draws the tree with no L1 section over it (2026-10-01) — a space whose rail is
177
+ * its pages, not a category of them (Search: user ruling, the space does not list itself). */
178
+ const body = (
179
+ <div className="shell-rail-stack-inner">
180
+ {/* LOOSE ROWS SHARE ONE LIST (the showcase review W12, 2026-09-30 — user: "the spaces are
181
+ * incorrect in the left sidebar"). Each childless entry rendered its own <nav>, and the
182
+ * stack's 16px gap landed between every pair, so a rail of four rows (Search) read 46px
183
+ * apart instead of a list's 26. Consecutive rows are one run now; a chapter breaks it. */}
184
+ {routes.reduce((runs, route) => {
185
+ const last = runs[runs.length - 1]
186
+ if (!route.children?.length && last && !last.group) last.rows.push(route)
187
+ else runs.push(route.children?.length ? { group: route } : { rows: [route] })
188
+ return runs
189
+ }, []).map((run) => (run.group ? (
190
+ <div key={run.group.id} className="shell-nav-group">
191
+ {renderGroup(run.group)}
192
+ </div>
193
+ ) : (
194
+ /* A CHILDLESS ENTRY IS A ROW, not a chapter (2026-09-30): drawn at L2 it read as an
195
+ * empty category with a blank caret slot. */
196
+ <nav key={run.rows[0].id} className="shell-nav-items">
197
+ {run.rows.map((route) => (
198
+ <RailRow key={route.id} to={route.path ?? getSectionRootPath(route, basePath)} onNavigate={onNavigate} icon={glyphOf(route)}>
199
+ {route.label}
200
+ </RailRow>
201
+ ))}
202
+ </nav>
203
+ )))}
204
+ </div>
205
+ )
133
206
  return (
134
207
  /* One rail layout, one class (2026-08-01). This was `space-y-4` against the
135
208
  * right rail's `space-y-6` against the outer `flex flex-col gap-6` — three
136
209
  * spellings of the same stack, and `space-y` fights any child that owns a
137
210
  * margin, which the eyebrow box does. */
138
211
  <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>
212
+ {label === null ? body : (
213
+ <RailSection
214
+ level={1}
215
+ label={label}
216
+ to={labelTo}
217
+ count={leafCount}
218
+ collapsed={mode === 'full' ? false : navCollapsed}
219
+ onToggle={handleToggle}
220
+ onNavigate={onNavigate}
221
+ icon={Icon}
222
+ >
223
+ {body}
224
+ </RailSection>
225
+ )}
176
226
  </div>
177
227
  )
178
228
  }
@@ -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. */}