@kolkrabbi/kol-workshop 0.34.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 +11 -10
- package/src/docs/DocsFrontmatter.jsx +10 -4
- package/src/docs/DocumentationReader.jsx +16 -1
- package/src/exhibit/ExhibitCard.jsx +1 -1
- package/src/exhibit/ExhibitLinkCard.jsx +1 -1
- package/src/exhibit/ExhibitOverview.jsx +1 -1
- package/src/exhibit/ExhibitSidebar.jsx +1 -1
- package/src/search/ResultRow.jsx +6 -4
- package/src/search/SearchPage.jsx +32 -28
- package/src/shell/RailSection.jsx +5 -3
- package/src/shell/RightRail.jsx +43 -22
- package/src/shell/ShellLayout.jsx +17 -6
- package/src/shell/ShellSidebar.jsx +82 -56
- package/src/tags/TagGraph.jsx +180 -165
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-workshop",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.0",
|
|
4
4
|
"private": false,
|
|
5
|
-
"description": "
|
|
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-brand": "^0.1.3",
|
|
16
17
|
"@kolkrabbi/kol-markdown": "^0.1.2",
|
|
17
|
-
"@kolkrabbi/kol-search": "^0.2.0"
|
|
18
|
-
"@kolkrabbi/kol-brand": "^0.1.3"
|
|
18
|
+
"@kolkrabbi/kol-search": "^0.2.0"
|
|
19
19
|
},
|
|
20
20
|
"peerDependencies": {
|
|
21
|
-
"@kolkrabbi/kol-component": ">=0.
|
|
21
|
+
"@kolkrabbi/kol-component": ">=0.234.0",
|
|
22
22
|
"@kolkrabbi/kol-framework": ">=0.45.0",
|
|
23
|
-
"@kolkrabbi/kol-theme": ">=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-
|
|
31
|
-
"@kolkrabbi/kol-
|
|
32
|
-
"@kolkrabbi/kol-
|
|
33
|
-
"@kolkrabbi/kol-
|
|
31
|
+
"@kolkrabbi/kol-component": "^0.234.0",
|
|
32
|
+
"@kolkrabbi/kol-icons": "^0.32.0",
|
|
33
|
+
"@kolkrabbi/kol-framework": "^0.48.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-
|
|
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-
|
|
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) ? (
|
|
@@ -197,7 +197,22 @@ const DocumentationReader = ({
|
|
|
197
197
|
<div key={blockKey} className="max-w-[var(--kol-content-panel)]">
|
|
198
198
|
<Table
|
|
199
199
|
variant="simple"
|
|
200
|
-
|
|
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
|
+
}))}
|
|
201
216
|
rows={block.rows.map((row, rowIndex) => ({
|
|
202
217
|
id: rowIndex,
|
|
203
218
|
...Object.fromEntries(row.map((cell, cellIndex) => [
|
|
@@ -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/search/ResultRow.jsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Link } from 'react-router-dom'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* ResultRow — one search result: a title, a meta line, an optional description, the whole row a
|
|
4
|
+
* ResultRow — One search result. one search result: a title, a meta line, an optional description, the whole row a
|
|
5
5
|
* link (2026-09-30, open questions Round 2 — the user: *"I like both, is this a component? if it
|
|
6
6
|
* is I would just have it available as variants … underline is more appealing currently"*).
|
|
7
7
|
*
|
|
@@ -17,7 +17,9 @@ import { Link } from 'react-router-dom'
|
|
|
17
17
|
* @param {'underline'|'wash'} [variant='underline']
|
|
18
18
|
*/
|
|
19
19
|
const VARIANTS = {
|
|
20
|
-
|
|
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' },
|
|
21
23
|
wash: { row: 'flex flex-col gap-1 px-3 py-3 hover:bg-fg-04', title: '' },
|
|
22
24
|
}
|
|
23
25
|
|
|
@@ -26,8 +28,8 @@ export default function ResultRow({ to, title, meta, description, variant = 'und
|
|
|
26
28
|
return (
|
|
27
29
|
<Link to={to} className={v.row}>
|
|
28
30
|
<span className={`kol-doc-body text-emphasis ${v.title}`.trim()}>{title}</span>
|
|
29
|
-
{meta && <span className="kol-helper-12 text-
|
|
30
|
-
{description && <span className="kol-doc-body">{description}</span>}
|
|
31
|
+
{meta && <span className="kol-helper-12 text-meta">{meta}</span>}
|
|
32
|
+
{description && <span className="kol-doc-body text-body">{description}</span>}
|
|
31
33
|
</Link>
|
|
32
34
|
)
|
|
33
35
|
}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import { Fragment, useMemo } from 'react'
|
|
1
|
+
import { Fragment, useMemo, useState } from 'react'
|
|
2
|
+
import { getTagColor } from '@kolkrabbi/kol-markdown'
|
|
2
3
|
import { useSearchParams } from 'react-router-dom'
|
|
3
|
-
import {
|
|
4
|
+
import { EmptyState, Input, TabsRow, Tag } from '@kolkrabbi/kol-component'
|
|
4
5
|
import ResultRow from './ResultRow.jsx'
|
|
5
6
|
import { createIndex, search, parseQuery } from '@kolkrabbi/kol-search'
|
|
6
7
|
import { DocHeader, DocSection } from '../docs/DocKit.jsx'
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
|
-
* 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
|
|
10
11
|
* lands here with `?q=`; so does a tag clicked anywhere (`#tag`). The WHOLE
|
|
11
12
|
* state is the query string in the URL — scope and facet picks are written back
|
|
12
13
|
* into it as `in:` · `kind:` · `cat:` · `tag:` tokens — so Back returns to the
|
|
@@ -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}”.` : '
|
|
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="
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
{
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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 ? (
|
|
@@ -136,7 +140,7 @@ export default function SearchPage({ items = [], spaces = [], facets = DEFAULT_F
|
|
|
136
140
|
))}
|
|
137
141
|
</ol>
|
|
138
142
|
)}
|
|
139
|
-
{out.total > out.results.length && <p className="kol-helper-12 text-
|
|
143
|
+
{out.total > out.results.length && <p className="kol-helper-12 text-meta">{out.total - out.results.length} more — narrow the query.</p>}
|
|
140
144
|
</DocSection>
|
|
141
145
|
</div>
|
|
142
146
|
)
|
|
@@ -128,6 +128,7 @@ export default function RailSection({
|
|
|
128
128
|
* one click from doing both. */
|
|
129
129
|
<Link
|
|
130
130
|
to={to}
|
|
131
|
+
className="truncate"
|
|
131
132
|
onClick={(e) => {
|
|
132
133
|
/* the label opens the page, the chevron folds (audit § Homes) */
|
|
133
134
|
e.stopPropagation()
|
|
@@ -137,19 +138,20 @@ export default function RailSection({
|
|
|
137
138
|
{label}
|
|
138
139
|
</Link>
|
|
139
140
|
) : (
|
|
140
|
-
<span>{label}</span>
|
|
141
|
+
<span className="truncate">{label}</span>
|
|
141
142
|
)
|
|
142
143
|
|
|
143
144
|
const inner = (
|
|
144
145
|
<>
|
|
145
|
-
|
|
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">
|
|
146
148
|
{chevron}
|
|
147
149
|
{/* L2 rows without a caret keep the caret's width as space, so an
|
|
148
150
|
* expandable row and a childless one share a left edge. */}
|
|
149
151
|
{level === 2 && !chevron && <span aria-hidden="true" style={{ width: 12 }} />}
|
|
150
152
|
{labelNode}
|
|
151
153
|
</span>
|
|
152
|
-
<span className="flex items-center gap-2">
|
|
154
|
+
<span className="flex items-center gap-2 shrink-0">
|
|
153
155
|
{countNode}
|
|
154
156
|
{l1State}
|
|
155
157
|
</span>
|
package/src/shell/RightRail.jsx
CHANGED
|
@@ -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 +
|
|
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,18 +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
|
|
247
|
-
{ id: 'enter', label: 'All results
|
|
248
|
+
{ id: 'k', label: 'Search', combo: '⌘ K , /' },
|
|
249
|
+
{ id: 'enter', label: 'All results', combo: '↵' },
|
|
248
250
|
] },
|
|
249
251
|
{ section: 'Shell', items: [
|
|
250
|
-
{ id: 'sheet', label: '
|
|
252
|
+
{ id: 'sheet', label: 'Shortcuts', combo: 'S' },
|
|
251
253
|
{ id: 'settings', label: 'Settings', combo: ',' },
|
|
252
254
|
{ id: 'nav', label: 'Left rail', combo: '[' },
|
|
253
255
|
{ id: 'toc', label: 'Right rail', combo: ']' },
|
|
254
|
-
{ id: '
|
|
255
|
-
{ id: '
|
|
256
|
+
{ id: 'rails', label: 'Both rails', combo: '\\' },
|
|
257
|
+
{ id: 'fold', label: 'Fold all', combo: 'C' },
|
|
258
|
+
{ id: 'esc', label: 'Close', combo: 'Esc' },
|
|
256
259
|
] },
|
|
257
260
|
...(shortcuts.length ? [{ section: 'Page', items: shortcuts.map(({ id, label, combo }) => ({ id, label, combo })) }] : []),
|
|
258
261
|
]
|
|
@@ -288,6 +291,14 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
|
|
|
288
291
|
} else if (e.key === ']') {
|
|
289
292
|
e.preventDefault()
|
|
290
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 })
|
|
291
302
|
} else if (e.key === 'c' || e.key === 'C') {
|
|
292
303
|
e.preventDefault()
|
|
293
304
|
foldedRef.current = !foldedRef.current
|
|
@@ -437,7 +448,7 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
|
|
|
437
448
|
{ id: 'toc', label: 'Right rail', render: () => <SettingsSwitch on={!tocCollapsed} onChange={(on) => { setTocCollapsed(!on); setPref('tocHidden', !on) }} /> },
|
|
438
449
|
] },
|
|
439
450
|
{ label: 'Search', rows: [
|
|
440
|
-
{ id: 'scope', label: '
|
|
451
|
+
{ id: 'scope', label: 'This space only', render: () => <SettingsSwitch on={!!prefs.limitToSpace} onChange={(on) => setPref('limitToSpace', on)} /> },
|
|
441
452
|
] },
|
|
442
453
|
...settings,
|
|
443
454
|
{ label: 'Keys', rowGap: 1, rows: SHORTCUTS.flatMap((sec) => sec.items).map((k) => ({
|
|
@@ -41,14 +41,36 @@ const ShellSidebar = ({ routes = [], basePath = '/', onNavigate, label = 'Naviga
|
|
|
41
41
|
* group you are in" on its landing page too). Landing on a page INSIDE a chapter opens that
|
|
42
42
|
* chapter and folds the rest; a chapter's own home opens nothing. Every other chapter starts
|
|
43
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
|
-
|
|
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(/\/$/, '')
|
|
47
53
|
return normalizedPath === cp || normalizedPath.startsWith(cp + '/')
|
|
48
|
-
}
|
|
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
|
+
}
|
|
49
69
|
const followed = () => {
|
|
50
|
-
const
|
|
51
|
-
|
|
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)]))
|
|
52
74
|
}
|
|
53
75
|
const [collapsedSections, setCollapsedSections] = useState(() => followed() ?? {})
|
|
54
76
|
useEffect(() => {
|
|
@@ -61,7 +83,7 @@ const ShellSidebar = ({ routes = [], basePath = '/', onNavigate, label = 'Naviga
|
|
|
61
83
|
/* FOLD ALL (2026-09-30, the names audit: *"shortcut collapse/expand the categories"*). The
|
|
62
84
|
* shell's `C` key broadcasts one target state; every rail's chapters take it. */
|
|
63
85
|
useEffect(() => {
|
|
64
|
-
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])))
|
|
65
87
|
window.addEventListener(RAIL_FOLD_EVENT, onFold)
|
|
66
88
|
return () => window.removeEventListener(RAIL_FOLD_EVENT, onFold)
|
|
67
89
|
}, [routes])
|
|
@@ -71,8 +93,37 @@ const ShellSidebar = ({ routes = [], basePath = '/', onNavigate, label = 'Naviga
|
|
|
71
93
|
}
|
|
72
94
|
|
|
73
95
|
/* The L1 count is every leaf row under it — RailSection shows it only while
|
|
74
|
-
* the section is folded (2026-09-28). */
|
|
75
|
-
const
|
|
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
|
+
)
|
|
76
127
|
return (
|
|
77
128
|
/* One rail layout, one class (2026-08-01). This was `space-y-4` against the
|
|
78
129
|
* right rail's `space-y-6` against the outer `flex flex-col gap-6` — three
|
|
@@ -90,55 +141,30 @@ const ShellSidebar = ({ routes = [], basePath = '/', onNavigate, label = 'Naviga
|
|
|
90
141
|
icon={Icon}
|
|
91
142
|
>
|
|
92
143
|
<div className="shell-rail-stack-inner">
|
|
93
|
-
{
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
+
) : (
|
|
101
158
|
/* A CHILDLESS ENTRY IS A ROW, not a chapter (2026-09-30): drawn at L2 it read as an
|
|
102
159
|
* empty category with a blank caret slot. */
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
<
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
return (
|
|
114
|
-
<div key={route.id} className="shell-nav-group">
|
|
115
|
-
<RailSection
|
|
116
|
-
level={2}
|
|
117
|
-
label={route.label}
|
|
118
|
-
count={route.children.length}
|
|
119
|
-
/* THE HEADER ALWAYS LINKS (2026-08-02): the label opens the chapter's home,
|
|
120
|
-
* the chevron folds. */
|
|
121
|
-
to={route.path ?? getSectionRootPath(route, basePath)}
|
|
122
|
-
collapsed={isFolded(route)}
|
|
123
|
-
onToggle={() => handleSectionClick(route)}
|
|
124
|
-
onNavigate={onNavigate}
|
|
125
|
-
icon={Icon}
|
|
126
|
-
>
|
|
127
|
-
<nav className="shell-nav-items">
|
|
128
|
-
{route.children.map((child) => (
|
|
129
|
-
<RailRow
|
|
130
|
-
key={child.id}
|
|
131
|
-
to={getChildPath(child, basePath)}
|
|
132
|
-
onNavigate={onNavigate}
|
|
133
|
-
>
|
|
134
|
-
{child.label}
|
|
135
|
-
</RailRow>
|
|
136
|
-
))}
|
|
137
|
-
</nav>
|
|
138
|
-
</RailSection>
|
|
139
|
-
</div>
|
|
140
|
-
)
|
|
141
|
-
})}
|
|
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
|
+
)))}
|
|
142
168
|
</div>
|
|
143
169
|
</RailSection>
|
|
144
170
|
</div>
|
package/src/tags/TagGraph.jsx
CHANGED
|
@@ -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
|
|
35
|
-
if (containerRef.current)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
42
|
-
const
|
|
43
|
-
if (containerRef.current)
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
.
|
|
78
|
-
|
|
79
|
-
.
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
107
|
-
const
|
|
108
|
-
.
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
.
|
|
112
|
-
.
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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', '
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
.
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
.
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
.
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
d.
|
|
210
|
-
|
|
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
|
-
|
|
227
|
+
sceneRef.current = { paint }
|
|
214
228
|
return () => {
|
|
215
|
-
|
|
229
|
+
sim.stop()
|
|
230
|
+
gsap.killTweensOf([...circle.nodes(), ...label.nodes(), ...link.nodes()])
|
|
231
|
+
svg.interrupt()
|
|
232
|
+
sceneRef.current = null
|
|
216
233
|
}
|
|
217
|
-
}, [graphData, dimensions,
|
|
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'})
|