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