@kolkrabbi/kol-workshop 0.33.0 → 0.35.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,8 +1,8 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-workshop",
3
- "version": "0.33.0",
3
+ "version": "0.35.0",
4
4
  "private": false,
5
- "description": "KOL workshop/docs system — a handrolled markdown engine, search, tag system (incl. a d3 tag graph), docs viewer, and docs shell. Sits above @kolkrabbi/kol-{theme,icons,component,framework}; content is consumer-injected (the package never globs docs).",
5
+ "description": "Docs viewer, shell and tag system",
6
6
  "license": "MIT",
7
7
  "type": "module",
8
8
  "main": "./src/index.js",
@@ -13,24 +13,25 @@
13
13
  },
14
14
  "dependencies": {
15
15
  "d3": "^7.9.0",
16
- "@kolkrabbi/kol-search": "^0.2.0",
16
+ "@kolkrabbi/kol-brand": "^0.1.3",
17
17
  "@kolkrabbi/kol-markdown": "^0.1.2",
18
- "@kolkrabbi/kol-brand": "^0.1.3"
18
+ "@kolkrabbi/kol-search": "^0.2.0"
19
19
  },
20
20
  "peerDependencies": {
21
- "@kolkrabbi/kol-component": ">=0.228.0",
21
+ "@kolkrabbi/kol-component": ">=0.234.0",
22
22
  "@kolkrabbi/kol-framework": ">=0.45.0",
23
- "@kolkrabbi/kol-theme": ">=0.155.0",
23
+ "@kolkrabbi/kol-theme": ">=0.161.0",
24
24
  "@kolkrabbi/kol-icons": ">=0.18.0",
25
+ "gsap": "^3.13.0",
25
26
  "react": "^18.3.0 || ^19.0.0",
26
27
  "react-dom": "^18.3.0 || ^19.0.0",
27
28
  "react-router-dom": "^6.0.0 || ^7.0.0"
28
29
  },
29
30
  "devDependencies": {
30
- "@kolkrabbi/kol-icons": "^0.30.0",
31
+ "@kolkrabbi/kol-component": "^0.234.0",
32
+ "@kolkrabbi/kol-icons": "^0.32.0",
31
33
  "@kolkrabbi/kol-framework": "^0.48.0",
32
- "@kolkrabbi/kol-theme": "^0.159.0",
33
- "@kolkrabbi/kol-component": "^0.230.0"
34
+ "@kolkrabbi/kol-theme": "^0.161.0"
34
35
  },
