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