@kolkrabbi/kol-workshop 0.35.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.35.0",
3
+ "version": "0.37.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-brand": "^0.1.3",
17
16
  "@kolkrabbi/kol-markdown": "^0.1.2",
18
- "@kolkrabbi/kol-search": "^0.2.0"
17
+ "@kolkrabbi/kol-brand": "^0.1.3",
18
+ "@kolkrabbi/kol-search": "^0.3.0"
19
19
  },
20
20
  "peerDependencies": {
21
- "@kolkrabbi/kol-component": ">=0.234.0",
21
+ "@kolkrabbi/kol-component": ">=0.237.0",
22
22
  "@kolkrabbi/kol-framework": ">=0.45.0",
23
- "@kolkrabbi/kol-theme": ">=0.161.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.234.0",
32
- "@kolkrabbi/kol-icons": "^0.32.0",
33
31
  "@kolkrabbi/kol-framework": "^0.48.0",
34
- "@kolkrabbi/kol-theme": "^0.161.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}
@@ -331,7 +331,9 @@ const DocumentationReader = ({
331
331
  setTocContent(
332
332
  <DocReaderSidebar
333
333
  key={docId}
334
- toc={toc}
334
+ /* THE TITLE LEADS THE OUTLINE (2026-10-01 — user: "something is always on this page"): a
335
+ * chapter index with no h2 used to hand the rail an empty list */
336
+ toc={docTitle ? [{ id: 'page-top', label: docTitle }, ...toc] : toc}
335
337
  allTags={allTags}
336
338
  topTags={topTags}
337
339
  related={related}
@@ -342,7 +344,7 @@ const DocumentationReader = ({
342
344
  />
343
345
  )
344
346
  return () => setTocContent(null)
345
- }, [rail, setTocContent, docId, toc, allTags, topTags, related, docsIndex, components, docFilePath])
347
+ }, [rail, setTocContent, docId, docTitle, toc, allTags, topTags, related, docsIndex, components, docFilePath])
346
348
 
347
349
  if (!doc) {
348
350
  return (
@@ -365,7 +367,7 @@ const DocumentationReader = ({
365
367
  <DocsArticle>
366
368
  {showFrontmatter && <DocsFrontmatter metadata={doc.metadata} docId={docId} />}
367
369
  {docTitle && (
368
- <h1 className="kol-doc-heading">{docTitle}</h1>
370
+ <h1 id="page-top" className="kol-doc-heading scroll-mt-20">{docTitle}</h1>
369
371
  )}
370
372
  {/* Render intro blocks (excluding H1 which is docTitle) */}
371
373
  {introBlocks.filter(b => b.type !== 'heading1').map((block, index) =>
@@ -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>
@@ -1,27 +1,29 @@
1
- import { Fragment, useMemo, useState } from 'react'
1
+ import { Fragment, useEffect, useMemo, useRef, useState } from 'react'
2
2
  import { getTagColor } from '@kolkrabbi/kol-markdown'
3
- import { useSearchParams } from 'react-router-dom'
4
- import { EmptyState, Input, TabsRow, Tag } from '@kolkrabbi/kol-component'
3
+ import { useNavigate, useSearchParams } from 'react-router-dom'
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
- import { DocHeader, DocSection } from '../docs/DocKit.jsx'
8
8
 
9
9
  /**
10
- * 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
11
11
  * lands here with `?q=`; so does a tag clicked anywhere (`#tag`). The WHOLE
12
12
  * state is the query string in the URL — scope and facet picks are written back
13
13
  * into it as `in:` · `kind:` · `cat:` · `tag:` tokens — so Back returns to the
14
14
  * same results, a result link is an ordinary navigation, and a search can be
15
15
  * shared as a link.
16
16
  *
17
- * 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
18
18
  * tag browser and a page whose groups were whatever `sectionLabel` each
19
19
  * source happened to emit.
20
20
  *
21
21
  * @param {Array} items kol-search items: { id, title, href, kind?, space?, category?, tags?, headings?, keywords?, description?, date? }
22
22
  * @param {Array} [spaces] the scope row, `[{ value, label }]` in header order — `value` is an item's `space`
23
- * @param {Array} [facets] `[[field, label]]` shown as filter rows (default kind · category · tags)
23
+ * @param {Array} [facets] `[[field, label]]` — the FIRST is the visible filter row, the rest fold behind `+`
24
+ * with the scope and the read-as line (default kind · category · tags)
24
25
  * @param {number} [limit=60]
26
+ * @param {string} [resultsPath] the route that shows results, when this box can also sit on another page
25
27
  */
26
28
  const DEFAULT_FACETS = [['kind', 'Kind'], ['category', 'Category'], ['tags', 'Tags']]
27
29
  const PREFIX = { space: 'in', kind: 'kind', category: 'cat', tags: 'tag' }
@@ -46,13 +48,18 @@ const readToken = (t) => (t.kind === 'term'
46
48
  ? `${t.field} ${t.value}`
47
49
  : `${t.negate ? 'not ' : ''}${t.field === 'tags' ? 'tag' : t.field}: ${t.value}${t.via === 'smart' ? ' (from the word)' : ''}`)
48
50
 
49
- export default function SearchPage({ items = [], spaces = [], facets = DEFAULT_FACETS, limit = 60, rowVariant = 'underline' }) {
51
+ export default function SearchPage({ items = [], spaces = [], facets = DEFAULT_FACETS, limit = 60, rowVariant = 'underline', rowMeta = 'facts', kindIcons, resultsPath }) {
50
52
  const [params, setParams] = useSearchParams()
53
+ const navigate = useNavigate()
51
54
  const q = params.get('q') ?? ''
52
55
  const index = useMemo(() => createIndex(items), [items])
53
56
  const out = useMemo(() => search(index, q, { limit }), [index, q, limit])
54
57
 
55
- const setQ = (next) => setParams(next.trim() ? { q: next } : {}, { replace: true })
58
+ /* `resultsPath`: the query is written to THAT route — a box on a search home sends its first
59
+ * keystroke straight to the results page instead of filtering in place */
60
+ const setQ = (next) => (resultsPath
61
+ ? navigate(next.trim() ? `${resultsPath}?q=${encodeURIComponent(next)}` : resultsPath, { replace: true })
62
+ : setParams(next.trim() ? { q: next } : {}, { replace: true }))
56
63
  /* a pick adds its token; picking it again takes the token back out */
57
64
  const toggleToken = (field, value) => {
58
65
  const tok = token(field, value)
@@ -61,29 +68,48 @@ export default function SearchPage({ items = [], spaces = [], facets = DEFAULT_F
61
68
  setQ((has ? parts.filter((p) => p !== tok) : [...parts, tok]).join(' '))
62
69
  }
63
70
  const removeToken = (raw) => setQ(q.split(/\s+/).filter((p) => p && p !== raw).join(' '))
71
+ /* THE BOX OWNS ITS TEXT (2026-10-01). The URL is still the state, but a navigation lands a beat
72
+ * after the keystroke, and an input controlled by the URL alone dropped the letter typed in
73
+ * between ("tag" arrived as "tg"). The box shows what was typed; the URL follows; a query that
74
+ * changes from OUTSIDE (a chip, Back, a link) is read back into the box. */
75
+ const [text, setText] = useState(q)
76
+ const pushed = useRef(q)
77
+ useEffect(() => {
78
+ if (q !== pushed.current) { pushed.current = q; setText(q) }
79
+ }, [q])
80
+ const type = (next) => { pushed.current = next.trim() ? next : ''; setText(next); setQ(next) }
64
81
  const inSpace = parseQuery(q).filters.space ?? []
65
82
 
66
- /* THE FACETS ARE TABS (the showcase review W11, 2026-09-30 — user: "could kind category and tags be
67
- * folded into tabs? … if you are searching something and press enter, why do you need to scroll
68
- * million kilometers down to get to the actual link"). Three facet sections sat between the box and
69
- * the results; now one tab strip picks which facet's chips show, and the results follow it. */
70
- const shown = facets.filter(([field]) => (out.facets[field] ?? []).length)
71
- const [facetTab, setFacetTab] = useState(facets[0]?.[0])
72
- const activeFacet = shown.find(([f]) => f === facetTab) ?? shown[0]
83
+ /* ONE FILTER ROW (2026-10-01 — user: "why are there so many categories and options, is it relevant
84
+ * to the results we are seeking as a 'searcher'"; ruled: kind stays, the rest folds behind a `+`).
85
+ * The page was a scope row, a "read as" row, a tab strip and its chips before the first result.
86
+ * Now: the box, one row of kind chips, the results. Scope · read-as · category · tags are the
87
+ * same controls, behind the `+` — and open by themselves while one of them is narrowing the list,
88
+ * so an active filter is never hidden. Reverses W11's tab strip (2026-09-30). */
89
+ const [first, ...rest] = facets.filter(([field]) => (out.facets[field] ?? []).length)
90
+ const tokens = out.query.tokens
91
+ const narrowed = inSpace.length > 0 || tokens.some((t) => t.kind !== 'term' && t.field !== first?.[0])
92
+ const [more, setMore] = useState(false)
93
+ const open = more || narrowed
94
+ const chips = ([field]) => (out.facets[field] ?? []).slice(0, 24).map((f) => (
95
+ <Tag key={f.value} hash={field === 'tags'} color={field === 'tags' ? getTagColor(f.value) : undefined} active={f.selected} onClick={() => toggleToken(field, f.value)}>
96
+ {f.value}<span className="ml-1.5 text-subtle">{f.count}</span>
97
+ </Tag>
98
+ ))
73
99
 
74
100
  return (
75
- <div className="flex flex-col gap-10">
76
- <DocHeader
77
- eyebrow="Search"
78
- title="Search"
79
- lede={q ? `${out.total} result${out.total === 1 ? '' : 's'} for “${q}”.` : 'Every page on the site.'}
80
- />
101
+ <div className="flex flex-col gap-6">
102
+ {/* ONE LINE, AND ONLY ONCE THERE IS A QUERY (2026-10-01 — user: "section is search and the title
103
+ * is search, seems like redundant noise"): the eyebrow, the title and the count were three
104
+ * lines, and the count was printed again on the results heading. Without a query the page is
105
+ * its home's (the consumer renders it above) and this is just the box. */}
106
+ {q && <h1 className="kol-doc-section-title">{`${out.total} result${out.total === 1 ? '' : 's'} for “${q}”.`}</h1>}
81
107
 
82
- <div className="flex flex-col gap-4">
108
+ <div className="flex flex-col gap-3">
83
109
  {/* md, the ladder's default — lg towered over the page (W11) */}
84
110
  <Input
85
- value={q}
86
- onChange={(e) => setQ(e.target.value)}
111
+ value={text}
112
+ onChange={(e) => type(e.target.value)}
87
113
  placeholder="Search — try atom, tag:pattern/input, in:docs, -legacy"
88
114
  iconLeft="search"
89
115
  size="md"
@@ -91,57 +117,65 @@ export default function SearchPage({ items = [], spaces = [], facets = DEFAULT_F
91
117
  autoFocus
92
118
  />
93
119
  {/* every chip on the page is THE Tag, one size (W11 — they were ghost Buttons and md Pills) */}
94
- {spaces.length > 0 && (
95
- <div className="flex flex-wrap items-center gap-2" role="group" aria-label="Scope">
96
- <Tag hash={false} active={!inSpace.length} onClick={() => setQ(q.split(/\s+/).filter((p) => p && !/^in:/.test(p)).join(' '))}>Everything</Tag>
97
- {spaces.map((sp) => (
98
- <Tag key={sp.value} hash={false} active={inSpace.includes(sp.value)} onClick={() => toggleToken('space', sp.value)}>{sp.label}</Tag>
99
- ))}
120
+ {q && (first || spaces.length > 0) && (
121
+ <div className="flex flex-wrap items-center gap-2" role="group" aria-label={first?.[1] ?? 'Filters'}>
122
+ {first && chips(first)}
123
+ <Tag hash={false} variant="secondary" active={open} onClick={() => setMore((m) => !m)} aria-expanded={open} aria-label="More filters">{open ? '−' : '+'}</Tag>
100
124
  </div>
101
125
  )}
102
- {out.query.tokens.length > 0 && (
103
- <div className="flex flex-wrap items-center gap-2">
104
- <span className="kol-doc-eyebrow">Read as</span>
105
- {out.query.tokens.map((t, i) => (
106
- <Tag key={`${t.raw}-${i}`} hash={false} variant="secondary" onRemove={() => removeToken(t.raw)} aria-label={`Remove ${t.raw}`}>{readToken(t)}</Tag>
126
+ {q && open && (
127
+ <div className="flex flex-col gap-3">
128
+ {spaces.length > 0 && (
129
+ <div className="flex flex-wrap items-center gap-2" role="group" aria-label="Scope">
130
+ <span className="kol-doc-eyebrow">Scope</span>
131
+ <Tag hash={false} active={!inSpace.length} onClick={() => setQ(q.split(/\s+/).filter((p) => p && !/^in:/.test(p)).join(' '))}>Everything</Tag>
132
+ {spaces.map((sp) => (
133
+ <Tag key={sp.value} hash={false} active={inSpace.includes(sp.value)} onClick={() => toggleToken('space', sp.value)}>{sp.label}</Tag>
134
+ ))}
135
+ </div>
136
+ )}
137
+ {rest.map((facet) => (
138
+ <div key={facet[0]} className="flex flex-wrap items-center gap-2" role="group" aria-label={facet[1]}>
139
+ <span className="kol-doc-eyebrow">{facet[1]}</span>
140
+ {chips(facet)}
141
+ </div>
107
142
  ))}
143
+ {tokens.length > 0 && (
144
+ <div className="flex flex-wrap items-center gap-2">
145
+ <span className="kol-doc-eyebrow">Read as</span>
146
+ {tokens.map((t, i) => (
147
+ <Tag key={`${t.raw}-${i}`} hash={false} variant="secondary" onRemove={() => removeToken(t.raw)} aria-label={`Remove ${t.raw}`}>{readToken(t)}</Tag>
148
+ ))}
149
+ </div>
150
+ )}
108
151
  </div>
109
152
  )}
110
153
  </div>
111
154
 
112
- {activeFacet && (
113
- <div className="flex flex-col gap-4">
114
- <TabsRow tabs={shown.map(([field, label]) => ({ id: field, label }))} value={activeFacet[0]} onChange={setFacetTab} />
115
- <div className="flex flex-wrap gap-2">
116
- {(out.facets[activeFacet[0]] ?? []).slice(0, 24).map((f) => (
117
- <Tag key={f.value} hash={activeFacet[0] === 'tags'} color={activeFacet[0] === 'tags' ? getTagColor(f.value) : undefined} active={f.selected} onClick={() => toggleToken(activeFacet[0], f.value)}>
118
- {f.value}<span className="ml-1.5 text-subtle">{f.count}</span>
119
- </Tag>
120
- ))}
121
- </div>
122
- </div>
155
+ {q && (
156
+ <section id="results" className="flex flex-col gap-4 scroll-mt-20">
157
+ {out.results.length === 0 ? (
158
+ <EmptyState eyebrow="No results" title="Nothing matches." body="Remove a token above, or try fewer words." />
159
+ ) : (
160
+ <ol className="flex flex-col border-t border-fg-08">
161
+ {out.results.map((r) => (
162
+ <li key={r.item.id} className="border-t border-fg-08 first:border-t-0">
163
+ <ResultRow
164
+ to={r.item.href}
165
+ variant={rowVariant}
166
+ title={<Highlight text={r.item.title} ranges={r.highlights.title} />}
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(' · ')}
170
+ description={r.item.description}
171
+ />
172
+ </li>
173
+ ))}
174
+ </ol>
175
+ )}
176
+ {out.total > out.results.length && <p className="kol-helper-12 text-meta">{out.total - out.results.length} more — narrow the query.</p>}
177
+ </section>
123
178
  )}
124
-
125
- <DocSection id="results" title={`Results (${out.total})`}>
126
- {out.results.length === 0 ? (
127
- <EmptyState eyebrow={q ? 'No results' : 'Search'} title={q ? 'Nothing matches.' : 'Type to search.'} body={q ? 'Remove a token above, or try fewer words.' : undefined} />
128
- ) : (
129
- <ol className="flex flex-col">
130
- {out.results.map((r) => (
131
- <li key={r.item.id} className="border-t border-fg-08 first:border-t-0">
132
- <ResultRow
133
- to={r.item.href}
134
- variant={rowVariant}
135
- title={<Highlight text={r.item.title} ranges={r.highlights.title} />}
136
- meta={[r.item.kind, r.item.category, r.item.space, r.item.date].filter(Boolean).join(' · ')}
137
- description={r.item.description}
138
- />
139
- </li>
140
- ))}
141
- </ol>
142
- )}
143
- {out.total > out.results.length && <p className="kol-helper-12 text-meta">{out.total - out.results.length} more — narrow the query.</p>}
144
- </DocSection>
145
179
  </div>
146
180
  )
147
181
  }
@@ -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
- import { Outlet, Link, useLocation, useNavigate } from 'react-router-dom'
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-8">
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
@@ -139,7 +169,7 @@ const MainColumn = ({ children, fullHeight, width = 'canvas', padStart, padEnd }
139
169
  * below). The breakpoint here and the one in gridCols are one decision and
140
170
  * must not be stated twice differently. */
141
171
  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">
172
+ <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-14">
143
173
  {grip}
144
174
  {/* The width is the grid track (--kol-shell-toc-w). An empty rail still
145
175
  * holds its column (user ruling 2026-08-01): a rail that disappears
@@ -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
@@ -231,10 +267,37 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
231
267
 
232
268
  /* a new page starts at the top of the one scroll region; a hash is the
233
269
  * browser's to resolve */
270
+ /* BACK RETURNS TO WHERE YOU WERE (2026-10-01 — user: "'back' loses the place vertically in the
271
+ * scroll … now I have to scroll and find my place"). Each history entry remembers its own
272
+ * scroll offset; Back/Forward (POP) restores it, a fresh navigation still starts at the top.
273
+ * The page may still be laying out when the entry returns (lazy walls, async demos), so the
274
+ * restore is retried for a moment until the region is tall enough to reach the offset. */
275
+ const navigationType = useNavigationType()
276
+ const scrollMemory = useRef(new Map())
277
+ useEffect(() => {
278
+ const el = document.getElementById('shell-scroll')
279
+ if (!el) return undefined
280
+ const key = location.key
281
+ const save = () => scrollMemory.current.set(key, el.scrollTop)
282
+ el.addEventListener('scroll', save, { passive: true })
283
+ return () => el.removeEventListener('scroll', save)
284
+ }, [location.key])
234
285
  useEffect(() => {
235
- if (location.hash) return
236
- document.getElementById('shell-scroll')?.scrollTo(0, 0)
237
- }, [location.pathname, location.hash])
286
+ if (location.hash) return undefined
287
+ const el = document.getElementById('shell-scroll')
288
+ if (!el) return undefined
289
+ const saved = navigationType === 'POP' ? scrollMemory.current.get(location.key) : 0
290
+ if (!saved) { el.scrollTo(0, 0); return undefined }
291
+ let frame
292
+ let tries = 60
293
+ const restore = () => {
294
+ el.scrollTo(0, saved)
295
+ if (Math.abs(el.scrollTop - saved) > 1 && tries-- > 0) frame = requestAnimationFrame(restore)
296
+ }
297
+ restore()
298
+ return () => cancelAnimationFrame(frame)
299
+ // eslint-disable-next-line react-hooks/exhaustive-deps
300
+ }, [location.pathname, location.hash, location.key])
238
301
 
239
302
  /* THE shortcut map. One list, rendered by the `S` sheet AND bound by the
240
303
  * handler below — a shortcut that isn't in this array doesn't exist, so the
@@ -254,7 +317,7 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
254
317
  { id: 'nav', label: 'Left rail', combo: '[' },
255
318
  { id: 'toc', label: 'Right rail', combo: ']' },
256
319
  { id: 'rails', label: 'Both rails', combo: '\\' },
257
- { id: 'fold', label: 'Fold all', combo: 'C' },
320
+ { id: 'fold', label: 'Fold / expand all', combo: 'C' },
258
321
  { id: 'esc', label: 'Close', combo: 'Esc' },
259
322
  ] },
260
323
  ...(shortcuts.length ? [{ section: 'Page', items: shortcuts.map(({ id, label, combo }) => ({ id, label, combo })) }] : []),
@@ -347,7 +410,12 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
347
410
  * rails are the workshop shell's own pair (--kol-shell-nav-w ·
348
411
  * --kol-shell-toc-w, 256px each since 2026-09-28): the left used to read
349
412
  * --kol-sidenav-w, the draggable app sidenav's 264/320 ladder. */
350
- 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
351
419
  ? showToc
352
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)]'
353
421
  : 'lg:grid-cols-[var(--kol-shell-nav-w)_minmax(0,1fr)]'
@@ -413,7 +481,7 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
413
481
  </>
414
482
  )
415
483
 
416
- /* 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
417
485
  * match, so `atom` ranked AppHub first on a word in its description and only
418
486
  * one row lit its match. Ranked now, every hit highlighted, and a word that
419
487
  * names a category (`atom`) filters by it. `limitToSpace` (settings) scopes
@@ -445,6 +513,7 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
445
513
  const settingsSections = [
446
514
  { label: 'Layout', rows: [
447
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)} /> },
448
517
  { id: 'toc', label: 'Right rail', render: () => <SettingsSwitch on={!tocCollapsed} onChange={(on) => { setTocCollapsed(!on); setPref('tocHidden', !on) }} /> },
449
518
  ] },
450
519
  { label: 'Search', rows: [
@@ -499,9 +568,9 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
499
568
  >
500
569
  <div className={`shell-content-grid grid ${gridCols}`} data-layout={layoutType} style={{ paddingInline: 'var(--kol-pad-chrome-x)' }}>
501
570
  {showNav && (
502
- <NavColumn railRef={navGrab.ref} grip={navGrab.grab}>
503
- {renderSidebar ? renderSidebar({ activeRoute }) : <ShellSidebar routes={routes} basePath={basePath} />}
504
- </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>
505
574
  )}
506
575
 
507
576
  <MainColumn fullHeight={isFullHeight} width={contentWidth} padStart={showNav} padEnd={showToc}>
@@ -546,10 +615,10 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
546
615
  query={searchQuery}
547
616
  onQueryChange={setSearchQuery}
548
617
  results={searchResults}
549
- /* 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 */
550
619
  suggestions={navItems.map((n) => ({ id: `space:${n.href}`, label: n.label, href: n.href, icon: 'arrow-right', group: 'Spaces' }))}
551
620
  /* THE EXPANDED BODY (user ruling 2026-08-01). The tag browser is
552
- * 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
553
622
  * commits the query and swaps the result rows for the full body;
554
623
  * committed tags ride along as chips on the same query. */
555
624
  expanded={tagMode.isProvided && tagMode.expanded}
@@ -560,12 +629,16 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
560
629
  * it. Without a `searchPath` Enter keeps the older in-place browser. */
561
630
  onExpand={searchPath ? openResultsPage : () => tagMode.setExpanded?.(true)}
562
631
  enterLabel={searchPath ? `All results for “${searchQuery.trim()}”` : undefined}
632
+ /* ⌘ENTER AND THE FOOTER LINK open the results page (2026-10-01); plain Enter opens
633
+ * the highlighted row and only falls through to the page when nothing matched. */
634
+ onOpenResults={searchPath ? openResultsPage : undefined}
635
+ resultsLabel={`All results for “${searchQuery.trim()}”`}
563
636
  chips={tagMode.isProvided ? tagMode.activeTags : []}
564
637
  onRemoveChip={tagMode.removeTag}
565
638
  placeholder="Search…"
566
639
  onSelect={(item) => {
567
640
  /* `action` before `href`: not every hit is a destination. A tag
568
- * row FILTERS the same query and the palette STAYS OPEN — closing
641
+ * row FILTERS the same query and the search modal STAYS OPEN — closing
569
642
  * it was the behaviour that forced tags into a second overlay.
570
643
  * Only a destination dismisses. */
571
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
@@ -39,7 +48,8 @@ const ShellSidebar = ({ routes = [], basePath = '/', onNavigate, label = 'Naviga
39
48
 
40
49
  /* THE RAIL FOLLOWS YOU — INTO A CHILD ONLY (2026-09-30, reversing the 2026-09-28 "open the
41
50
  * 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
51
+ * chapter and folds the rest; a chapter's own home keeps that chapter open (2026-10-01, see
52
+ * `chainTo`). Every other chapter starts
43
53
  * folded — including ones this rail has never seen (a Group-by switch hands it new ids, and an
44
54
  * unknown id used to read as open, so every chapter sprang open).
45
55
  *
@@ -63,6 +73,11 @@ const ShellSidebar = ({ routes = [], basePath = '/', onNavigate, label = 'Naviga
63
73
  const deeper = chainTo(r.children, [...trail, r])
64
74
  if (deeper) return deeper
65
75
  if (r.children.some(isHere)) return [...trail, r]
76
+ /* A GROUP'S OWN HOME KEEPS IT OPEN (user 2026-10-01: *"I dont want to collapse the list
77
+ * like that when I just want to go to atoms home and still see the atoms list"* — reverses
78
+ * the 2026-09-30 "a chapter's own home opens nothing"). The label is a link; only the
79
+ * chevron folds. */
80
+ if (r.path && getChildPath(r, basePath).replace(/\/$/, '') === normalizedPath) return [...trail, r]
66
81
  }
67
82
  return null
68
83
  }
@@ -94,12 +109,18 @@ const ShellSidebar = ({ routes = [], basePath = '/', onNavigate, label = 'Naviga
94
109
 
95
110
  /* The L1 count is every leaf row under it — RailSection shows it only while
96
111
  * the section is folded (2026-09-28). At any depth: leaves, not groups. */
97
- 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)
98
116
  const leafCount = leavesOf(routes)
99
117
 
100
118
  /* ONE GROUP, AT ANY DEPTH. A chapter's child with children of its own is a chapter again — the
101
119
  * same RailSection rung, the same count and chevron, stepped in one row-indent by `.shell-nav-nest`
102
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)
103
124
  const renderGroup = (route) => (
104
125
  <RailSection
105
126
  level={2}
@@ -112,61 +133,91 @@ const ShellSidebar = ({ routes = [], basePath = '/', onNavigate, label = 'Naviga
112
133
  onToggle={() => handleSectionClick(route)}
113
134
  onNavigate={onNavigate}
114
135
  icon={Icon}
136
+ glyph={glyphOf(route)}
115
137
  >
116
- <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()}>
117
141
  {route.children.map((child) => (child.children?.length ? (
118
142
  <div key={child.id} className="shell-nav-nest">{renderGroup(child)}</div>
119
143
  ) : (
120
- <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}>
121
147
  {child.label}
122
148
  </RailRow>
123
149
  )))}
124
150
  </nav>
125
151
  </RailSection>
126
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
+ )
127
201
  return (
128
202
  /* One rail layout, one class (2026-08-01). This was `space-y-4` against the
129
203
  * right rail's `space-y-6` against the outer `flex flex-col gap-6` — three
130
204
  * spellings of the same stack, and `space-y` fights any child that owns a
131
205
  * margin, which the eyebrow box does. */
132
206
  <div className="shell-rail-stack-inner">
133
- <RailSection
134
- level={1}
135
- label={label}
136
- to={labelTo}
137
- count={leafCount}
138
- collapsed={navCollapsed}
139
- onToggle={handleToggle}
140
- onNavigate={onNavigate}
141
- icon={Icon}
142
- >
143
- <div className="shell-rail-stack-inner">
144
- {/* LOOSE ROWS SHARE ONE LIST (the showcase review W12, 2026-09-30 — user: "the spaces are
145
- * incorrect in the left sidebar"). Each childless entry rendered its own <nav>, and the
146
- * stack's 16px gap landed between every pair, so a rail of four rows (Search) read 46px
147
- * apart instead of a list's 26. Consecutive rows are one run now; a chapter breaks it. */}
148
- {routes.reduce((runs, route) => {
149
- const last = runs[runs.length - 1]
150
- if (!route.children?.length && last && !last.group) last.rows.push(route)
151
- else runs.push(route.children?.length ? { group: route } : { rows: [route] })
152
- return runs
153
- }, []).map((run) => (run.group ? (
154
- <div key={run.group.id} className="shell-nav-group">
155
- {renderGroup(run.group)}
156
- </div>
157
- ) : (
158
- /* A CHILDLESS ENTRY IS A ROW, not a chapter (2026-09-30): drawn at L2 it read as an
159
- * empty category with a blank caret slot. */
160
- <nav key={run.rows[0].id} className="shell-nav-items">
161
- {run.rows.map((route) => (
162
- <RailRow key={route.id} to={route.path ?? getSectionRootPath(route, basePath)} onNavigate={onNavigate}>
163
- {route.label}
164
- </RailRow>
165
- ))}
166
- </nav>
167
- )))}
168
- </div>
169
- </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
+ )}
170
221
  </div>
171
222
  )
172
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. */}