35
36
  "files": [
36
37
  "src",
@@ -1,12 +1,13 @@
1
1
  import { useLayoutEffect, useRef, useState } from 'react'
2
- import { Icon, Tag } from '@kolkrabbi/kol-component'
2
+ import { Badge, Icon, Tag } from '@kolkrabbi/kol-component'
3
+ import { getTagColor } from '@kolkrabbi/kol-markdown'
3
4
  import { useTagMode } from '../tags'
4
5
  import TagPath from '../tags/TagPath.jsx'
5
6
 
6
7
  /* EVERY field carries an icon (user ruling 2026-08-01). Ten of these were
7
8
  * hard-coded `null`, so two rows had a glyph and the rest sat at a ragged left
8
9
  * edge — a column that is sometimes iconned is worse than one that never is.
9
- * Every name below is verified present in kol-icon-set-v1. */
10
+ * Every name below is verified present in kol-icon-set-interface. */
10
11
  const FIELD_ICONS = {
11
12
  file: 'file',
12
13
  title: 'type',
@@ -25,7 +26,7 @@ const FIELD_ICONS = {
25
26
  tags: 'hash-02',
26
27
  /* 2026-08-01: these three rendered iconless while the rule above promised
27
28
  * every field carries one — the exact ragged edge that rule exists to stop.
28
- * All verified present in kol-icon-set-v1. */
29
+ * All verified present in kol-icon-set-interface. */
29
30
  id: 'hash-01',
30
31
  reuses: 'repeat',
31
32
  slug: 'external-link',
@@ -133,6 +134,8 @@ const HIDDEN = new Set(['related', 'aliases'])
133
134
  * against the key, never with a text-transform. Every other value is content
134
135
  * and is printed exactly as written. */
135
136
  const CASED_VALUE_FIELDS = new Set(['type', 'status'])
137
+ /* a doc's lifecycle on Badge's status tones (W17): live reads success, in progress warning, gone error */
138
+ const STATUS_TONE = { active: 'success', canonical: 'info', draft: 'warning', archived: 'error', superseded: 'error', deprecated: 'error' }
136
139
 
137
140
  /** Contract order first, then anything else the doc carries, alphabetically. */
138
141
  const orderFields = (metadata) => {
@@ -210,7 +213,7 @@ const DocsFrontmatter = ({ metadata, docId }) => {
210
213
  * `size` is omitted: `sm` is the default and the only size. */
211
214
  <span className="flex flex-wrap gap-1.5">
212
215
  {value.map((tag) => (
213
- <Tag key={tag} onClick={() => openTagMode(tag)}>
216
+ <Tag key={tag} color={getTagColor(tag)} onClick={() => openTagMode(tag)}>
214
217
  {/* the path renderer, not the raw string — namespace dims,
215
218
  * leaf carries. Shared with the tag overlay so the two
216
219
  * surfaces cannot spell a tag two ways. */}
@@ -220,6 +223,9 @@ const DocsFrontmatter = ({ metadata, docId }) => {
220
223
  </span>
221
224
  ) : DATE_FIELDS.has(key) ? (
222
225
  formatDate(String(value))
226
+ ) : key === 'status' && STATUS_TONE[String(value).toLowerCase()] ? (
227
+ /* STATUS WEARS ITS TONE (W17, 2026-09-30 — user: "no active error warning nothing") */
228
+ <Badge variant={STATUS_TONE[String(value).toLowerCase()]} size="sm">{humanise(String(value))}</Badge>
223
229
  ) : CASED_VALUE_FIELDS.has(key) ? (
224
230
  humanise(String(value))
225
231
  ) : Array.isArray(value) ? (
@@ -85,6 +85,9 @@ const DocumentationReader = ({
85
85
  routes = {},
86
86
  docId: docIdProp,
87
87
  showFrontmatter = true,
88
+ /* false → render the article only and leave the right rail to the page (a HOME sits on top of
89
+ * a page whose sections the rail must list — 2026-09-30, "This page (0)" on every home) */
90
+ rail = true,
88
91
  }) => {
89
92
  const {
90
93
  docsIndex = '/docs',
@@ -194,7 +197,22 @@ const DocumentationReader = ({
194
197
  <div key={blockKey} className="max-w-[var(--kol-content-panel)]">
195
198
  <Table
196
199
  variant="simple"
197
- columns={block.headers.map((header, i) => ({ accessor: `c${i}`, header }))}
200
+ /* THE CELL ROLE PER COLUMN (the showcase review W8, 2026-09-30 — the Icons home's third
201
+ * column clipped). Every cell took Table's default `kol-table-cell-text`, which never
202
+ * wraps, inside a wrapper whose scrollbar is hidden: a wide markdown table lost its right
203
+ * edge silently on every doc. A column whose cells all fit in three words and 24 characters is a TOKEN
204
+ * column (single line); any longer cell makes it COPY, which wraps with real leading —
205
+ * the two roles kol-type-roles.css already defines for exactly this split. */
206
+ columns={block.headers.map((header, i) => ({
207
+ accessor: `c${i}`,
208
+ header,
209
+ className: block.rows.some((row) => {
210
+ const text = String(row[i]?.content ?? '')
211
+ return text.split(/\s+/).filter(Boolean).length > 3 || text.length > 24
212
+ })
213
+ ? 'kol-doc-table-copy'
214
+ : 'kol-doc-table-token',
215
+ }))}
198
216
  rows={block.rows.map((row, rowIndex) => ({
199
217
  id: rowIndex,
200
218
  ...Object.fromEntries(row.map((cell, cellIndex) => [
@@ -309,7 +327,7 @@ const DocumentationReader = ({
309
327
  * ShellTocContext then defaults to null, so calling it unguarded threw.
310
328
  * No shell → no right rail, and the reader just renders its article. */
311
329
  useEffect(() => {
312
- if (!setTocContent) return undefined
330
+ if (!setTocContent || !rail) return undefined
313
331
  setTocContent(
314
332
  <DocReaderSidebar
315
333
  key={docId}
@@ -324,7 +342,7 @@ const DocumentationReader = ({
324
342
  />
325
343
  )
326
344
  return () => setTocContent(null)
327
- }, [setTocContent, docId, toc, allTags, topTags, related, docsIndex, components, docFilePath])
345
+ }, [rail, setTocContent, docId, toc, allTags, topTags, related, docsIndex, components, docFilePath])
328
346
 
329
347
  if (!doc) {
330
348
  return (
@@ -1,5 +1,5 @@
1
1
  /**
2
- * ExhibitCard — the specimen header inside an exhibit section: what this
2
+ * ExhibitCard — The header of an exhibit section. the specimen header inside an exhibit section: what this
3
3
  * variant is called, what it does, and (optionally) its technical line and its
4
4
  * prop signature. Recreated from kol-website's local `DesCard` (37L), which
5
5
  * every showcase page in that repo imported by relative path.
@@ -2,7 +2,7 @@ import { Link } from 'react-router-dom'
2
2
  import { Icon } from '@kolkrabbi/kol-component'
3
3
 
4
4
  /**
5
- * ExhibitLinkCard — the card an exhibit's landing page uses for each child
5
+ * ExhibitLinkCard — A card linking to an exhibit page. the card an exhibit's landing page uses for each child
6
6
  * page. Recreated from kol-website's `OverviewCard`, which carried a standing
7
7
  * note on itself: *"vendored verbatim from elder @kol/ui (no DS twin as of
8
8
  * kol-dashboards 0.2.0); lobby to the DS or fold into kol-dashboards if a
@@ -3,7 +3,7 @@ import ExhibitLinkCard from './ExhibitLinkCard.jsx'
3
3
  import useExhibitToc from './useExhibitToc.js'
4
4
 
5
5
  /**
6
- * ExhibitOverview — an exhibit's landing page: what this section is, then every
6
+ * ExhibitOverview — An exhibit's landing page. an exhibit's landing page: what this section is, then every
7
7
  * child page as a card.
8
8
  *
9
9
  * The overview contract kol-website's DashboardOverview implements by hand —
@@ -4,7 +4,7 @@ import RailSection from '../shell/RailSection.jsx'
4
4
  import RailRow from '../shell/RailRow.jsx'
5
5
 
6
6
  /**
7
- * ExhibitSidebar — the rail block an exhibit page registers into the shell's
7
+ * ExhibitSidebar — An exhibit page's rail block. the rail block an exhibit page registers into the shell's
8
8
  * TOC slot: on-this-page, the section's documentation links, and quick actions.
9
9
  * Recreated from kol-website's local `WorkshopSidebarContent`.
10
10
  *
package/src/index.js CHANGED
@@ -14,6 +14,4 @@ export * from './tags/index.js'
14
14
  export * from './exhibit/index.js'
15
15
  export * from './compositions/index.js'
16
16
  export * from './search/index.js'
17
- /* retiring — ContentRow replaces it (retirement ledger, 2026-09-30) */
18
- export { default as ResultRow } from './search/ResultRow.jsx'
19
17
  export * from './engine/index.js'
@@ -1,28 +1,35 @@
1
- /**
2
- * @deprecated — use kol-component's `ContentRow` (variant `article`, `media={false}`). ResultRow was
3
- * minted 2026-09-30 for the search page while the design system already shipped its row; it
4
- * forwards to ContentRow and drops at 30 days on the retirement ledger. `variant` is ignored.
5
- */
6
-
7
- import { useNavigate } from 'react-router-dom'
8
- import { ContentRow } from '@kolkrabbi/kol-component'
1
+ import { Link } from 'react-router-dom'
9
2
 
10
3
  /**
11
- * @deprecated — use kol-component's `ContentRow` (variant `article`, `media={false}`). ResultRow was
12
- * minted 2026-09-30 for the search page while the design system already shipped its row; it
13
- * forwards to ContentRow and drops at 30 days on the retirement ledger. `variant` is ignored.
4
+ * ResultRow — One search result. one search result: a title, a meta line, an optional description, the whole row a
5
+ * link (2026-09-30, open questions Round 2 — the user: *"I like both, is this a component? if it
6
+ * is I would just have it available as variants … underline is more appealing currently"*).
7
+ *
8
+ * Two variants, each a complete pair — the wash needs its x padding, the underline must not have
9
+ * one (Round 1: x padding without a wash was drawn wrong):
10
+ * `underline` (default) — no ground; the title underlines and the row inks up on hover
11
+ * `wash` — x padding and a ground wash on hover
12
+ *
13
+ * @param {string} to the route the row opens
14
+ * @param {ReactNode} title the result's name (a highlighted node is fine)
15
+ * @param {string} [meta] kind · category · space · date
16
+ * @param {ReactNode} [description]
17
+ * @param {'underline'|'wash'} [variant='underline']
14
18
  */
15
- export default function ResultRow({ to, title, meta, description }) {
16
- const navigate = useNavigate()
19
+ const VARIANTS = {
20
+ /* full ink at rest (W11, 2026-09-30 — user: "Results item is also so low opacity"); the ROLES carry
21
+ * the hierarchy — title emphasis, meta, body — not an opacity on the whole row */
22
+ underline: { row: 'group flex flex-col gap-1 py-3', title: 'group-hover:underline underline-offset-4' },
23
+ wash: { row: 'flex flex-col gap-1 px-3 py-3 hover:bg-fg-04', title: '' },
24
+ }
25
+
26
+ export default function ResultRow({ to, title, meta, description, variant = 'underline' }) {
27
+ const v = VARIANTS[variant] ?? VARIANTS.underline
17
28
  return (
18
- <ContentRow
19
- variant="article"
20
- media={false}
21
- href={to}
22
- onNavigate={(e) => { e.preventDefault(); navigate(to) }}
23
- eyebrow={meta}
24
- title={title}
25
- body={description}
26
- />
29
+ <Link to={to} className={v.row}>
30
+ <span className={`kol-doc-body text-emphasis ${v.title}`.trim()}>{title}</span>
31
+ {meta && <span className="kol-helper-12 text-meta">{meta}</span>}
32
+ {description && <span className="kol-doc-body text-body">{description}</span>}
33
+ </Link>
27
34
  )
28
35
  }
@@ -1,11 +1,13 @@
1
- import { Fragment, useMemo } from 'react'
2
- import { useNavigate, useSearchParams } from 'react-router-dom'
3
- import { Button, ContentRow, EmptyState, Input, Pill } from '@kolkrabbi/kol-component'
1
+ import { Fragment, useMemo, useState } from 'react'
2
+ import { getTagColor } from '@kolkrabbi/kol-markdown'
3
+ import { useSearchParams } from 'react-router-dom'
4
+ import { EmptyState, Input, TabsRow, Tag } from '@kolkrabbi/kol-component'
5
+ import ResultRow from './ResultRow.jsx'
4
6
  import { createIndex, search, parseQuery } from '@kolkrabbi/kol-search'
5
7
  import { DocHeader, DocSection } from '../docs/DocKit.jsx'
6
8
 
7
9
  /**
8
- * SearchPage — 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 palette
9
11
  * lands here with `?q=`; so does a tag clicked anywhere (`#tag`). The WHOLE
10
12
  * state is the query string in the URL — scope and facet picks are written back
11
13
  * into it as `in:` · `kind:` · `cat:` · `tag:` tokens — so Back returns to the
@@ -44,8 +46,7 @@ const readToken = (t) => (t.kind === 'term'
44
46
  ? `${t.field} ${t.value}`
45
47
  : `${t.negate ? 'not ' : ''}${t.field === 'tags' ? 'tag' : t.field}: ${t.value}${t.via === 'smart' ? ' (from the word)' : ''}`)
46
48
 
47
- export default function SearchPage({ items = [], spaces = [], facets = DEFAULT_FACETS, limit = 60 }) {
48
- const navigate = useNavigate()
49
+ export default function SearchPage({ items = [], spaces = [], facets = DEFAULT_FACETS, limit = 60, rowVariant = 'underline' }) {
49
50
  const [params, setParams] = useSearchParams()
50
51
  const q = params.get('q') ?? ''
51
52
  const index = useMemo(() => createIndex(items), [items])
@@ -62,31 +63,39 @@ export default function SearchPage({ items = [], spaces = [], facets = DEFAULT_F
62
63
  const removeToken = (raw) => setQ(q.split(/\s+/).filter((p) => p && p !== raw).join(' '))
63
64
  const inSpace = parseQuery(q).filters.space ?? []
64
65
 
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]
73
+
65
74
  return (
66
75
  <div className="flex flex-col gap-10">
67
76
  <DocHeader
68
77
  eyebrow="Search"
69
78
  title="Search"
70
- lede={q ? `${out.total} result${out.total === 1 ? '' : 's'} for “${q}”.` : 'Everything in the site — components, docs, blocks, sets.'}
79
+ lede={q ? `${out.total} result${out.total === 1 ? '' : 's'} for “${q}”.` : 'Every page on the site.'}
71
80
  />
72
81
 
73
82
  <div className="flex flex-col gap-4">
83
+ {/* md, the ladder's default — lg towered over the page (W11) */}
74
84
  <Input
75
85
  value={q}
76
86
  onChange={(e) => setQ(e.target.value)}
77
87
  placeholder="Search — try atom, tag:pattern/input, in:docs, -legacy"
78
88
  iconLeft="search"
79
- size="lg"
89
+ size="md"
80
90
  aria-label="Search query"
81
91
  autoFocus
82
92
  />
93
+ {/* every chip on the page is THE Tag, one size (W11 — they were ghost Buttons and md Pills) */}
83
94
  {spaces.length > 0 && (
84
95
  <div className="flex flex-wrap items-center gap-2" role="group" aria-label="Scope">
85
- <Button variant="ghost" size="sm" pressed={!inSpace.length} onClick={() => setQ(q.split(/\s+/).filter((p) => p && !/^in:/.test(p)).join(' '))}>Everything</Button>
96
+ <Tag hash={false} active={!inSpace.length} onClick={() => setQ(q.split(/\s+/).filter((p) => p && !/^in:/.test(p)).join(' '))}>Everything</Tag>
86
97
  {spaces.map((sp) => (
87
- <Button key={sp.value} variant="ghost" size="sm" pressed={inSpace.includes(sp.value)} onClick={() => toggleToken('space', sp.value)}>
88
- {sp.label}
89
- </Button>
98
+ <Tag key={sp.value} hash={false} active={inSpace.includes(sp.value)} onClick={() => toggleToken('space', sp.value)}>{sp.label}</Tag>
90
99
  ))}
91
100
  </div>
92
101
  )}
@@ -94,29 +103,24 @@ export default function SearchPage({ items = [], spaces = [], facets = DEFAULT_F
94
103
  <div className="flex flex-wrap items-center gap-2">
95
104
  <span className="kol-doc-eyebrow">Read as</span>
96
105
  {out.query.tokens.map((t, i) => (
97
- <button key={`${t.raw}-${i}`} type="button" onClick={() => removeToken(t.raw)} aria-label={`Remove ${t.raw}`} className="cursor-pointer">
98
- <Pill variant={t.kind === 'term' ? 'secondary' : 'primary'} size="md">{readToken(t)} ×</Pill>
99
- </button>
106
+ <Tag key={`${t.raw}-${i}`} hash={false} variant="secondary" onRemove={() => removeToken(t.raw)} aria-label={`Remove ${t.raw}`}>{readToken(t)}</Tag>
100
107
  ))}
101
108
  </div>
102
109
  )}
103
110
  </div>
104
111
 
105
- {facets.map(([field, label]) => {
106
- const values = (out.facets[field] ?? []).slice(0, 16)
107
- if (!values.length) return null
108
- return (
109
- <DocSection key={field} id={`facet-${field}`} title={label}>
110
- <div className="flex flex-wrap gap-2">
111
- {values.map((f) => (
112
- <Button key={f.value} variant="ghost" size="sm" pressed={f.selected} onClick={() => toggleToken(field, f.value)}>
113
- {f.value}<span className="ml-1.5 opacity-60">{f.count}</span>
114
- </Button>
115
- ))}
116
- </div>
117
- </DocSection>
118
- )
119
- })}
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>
123
+ )}
120
124
 
121
125
  <DocSection id="results" title={`Results (${out.total})`}>
122
126
  {out.results.length === 0 ? (
@@ -125,22 +129,18 @@ export default function SearchPage({ items = [], spaces = [], facets = DEFAULT_F
125
129
  <ol className="flex flex-col">
126
130
  {out.results.map((r) => (
127
131
  <li key={r.item.id} className="border-t border-fg-08 first:border-t-0">
128
- {/* THE SHIPPED ROW (2026-09-30): kol-component's ContentRow, the row form of the
129
- * content-card system — not a row minted here. Article form, no cover. */}
130
- <ContentRow
131
- variant="article"
132
- media={false}
133
- href={r.item.href}
134
- onNavigate={(e) => { e.preventDefault(); navigate(r.item.href) }}
135
- eyebrow={[r.item.kind, r.item.category, r.item.space, r.item.date].filter(Boolean).join(' · ')}
132
+ <ResultRow
133
+ to={r.item.href}
134
+ variant={rowVariant}
136
135
  title={<Highlight text={r.item.title} ranges={r.highlights.title} />}
137
- body={r.item.description}
136
+ meta={[r.item.kind, r.item.category, r.item.space, r.item.date].filter(Boolean).join(' · ')}
137
+ description={r.item.description}
138
138
  />
139
139
  </li>
140
140
  ))}
141
141
  </ol>
142
142
  )}
143
- {out.total > out.results.length && <p className="kol-helper-12 text-subtle">{out.total - out.results.length} more — narrow the query.</p>}
143
+ {out.total > out.results.length && <p className="kol-helper-12 text-meta">{out.total - out.results.length} more — narrow the query.</p>}
144
144
  </DocSection>
145
145
  </div>
146
146
  )
@@ -1 +1,2 @@
1
1
  export { default as SearchPage } from './SearchPage.jsx'
2
+ export { default as ResultRow } from './ResultRow.jsx'
@@ -1,5 +1,6 @@
1
1
  import { useState } from 'react'
2
2
  import { Link } from 'react-router-dom'
3
+ import { Icon } from '@kolkrabbi/kol-component'
3
4
 
4
5
  /**
5
6
  * RailSection — THE rail ladder. One component owns every rail header, at
@@ -70,7 +71,7 @@ export default function RailSection({
70
71
  onToggle,
71
72
  defaultCollapsed = false,
72
73
  onNavigate,
73
- icon: IconComponent,
74
+ icon: IconComponent = Icon,
74
75
  children,
75
76
  }) {
76
77
  const [internal, setInternal] = useState(defaultCollapsed)
@@ -120,33 +121,37 @@ export default function RailSection({
120
121
  * the eyebrow felt expand-only. A category's door lives in the header tabs.
121
122
  * L2 keeps the split (2026-08-02 ruling): its label is a chapter's only
122
123
  * door to the landing page, and the chevron marks the toggle. */
123
- const labelNode = to && level === 2 ? (
124
+ /* L1 JOINS L2 (2026-09-30, user: every label opens its home — *"components from the top and
125
+ * components go to the same components home"*; reverses the 2026-08-09 pure-toggle eyebrow). */
126
+ const labelNode = to ? (
124
127
  /* The label navigates; the rest of the row toggles. stopPropagation keeps
125
128
  * one click from doing both. */
126
129
  <Link
127
130
  to={to}
131
+ className="truncate"
128
132
  onClick={(e) => {
133
+ /* the label opens the page, the chevron folds (audit § Homes) */
129
134
  e.stopPropagation()
130
- if (isCollapsed && toggle) toggle()
131
135
  if (onNavigate) onNavigate(e)
132
136
  }}
133
137
  >
134
138
  {label}
135
139
  </Link>
136
140
  ) : (
137
- <span>{label}</span>
141
+ <span className="truncate">{label}</span>
138
142
  )
139
143
 
140
144
  const inner = (
141
145
  <>
142
- <span className="flex items-center gap-2">
146
+ {/* the label truncates like a row does (W4, 2026-09-30: long set names wrapped to two lines) */}
147
+ <span className="flex items-center gap-2 min-w-0">
143
148
  {chevron}
144
149
  {/* L2 rows without a caret keep the caret's width as space, so an
145
150
  * expandable row and a childless one share a left edge. */}
146
151
  {level === 2 && !chevron && <span aria-hidden="true" style={{ width: 12 }} />}
147
152
  {labelNode}
148
153
  </span>
149
- <span className="flex items-center gap-2">
154
+ <span className="flex items-center gap-2 shrink-0">
150
155
  {countNode}
151
156
  {l1State}
152
157
  </span>
@@ -1,8 +1,11 @@
1
1
  import { useState } from 'react'
2
+ import { getTagColor } from '@kolkrabbi/kol-markdown'
2
3
  import { useLocation } from 'react-router-dom'
3
4
  import RailSection from './RailSection.jsx'
4
5
  import RailRow from './RailRow.jsx'
5
6
 
7
+ const hueOf = (tag) => { const c = getTagColor(tag); return c === 'dark' ? undefined : `var(--kol-palette-${c})` }
8
+
6
9
  /**
7
10
  * RightRail — THE right rail. One component, every route.
8
11
  *
@@ -81,6 +84,8 @@ export default function RightRail({
81
84
  tags = [],
82
85
  renderTag,
83
86
  onTagClick,
87
+ /* where every tag in use is listed (the showcase's /search/tags) — a row in the Tags section */
88
+ tagsHref,
84
89
  icon: IconComponent,
85
90
  }) {
86
91
  /* One collapse map, not a useState per section — a section added later must
@@ -100,10 +105,19 @@ export default function RightRail({
100
105
  setPins(next)
101
106
  writePins(next)
102
107
  }
108
+ /* TAGS ARE THEIR OWN SECTION (the showcase review W15, 2026-09-30 — user: "right sidebar has this
109
+ * page, links, and inside links is tags? feel like maybe tags is its own thing? with tag graph and
110
+ * all that"). The tag graph is a TAG view, so the caller's `graph` action moves out of Quick actions
111
+ * and leads the Tags section; every caller already passes it under that id. */
112
+ const graphAction = actions.find((a) => a.id === 'graph')
103
113
  const allActions = [
104
- ...actions,
114
+ ...actions.filter((a) => a.id !== 'graph'),
105
115
  { id: 'pin', label: isPinned ? 'Unpin' : 'Pin', icon: IconComponent ? <IconComponent name="pushpin" size={14} /> : null, onClick: togglePin },
106
116
  ]
117
+ const tagViews = [
118
+ ...(graphAction ? [graphAction] : []),
119
+ ...(tagsHref ? [{ id: 'all-tags', label: 'All tags', to: tagsHref, icon: IconComponent ? <IconComponent name="hash-02" size={14} /> : null }] : []),
120
+ ]
107
121
  const toggle = (key) => setCollapsed((c) => ({ ...c, [key]: !c[key] }))
108
122
 
109
123
  /* OWN TAGS ONLY, GROUPED BY NAMESPACE (2026-09-30, the names audit — ruled on the
@@ -153,7 +167,7 @@ export default function RightRail({
153
167
  </div>
154
168
  </RailSection>
155
169
 
156
- <RailSection level={1} label="Links" count={pins.length + allActions.length + tags.length + related.length}>
170
+ <RailSection level={1} label="Links" count={pins.length + allActions.length + related.length}>
157
171
  <div className="shell-rail-stack-inner">
158
172
  {group('pinned', 'Pinned', pins.length,
159
173
  pins.map((p) => (
@@ -171,26 +185,6 @@ export default function RightRail({
171
185
  ))
172
186
  )}
173
187
 
174
- {group('tags', 'Tags', tags.length, (
175
- <>
176
- {Object.entries(byNamespace).map(([ns, list]) => (
177
- <div key={`ns-${ns}`}>
178
- {ns && <RailRow className="shell-nav-item--muted">{ns.charAt(0).toUpperCase() + ns.slice(1)}</RailRow>}
179
- {list.map(({ tag, leaf }) => (
180
- <RailRow
181
- key={`own-${tag}`}
182
- sub={!!ns}
183
- onClick={() => onTagClick?.(tag)}
184
- icon={IconComponent ? <IconComponent name="hash-02" size={14} /> : null}
185
- >
186
- {leaf}
187
- </RailRow>
188
- ))}
189
- </div>
190
- ))}
191
- </>
192
- ))}
193
-
194
188
  {/* Related carries SOURCES too (user ruling 2026-08-01) — a repo URL
195
189
  * or an external reference is another document this one names, and
196
190
  * splitting them made two sections that answer the same question. */}
@@ -201,6 +195,33 @@ export default function RightRail({
201
195
  )}
202
196
  </div>
203
197
  </RailSection>
198
+
199
+ {/* TAGS — the views of the tag system, then this page's tags, a group per namespace */}
200
+ {(tagViews.length > 0 || tags.length > 0) && (
201
+ <RailSection level={1} label="Tags" count={tags.length}>
202
+ <div className="shell-rail-stack-inner">
203
+ {tagViews.length > 0 && (
204
+ <nav className="shell-nav-items">
205
+ {tagViews.map((a) => (
206
+ <RailRow key={a.id} to={a.to} onClick={a.onClick} icon={a.icon}>{a.label}</RailRow>
207
+ ))}
208
+ </nav>
209
+ )}
210
+ {Object.entries(byNamespace).map(([ns, list]) => <div key={`tags-${ns || 'flat'}`}>{group(`tags-${ns || 'flat'}`, ns ? ns.charAt(0).toUpperCase() + ns.slice(1) : 'Tags', list.length,
211
+ list.map(({ tag, leaf }) => (
212
+ <RailRow
213
+ key={`own-${tag}`}
214
+ onClick={() => onTagClick?.(tag)}
215
+ /* the hash wears the tag's namespace color (W17), the same hue its chip and graph node wear */
216
+ icon={IconComponent ? <span className="inline-flex" style={{ color: hueOf(tag) }}><IconComponent name="hash-02" size={14} /></span> : null}
217
+ >
218
+ {leaf}
219
+ </RailRow>
220
+ ))
221
+ )}</div>)}
222
+ </div>
223
+ </RailSection>
224
+ )}
204
225
  </div>
205
226
  )
206
227
  }
@@ -241,19 +241,21 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
241
241
  * sheet can never drift from the bindings. The sheet is kol-component's
242
242
  * ShortcutsOverlay (2026-09-28): the same sheet, on the same key, as every
243
243
  * app — it was a `?` sheet of its own here. `?` still opens it. */
244
+ /* FEWEST WORDS (the showcase review W16, 2026-09-30 — user: "shortcuts overlay, too wordy"):
245
+ * a label names the thing the key does, nothing more. `\\` hides or shows both rails at once. */
244
246
  const SHORTCUTS = [
245
247
  { section: 'Search', items: [
246
- { id: 'k', label: 'Search everything', combo: '⌘ K' },
247
- { id: 'slash', label: 'Search everything', combo: '/' },
248
- { id: 'enter', label: 'All results, on the search page', combo: '↵' },
248
+ { id: 'k', label: 'Search', combo: '⌘ K , /' },
249
+ { id: 'enter', label: 'All results', combo: '↵' },
249
250
  ] },
250
251
  { section: 'Shell', items: [
251
- { id: 'sheet', label: 'This sheet', combo: 'S' },
252
+ { id: 'sheet', label: 'Shortcuts', combo: 'S' },
252
253
  { id: 'settings', label: 'Settings', combo: ',' },
253
254
  { id: 'nav', label: 'Left rail', combo: '[' },
254
255
  { id: 'toc', label: 'Right rail', combo: ']' },
255
- { id: 'fold', label: 'Fold or open every chapter', combo: 'C' },
256
- { id: 'esc', label: 'Close what is open', combo: 'Esc' },
256
+ { id: 'rails', label: 'Both rails', combo: '\\' },
257
+ { id: 'fold', label: 'Fold all', combo: 'C' },
258
+ { id: 'esc', label: 'Close', combo: 'Esc' },
257
259
  ] },
258
260
  ...(shortcuts.length ? [{ section: 'Page', items: shortcuts.map(({ id, label, combo }) => ({ id, label, combo })) }] : []),
259
261
  ]
@@ -289,6 +291,14 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
289
291
  } else if (e.key === ']') {
290
292
  e.preventDefault()
291
293
  setTocCollapsed((v) => { writeSettings({ ...readSettings(), tocHidden: !v }); return !v })
294
+ } else if (e.key === '\\') {
295
+ /* BOTH RAILS (W16): any rail showing → hide both; both hidden → show both */
296
+ e.preventDefault()
297
+ const s = readSettings()
298
+ const hide = !(s.navHidden && s.tocHidden)
299
+ setNavCollapsed(hide)
300
+ setTocCollapsed(hide)
301
+ writeSettings({ ...s, navHidden: hide, tocHidden: hide })
292
302
  } else if (e.key === 'c' || e.key === 'C') {
293
303
  e.preventDefault()
294
304
  foldedRef.current = !foldedRef.current
@@ -438,7 +448,7 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
438
448
  { id: 'toc', label: 'Right rail', render: () => <SettingsSwitch on={!tocCollapsed} onChange={(on) => { setTocCollapsed(!on); setPref('tocHidden', !on) }} /> },
439
449
  ] },
440
450
  { label: 'Search', rows: [
441
- { id: 'scope', label: 'Quick search in this space only', render: () => <SettingsSwitch on={!!prefs.limitToSpace} onChange={(on) => setPref('limitToSpace', on)} /> },
451
+ { id: 'scope', label: 'This space only', render: () => <SettingsSwitch on={!!prefs.limitToSpace} onChange={(on) => setPref('limitToSpace', on)} /> },
442
452
  ] },
443
453
  ...settings,
444
454
  { label: 'Keys', rowGap: 1, rows: SHORTCUTS.flatMap((sec) => sec.items).map((k) => ({
@@ -536,6 +546,8 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
536
546
  query={searchQuery}
537
547
  onQueryChange={setSearchQuery}
538
548
  results={searchResults}
549
+ /* an empty palette opens on the space table, not a blank box */
550
+ suggestions={navItems.map((n) => ({ id: `space:${n.href}`, label: n.label, href: n.href, icon: 'arrow-right', group: 'Spaces' }))}
539
551
  /* THE EXPANDED BODY (user ruling 2026-08-01). The tag browser is
540
552
  * not a sibling overlay — it is this palette's second state. Enter
541
553
  * commits the query and swaps the result rows for the full body;
@@ -37,55 +37,93 @@ const ShellSidebar = ({ routes = [], basePath = '/', onNavigate, label = 'Naviga
37
37
  const navCollapsed = isControlled ? collapsed : internalCollapsed
38
38
  const handleToggle = isControlled ? onToggle : () => setInternalCollapsed(prev => !prev)
39
39
 
40
- const [collapsedSections, setCollapsedSections] = useState(() => {
41
- const initial = {}
42
- routes.forEach((route) => {
43
- const sectionPath = getSectionRootPath(route, basePath)
44
- const isActive =
45
- sectionPath === basePath
46
- ? normalizedPath === basePath
47
- : normalizedPath === sectionPath || normalizedPath.startsWith(sectionPath + '/')
48
- initial[route.id] = !isActive
49
- })
50
- return initial
51
- })
52
-
53
- /* THE RAIL FOLLOWS YOU (2026-09-28, user: "the sidebar doesnt highlight that
54
- * part, which it probably should by closing/collapsing other categories").
55
- * Arriving in a group opens it AND folds its siblings, so the open group is
56
- * the one you are in. Opening another by hand still works until you move. */
57
- const isRouteActive = (route) => {
58
- const sectionPath = getSectionRootPath(route, basePath)
59
- const own = sectionPath === basePath
60
- ? normalizedPath === basePath
61
- : normalizedPath === sectionPath || normalizedPath.startsWith(sectionPath + '/')
62
- return own || (route.children ?? []).some((c) => {
63
- const cp = getChildPath(c, basePath).replace(/\/$/, '')
64
- return normalizedPath === cp || normalizedPath.startsWith(cp + '/')
65
- })
40
+ /* THE RAIL FOLLOWS YOU — INTO A CHILD ONLY (2026-09-30, reversing the 2026-09-28 "open the
41
+ * group you are in" on its landing page too). Landing on a page INSIDE a chapter opens that
42
+ * chapter and folds the rest; a chapter's own home opens nothing. Every other chapter starts
43
+ * folded — including ones this rail has never seen (a Group-by switch hands it new ids, and an
44
+ * unknown id used to read as open, so every chapter sprang open).
45
+ *
46
+ * ANY DEPTH (2026-09-30, the showcase review W1 — user: Library › Composition › Components ›
47
+ * Atoms › Button). A chapter may hold chapters; every group at every depth is a fold of its own,
48
+ * and the chain down to where you are opens and everything off it folds. */
49
+ const isHere = (route) => {
50
+ /* a pathless group is not a place — without this its basePath fallback matched every URL */
51
+ if (!route.path) return false
52
+ const cp = getChildPath(route, basePath).replace(/\/$/, '')
53
+ return normalizedPath === cp || normalizedPath.startsWith(cp + '/')
54
+ }
55
+ const groupsOf = (list) => list.flatMap((r) => (r.children?.length ? [r, ...groupsOf(r.children)] : []))
56
+ /* ONE PLACE YOU ARE (W4, 2026-09-30). A page can sit in more than one group — Button is an
57
+ * atom AND a member of every set that uses it — and opening every group that holds it would
58
+ * unfold half the rail. The first place in tree order wins: its chain opens, the rest fold. */
59
+ const chainTo = (list, trail = []) => {
60
+ for (const r of list) {
61
+ if (!r.children?.length) continue
62
+ /* deepest first: `/sets` holds `/sets/app-shell` by prefix, but the set's own row is the place */
63
+ const deeper = chainTo(r.children, [...trail, r])
64
+ if (deeper) return deeper
65
+ if (r.children.some(isHere)) return [...trail, r]
66
+ }
67
+ return null
68
+ }
69
+ const followed = () => {
70
+ const chain = chainTo(routes)
71
+ if (!chain) return null
72
+ const open = new Set(chain.map((g) => g.id))
73
+ return Object.fromEntries(groupsOf(routes).map((g) => [g.id, !open.has(g.id)]))
66
74
  }
75
+ const [collapsedSections, setCollapsedSections] = useState(() => followed() ?? {})
67
76
  useEffect(() => {
68
- const active = routes.filter(isRouteActive)
69
- if (!active.length) return
70
- setCollapsedSections(Object.fromEntries(routes.map((r) => [r.id, !active.includes(r)])))
77
+ const next = followed()
78
+ if (next) setCollapsedSections(next)
71
79
  // eslint-disable-next-line react-hooks/exhaustive-deps
72
80
  }, [normalizedPath, routes, basePath])
81
+ const isFolded = (route) => collapsedSections[route.id] ?? true
73
82
 
74
83
  /* FOLD ALL (2026-09-30, the names audit: *"shortcut collapse/expand the categories"*). The
75
84
  * shell's `C` key broadcasts one target state; every rail's chapters take it. */
76
85
  useEffect(() => {
77
- const onFold = (e) => setCollapsedSections(Object.fromEntries(routes.map((r) => [r.id, !!e.detail?.collapsed])))
86
+ const onFold = (e) => setCollapsedSections(Object.fromEntries(groupsOf(routes).map((r) => [r.id, !!e.detail?.collapsed])))
78
87
  window.addEventListener(RAIL_FOLD_EVENT, onFold)
79
88
  return () => window.removeEventListener(RAIL_FOLD_EVENT, onFold)
80
89
  }, [routes])
81
90
 
82
91
  const handleSectionClick = (route) => {
83
- setCollapsedSections((prev) => ({ ...prev, [route.id]: !prev[route.id] }))
92
+ setCollapsedSections((prev) => ({ ...prev, [route.id]: !(prev[route.id] ?? true) }))
84
93
  }
85
94
 
86
95
  /* The L1 count is every leaf row under it — RailSection shows it only while
87
- * the section is folded (2026-09-28). */
88
- const leafCount = routes.reduce((n, r) => n + (r.children?.length ? r.children.length : 1), 0)
96
+ * 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)
98
+ const leafCount = leavesOf(routes)
99
+
100
+ /* ONE GROUP, AT ANY DEPTH. A chapter's child with children of its own is a chapter again — the
101
+ * same RailSection rung, the same count and chevron, stepped in one row-indent by `.shell-nav-nest`
102
+ * so its caret sits on its siblings' text edge. A leaf is a RailRow wherever it lands. */
103
+ const renderGroup = (route) => (
104
+ <RailSection
105
+ level={2}
106
+ label={route.label}
107
+ count={route.children.length}
108
+ /* THE HEADER ALWAYS LINKS (2026-08-02): the label opens the chapter's home,
109
+ * the chevron folds. */
110
+ to={route.path ?? getSectionRootPath(route, basePath)}
111
+ collapsed={isFolded(route)}
112
+ onToggle={() => handleSectionClick(route)}
113
+ onNavigate={onNavigate}
114
+ icon={Icon}
115
+ >
116
+ <nav className="shell-nav-items">
117
+ {route.children.map((child) => (child.children?.length ? (
118
+ <div key={child.id} className="shell-nav-nest">{renderGroup(child)}</div>
119
+ ) : (
120
+ <RailRow key={child.id} to={getChildPath(child, basePath)} onNavigate={onNavigate}>
121
+ {child.label}
122
+ </RailRow>
123
+ )))}
124
+ </nav>
125
+ </RailSection>
126
+ )
89
127
  return (
90
128
  /* One rail layout, one class (2026-08-01). This was `space-y-4` against the
91
129
  * right rail's `space-y-6` against the outer `flex flex-col gap-6` — three
@@ -103,48 +141,30 @@ const ShellSidebar = ({ routes = [], basePath = '/', onNavigate, label = 'Naviga
103
141
  icon={Icon}
104
142
  >
105
143
  <div className="shell-rail-stack-inner">
106
- {routes.map((route) => {
107
- /* A group with no children is not a group — it is a link. It used
108
- * to render as a header anyway: a chevron that rotated over an
109
- * empty body, no count, and no navigation, so clicking "Icons" or
110
- * "Components" in the tree did nothing at all while looking like
111
- * it should. `collapsible` is what that distinction is now. */
112
- const hasChildren = route.children?.length > 0
113
-
114
- return (
115
- <div key={route.id} className="shell-nav-group">
116
- <RailSection
117
- level={2}
118
- label={route.label}
119
- count={hasChildren ? route.children.length : undefined}
120
- /* THE HEADER ALWAYS LINKS (2026-08-02). It used to link only
121
- * when the group had NO children, so a chapter with pages had
122
- * a dead header and its index had to ride as a row inside
123
- * itself. RailSection already separates the two gestures —
124
- * the label navigates, the rest of the row toggles — so a
125
- * group can open its landing page and still collapse. */
126
- to={route.path ?? getSectionRootPath(route, basePath)}
127
- collapsible={hasChildren}
128
- collapsed={!!collapsedSections[route.id]}
129
- onToggle={() => handleSectionClick(route)}
130
- onNavigate={onNavigate}
131
- icon={Icon}
132
- >
133
- <nav className="shell-nav-items">
134
- {(route.children ?? []).map((child) => (
135
- <RailRow
136
- key={child.id}
137
- to={getChildPath(child, basePath)}
138
- onNavigate={onNavigate}
139
- >
140
- {child.label}
141
- </RailRow>
142
- ))}
143
- </nav>
144
- </RailSection>
145
- </div>
146
- )
147
- })}
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
+ )))}
148
168
  </div>
149
169
  </RailSection>
150
170
  </div>
@@ -1,8 +1,10 @@
1
1
  import { useEffect, useRef, useMemo, useState } from 'react'
2
2
  import * as d3 from 'd3'
3
+ import { gsap } from 'gsap'
3
4
  import { useNavigate } from 'react-router-dom'
4
5
  import { getTagColor } from '@kolkrabbi/kol-markdown'
5
6
  import { tagGraph } from '@kolkrabbi/kol-search'
7
+ import { EASE, DURATION, s } from '@kolkrabbi/kol-component/utilities/motion'
6
8
 
7
9
  /**
8
10
  * TagGraph - Obsidian-style tag graph
@@ -12,209 +14,227 @@ import { tagGraph } from '@kolkrabbi/kol-search'
12
14
  *
13
15
  * `tagHref(tag)` builds the navigation target when no `onTagClick` is provided
14
16
  * (route-decoupled — no hardcoded workshop paths here).
17
+ *
18
+ * REBUILT (the showcase review W14, 2026-09-30 — user: *"the node graph is fucked, it needs better
19
+ * gsap and physics. asap."*). What was wrong, and what replaced it:
20
+ *
21
+ * - ONE SIMULATION. Every hover and every active-tag change wiped the SVG and restarted the
22
+ * simulation from random positions, so the graph re-exploded on each interaction. The layout now
23
+ * builds once per data/size; highlight is a separate, tweened pass over the same elements.
24
+ * - PHYSICS THAT SCALES. Fixed numbers (charge −150, link 60, collide 30) whatever the graph held.
25
+ * Now: charge by node size and capped in range, link length and pull by how many pages two tags
26
+ * share, a gentle gravity so the cluster stays centred, collision from the drawn radius. Seeded on
27
+ * a phyllotaxis spiral and settled BEFORE the first frame, so it opens composed, not exploding.
28
+ * - MOTION ON THE HOUSE CURVE. Nodes grow in from the centre outward, edges fade in, the view eases
29
+ * to fit; hovering a tag brings it and its neighbours forward and dims the rest. Durations and
30
+ * curves are kol-component's motion constants; reduced motion skips the tweens.
31
+ * - LABELS WHERE THEY HELP. Every node printed its name; now the larger tags carry a label at rest
32
+ * and the rest show theirs when focused.
15
33
  */
16
34
  const defaultTagHref = (tag) => `/workshop/design-system/documentation?tag=${encodeURIComponent(tag)}`
17
35
 
36
+ const radiusOf = (d) => Math.max(5, Math.min(14, 4 + Math.sqrt(d.count) * 2.2))
37
+ const fillOf = (d) => {
38
+ /* palette tokens, not copies; `dark` is a surface role with no palette entry */
39
+ const key = getTagColor(d.id)
40
+ return key === 'dark' ? 'var(--kol-surface-on-primary)' : `var(--kol-palette-${key}, var(--kol-palette-teal))`
41
+ }
42
+ const EDGE = 'color-mix(in srgb, var(--kol-surface-on-primary) 24%, transparent)'
43
+ const ACCENT = 'var(--kol-accent-primary)'
44
+
18
45
  const TagGraph = ({ docs, activeTag, onTagClick, allDocs, tagHref = defaultTagHref }) => {
19
46
  const svgRef = useRef(null)
20
47
  const containerRef = useRef(null)
48
+ const sceneRef = useRef(null)
21
49
  const navigate = useNavigate()
22
50
  const [dimensions, setDimensions] = useState({ width: 300, height: 300 })
23
51
  const [hoveredNode, setHoveredNode] = useState(null)
52
+ const activeRef = useRef(activeTag)
53
+ activeRef.current = activeTag
24
54
 
25
- // Use allDocs if provided, otherwise fall back to docs
26
55
  const sourceDocs = allDocs || docs
27
-
28
- // Compute graph data: ALL tags with connections (pure — see engine/tags.js)
29
56
  /* the engine's graph (kol-search `tagGraph`, 2026-09-29) over the docs' tags */
30
57
  const graphData = useMemo(() => tagGraph(sourceDocs.map((d) => ({ id: d.id, tags: d.metadata?.tags ?? [] }))), [sourceDocs])
31
58
 
32
- // Update dimensions on resize
33
59
  useEffect(() => {
34
- const updateDimensions = () => {
35
- if (containerRef.current) {
36
- const { width } = containerRef.current.getBoundingClientRect()
37
- const size = Math.max(250, width)
38
- setDimensions({ width: size, height: size })
39
- }
60
+ const update = () => {
61
+ if (!containerRef.current) return
62
+ const { width } = containerRef.current.getBoundingClientRect()
63
+ const size = Math.max(250, width)
64
+ setDimensions((d) => (d.width === size ? d : { width: size, height: size }))
40
65
  }
41
- updateDimensions()
42
- const resizeObserver = new ResizeObserver(updateDimensions)
43
- if (containerRef.current) {
44
- resizeObserver.observe(containerRef.current)
45
- }
46
- return () => resizeObserver.disconnect()
66
+ update()
67
+ const ro = new ResizeObserver(update)
68
+ if (containerRef.current) ro.observe(containerRef.current)
69
+ return () => ro.disconnect()
47
70
  }, [])
48
71
 
49
- // Render the force-directed graph
72
+ /* ── THE LAYOUT — once per data and size ─────────────────────────────────── */
50
73
  useEffect(() => {
51
74
  if (!svgRef.current || graphData.nodes.length === 0) return
52
-
53
75
  const { width, height } = dimensions
76
+ const reduced = typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
77
+ const t = (ms) => (reduced ? 0 : s(ms))
54
78
 
55
- // Clear previous
56
79
  const svg = d3.select(svgRef.current)
57
80
  svg.selectAll('*').remove()
58
-
59
- // Create container group
60
81
  const g = svg.append('g')
82
+ const zoom = d3.zoom().scaleExtent([0.3, 4]).on('zoom', (e) => g.attr('transform', e.transform))
83
+ svg.call(zoom).on('dblclick.zoom', null)
61
84
 
62
- // Create zoom behavior
63
- const zoom = d3.zoom()
64
- .scaleExtent([0.3, 3])
65
- .on('zoom', (event) => {
66
- g.attr('transform', event.transform)
67
- })
68
- svg.call(zoom)
69
-
70
- // Prepare data with D3-friendly structure
71
- const nodes = graphData.nodes.map(d => ({ ...d }))
72
- const links = graphData.edges.map(d => ({ ...d }))
73
-
74
- // Create force simulation
75
- const simulation = d3.forceSimulation(nodes)
76
- .force('link', d3.forceLink(links).id(d => d.id).distance(60).strength(0.5))
77
- .force('charge', d3.forceManyBody().strength(-150))
78
- .force('center', d3.forceCenter(width / 2, height / 2))
79
- .force('collision', d3.forceCollide().radius(30))
80
-
81
- // Draw edges
82
- const link = g.append('g')
83
- .attr('class', 'links')
84
- .selectAll('line')
85
- .data(links)
86
- .enter()
87
- .append('line')
88
- .attr('stroke', d => {
89
- // Highlight edges connected to active tag
90
- if (activeTag && (d.source.id === activeTag || d.target.id === activeTag)) {
91
- return 'var(--kol-accent-primary)'
92
- }
93
- return 'color-mix(in srgb, var(--kol-surface-on-primary) 20%, transparent)'
94
- })
95
- .attr('stroke-width', d => {
96
- if (activeTag && (d.source.id === activeTag || d.target.id === activeTag)) {
97
- return 2
98
- }
99
- return Math.min(2, d.weight * 0.5)
100
- })
101
- .attr('opacity', d => {
102
- if (!activeTag) return 0.6
103
- return (d.source.id === activeTag || d.target.id === activeTag) ? 1 : 0.2
104
- })
85
+ const nodes = graphData.nodes.map((d) => ({ ...d }))
86
+ const links = graphData.edges.map((d) => ({ ...d }))
87
+ const maxW = d3.max(links, (l) => l.weight) || 1
88
+ const counts = nodes.map((d) => d.count).sort((a, b) => b - a)
89
+ /* the eight biggest tags keep their label at rest; the rest show on focus */
90
+ const labelFloor = counts[Math.min(7, counts.length - 1)] ?? 0
91
+
92
+ /* neighbours, for focus */
93
+ const near = new Map(nodes.map((d) => [d.id, new Set([d.id])]))
94
+ for (const l of graphData.edges) { near.get(l.source)?.add(l.target); near.get(l.target)?.add(l.source) }
95
+
96
+ /* seed on a phyllotaxis spiral, biggest first — deterministic, and already roughly in place */
97
+ const cx = width / 2
98
+ const cy = height / 2
99
+ ;[...nodes].sort((a, b) => b.count - a.count).forEach((d, i) => {
100
+ const r = 10 * Math.sqrt(i + 0.5)
101
+ const a = i * 2.39996
102
+ d.x = cx + r * Math.cos(a)
103
+ d.y = cy + r * Math.sin(a)
104
+ })
105
105
 
106
- // Draw nodes
107
- const node = g.append('g')
108
- .attr('class', 'nodes')
109
- .selectAll('g')
110
- .data(nodes)
111
- .enter()
112
- .append('g')
106
+ const span = Math.min(width, height)
107
+ const sim = d3.forceSimulation(nodes)
108
+ .force('link', d3.forceLink(links).id((d) => d.id)
109
+ .distance((l) => 45 + 95 * (1 - l.weight / maxW))
110
+ .strength((l) => 0.08 + 0.4 * (l.weight / maxW)))
111
+ .force('charge', d3.forceManyBody().strength((d) => -70 - radiusOf(d) * 16).distanceMax(span * 0.8))
112
+ .force('x', d3.forceX(cx).strength(0.07))
113
+ .force('y', d3.forceY(cy).strength(0.07))
114
+ .force('collide', d3.forceCollide((d) => radiusOf(d) + 9).iterations(2))
115
+ .velocityDecay(0.38)
116
+ .alphaDecay(0.035)
117
+ .stop()
118
+ /* settle before the first frame — it opens composed, not exploding */
119
+ for (let i = 0; i < 260; i++) sim.tick()
120
+
121
+ const link = g.append('g').attr('class', 'links').selectAll('line').data(links).enter().append('line')
122
+ .attr('stroke', EDGE)
123
+ .attr('stroke-width', (l) => 0.6 + 1.6 * (l.weight / maxW))
124
+ .attr('stroke-linecap', 'round')
125
+ .attr('opacity', 0)
126
+
127
+ const node = g.append('g').attr('class', 'nodes').selectAll('g').data(nodes).enter().append('g')
113
128
  .attr('class', 'node')
114
129
  .style('cursor', 'pointer')
115
- .call(d3.drag()
116
- .on('start', dragstarted)
117
- .on('drag', dragged)
118
- .on('end', dragended))
119
-
120
- // Node circles
121
- node.append('circle')
122
- .attr('r', d => Math.max(8, Math.min(16, 6 + d.count * 2)))
123
- /* Palette tokens, not copies. This held eight hardcoded hexes that were
124
- * transcriptions of --kol-palette-* (#3740D3 IS palette-blue) — correct
125
- * the day they were typed and frozen ever since, so a retuned palette
126
- * would move every chip and leave the graph behind. The chip rules in
127
- * kol-components-molecules.css resolve the variables; the graph now
128
- * reads the same ones. `dark` has no palette entry — it is a surface
129
- * role — so it falls back rather than emitting an invalid var(). */
130
- .attr('fill', d => {
131
- const key = getTagColor(d.id)
132
- return key === 'dark'
133
- ? 'var(--kol-surface-on-primary)'
134
- : `var(--kol-palette-${key}, var(--kol-palette-teal))`
135
- })
136
- .attr('opacity', d => activeTag && d.id !== activeTag ? 0.4 : 0.9)
137
- .attr('stroke', d => d.id === activeTag ? 'var(--kol-accent-primary)' : 'none')
138
- .attr('stroke-width', d => d.id === activeTag ? 2 : 0)
139
-
140
- // Node labels
141
- node.append('text')
142
- .text(d => `#${d.id}`)
143
- .attr('dy', d => Math.max(8, Math.min(16, 6 + d.count * 2)) + 12)
130
+
131
+ const circle = node.append('circle')
132
+ .attr('r', 0)
133
+ .attr('fill', fillOf)
134
+ .attr('stroke', 'var(--kol-surface-primary)')
135
+ .attr('stroke-width', 1.5)
136
+
137
+ const label = node.append('text')
138
+ .text((d) => `#${d.id}`)
139
+ .attr('dy', (d) => radiusOf(d) + 12)
144
140
  .attr('text-anchor', 'middle')
145
- .attr('fill', 'color-mix(in srgb, var(--kol-surface-on-primary) 64%, transparent)')
141
+ .attr('fill', 'var(--kol-fg-72)')
146
142
  .attr('font-family', 'var(--kol-font-family-mono)')
147
143
  .attr('font-size', '9px')
148
144
  .attr('pointer-events', 'none')
145
+ .attr('opacity', 0)
149
146
 
150
- // Node interactions
151
- node
152
- .on('click', (event, d) => {
153
- event.stopPropagation()
154
- if (onTagClick) {
155
- onTagClick(d.id)
156
- } else {
157
- navigate(tagHref(d.id))
158
- }
147
+ const place = () => {
148
+ link.attr('x1', (d) => d.source.x).attr('y1', (d) => d.source.y).attr('x2', (d) => d.target.x).attr('y2', (d) => d.target.y)
149
+ node.attr('transform', (d) => `translate(${d.x}, ${d.y})`)
150
+ }
151
+ place()
152
+ sim.on('tick', place)
153
+
154
+ /* ── the resting look, and a focus — both tweened ── */
155
+ const restLabel = (d) => (d.count >= labelFloor ? 1 : 0)
156
+ const paint = (focusId) => {
157
+ const lit = focusId ? near.get(focusId) : null
158
+ const act = activeRef.current
159
+ gsap.to(circle.nodes(), {
160
+ duration: t(DURATION.slow),
161
+ ease: EASE.houseGsap,
162
+ 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) },
164
+ overwrite: 'auto',
159
165
  })
160
- .on('mouseenter', (event, d) => {
161
- setHoveredNode(d)
162
- // Highlight connected edges
163
- link
164
- .attr('opacity', l => l.source.id === d.id || l.target.id === d.id ? 1 : 0.15)
165
- .attr('stroke', l => l.source.id === d.id || l.target.id === d.id
166
- ? 'var(--kol-accent-primary)'
167
- : 'color-mix(in srgb, var(--kol-surface-on-primary) 20%, transparent)')
166
+ gsap.to(label.nodes(), {
167
+ duration: t(DURATION.slow),
168
+ ease: EASE.houseGsap,
169
+ opacity: (i) => (lit ? (lit.has(nodes[i].id) ? 1 : 0) : nodes[i].id === act ? 1 : restLabel(nodes[i])),
170
+ overwrite: 'auto',
168
171
  })
169
- .on('mouseleave', () => {
170
- setHoveredNode(null)
171
- link
172
- .attr('opacity', d => {
173
- if (!activeTag) return 0.6
174
- return (d.source.id === activeTag || d.target.id === activeTag) ? 1 : 0.2
175
- })
176
- .attr('stroke', d => {
177
- if (activeTag && (d.source.id === activeTag || d.target.id === activeTag)) {
178
- return 'var(--kol-accent-primary)'
179
- }
180
- return 'color-mix(in srgb, var(--kol-surface-on-primary) 20%, transparent)'
181
- })
172
+ gsap.to(link.nodes(), {
173
+ duration: t(DURATION.slow),
174
+ ease: EASE.houseGsap,
175
+ opacity: (i) => {
176
+ const l = links[i]
177
+ const hit = (id) => l.source.id === id || l.target.id === id
178
+ if (focusId) return hit(focusId) ? 1 : 0.06
179
+ if (act) return hit(act) ? 1 : 0.15
180
+ return 0.35
181
+ },
182
+ overwrite: 'auto',
182
183
  })
184
+ /* the stroke is SET, not tweened — two color-mix()/var() strings do not interpolate */
185
+ link.attr('stroke', (l) => {
186
+ const id = focusId ?? act
187
+ return id && (l.source.id === id || l.target.id === id) ? ACCENT : EDGE
188
+ })
189
+ circle.attr('stroke', (d) => (d.id === act ? ACCENT : 'var(--kol-surface-primary)'))
190
+ }
183
191
 
184
- // Simulation tick
185
- simulation.on('tick', () => {
186
- link
187
- .attr('x1', d => d.source.x)
188
- .attr('y1', d => d.source.y)
189
- .attr('x2', d => d.target.x)
190
- .attr('y2', d => d.target.y)
191
-
192
- node.attr('transform', d => `translate(${d.x}, ${d.y})`)
192
+ /* ── the entrance: edges fade, nodes grow from the centre outward, the view eases to fit ── */
193
+ const dist = (d) => Math.hypot(d.x - cx, d.y - cy)
194
+ const maxDist = d3.max(nodes, dist) || 1
195
+ gsap.to(link.nodes(), { duration: t(DURATION.zoom), ease: EASE.houseGsap, opacity: 0.35 })
196
+ gsap.to(circle.nodes(), {
197
+ duration: t(DURATION.spring),
198
+ ease: 'back.out(1.6)',
199
+ attr: { r: (i) => radiusOf(nodes[i]) },
200
+ opacity: 0.95,
201
+ delay: (i) => (reduced ? 0 : (dist(nodes[i]) / maxDist) * 0.35),
202
+ onComplete: () => paint(null),
193
203
  })
204
+ gsap.to(label.nodes(), { duration: t(DURATION.zoom), delay: t(300), ease: EASE.houseGsap, opacity: (i) => restLabel(nodes[i]) })
194
205
 
195
- // Drag functions
196
- function dragstarted(event, d) {
197
- if (!event.active) simulation.alphaTarget(0.3).restart()
198
- d.fx = d.x
199
- d.fy = d.y
200
- }
206
+ const [x0, x1] = d3.extent(nodes, (d) => d.x)
207
+ const [y0, y1] = d3.extent(nodes, (d) => d.y)
208
+ const pad = 36
209
+ const k = Math.min(3, 0.95 * Math.min(width / (x1 - x0 + pad * 2 || 1), height / (y1 - y0 + pad * 2 || 1)))
210
+ const fit = d3.zoomIdentity.translate(width / 2, height / 2).scale(k).translate(-(x0 + x1) / 2, -(y0 + y1) / 2)
211
+ svg.transition().duration(reduced ? 0 : DURATION.zoom).ease(d3.easeCubicInOut).call(zoom.transform, fit)
201
212
 
202
- function dragged(event, d) {
203
- d.fx = event.x
204
- d.fy = event.y
205
- }
206
-
207
- function dragended(event, d) {
208
- if (!event.active) simulation.alphaTarget(0)
209
- d.fx = null
210
- d.fy = null
211
- }
213
+ /* ── interaction ── */
214
+ node
215
+ .on('click', (event, d) => {
216
+ event.stopPropagation()
217
+ if (onTagClick) onTagClick(d.id)
218
+ else navigate(tagHref(d.id))
219
+ })
220
+ .on('mouseenter', (event, d) => { setHoveredNode(d); paint(d.id) })
221
+ .on('mouseleave', () => { setHoveredNode(null); paint(null) })
222
+ .call(d3.drag()
223
+ .on('start', (event, d) => { if (!event.active) sim.alphaTarget(0.2).restart(); d.fx = d.x; d.fy = d.y })
224
+ .on('drag', (event, d) => { d.fx = event.x; d.fy = event.y })
225
+ .on('end', (event, d) => { if (!event.active) sim.alphaTarget(0); d.fx = null; d.fy = null }))
212
226
 
213
- // Cleanup
227
+ sceneRef.current = { paint }
214
228
  return () => {
215
- simulation.stop()
229
+ sim.stop()
230
+ gsap.killTweensOf([...circle.nodes(), ...label.nodes(), ...link.nodes()])
231
+ svg.interrupt()
232
+ sceneRef.current = null
216
233
  }
217
- }, [graphData, dimensions, activeTag, navigate, onTagClick, tagHref])
234
+ }, [graphData, dimensions, navigate, onTagClick, tagHref])
235
+
236
+ /* ── an active-tag change re-paints; it never rebuilds the layout ── */
237
+ useEffect(() => { sceneRef.current?.paint(null) }, [activeTag])
218
238
 
219
239
  if (graphData.nodes.length === 0) {
220
240
  return (
@@ -226,12 +246,7 @@ const TagGraph = ({ docs, activeTag, onTagClick, allDocs, tagHref = defaultTagHr
226
246
 
227
247
  return (
228
248
  <div ref={containerRef} className="tag-graph-sidebar">
229
- <svg
230
- ref={svgRef}
231
- width={dimensions.width}
232
- height={dimensions.height}
233
- className="tag-graph-svg"
234
- />
249
+ <svg ref={svgRef} width={dimensions.width} height={dimensions.height} className="tag-graph-svg" />
235
250
  {hoveredNode && (
236
251
  <div className="tag-graph-tooltip kol-helper-10 text-emphasis">
237
252
  #{hoveredNode.id} ({hoveredNode.count} {hoveredNode.count === 1 ? 'doc' : 'docs'})