@kolkrabbi/kol-workshop 0.34.0 → 0.36.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 -8
- package/src/docs/DocsFrontmatter.jsx +10 -4
- package/src/docs/DocumentationReader.jsx +21 -4
- package/src/exhibit/ExhibitCard.jsx +1 -1
- package/src/exhibit/ExhibitLinkCard.jsx +1 -1
- package/src/exhibit/ExhibitOverview.jsx +1 -1
- package/src/exhibit/ExhibitSidebar.jsx +1 -1
- package/src/search/ResultRow.jsx +6 -4
- package/src/search/SearchPage.jsx +103 -68
- package/src/shell/RailSection.jsx +5 -3
- package/src/shell/RightRail.jsx +43 -22
- package/src/shell/ShellLayout.jsx +54 -12
- package/src/shell/ShellSidebar.jsx +89 -57
- package/src/tags/TagGraph.jsx +180 -165
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-workshop",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.36.0",
|
|
4
4
|
"private": false,
|
|
5
|
-
"description": "
|
|
5
|
+
"description": "Docs viewer, shell and tag system",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"main": "./src/index.js",
|
|
@@ -18,19 +18,20 @@
|
|
|
18
18
|
"@kolkrabbi/kol-brand": "^0.1.3"
|
|
19
19
|
},
|
|
20
20
|
"peerDependencies": {
|
|
21
|
-
"@kolkrabbi/kol-component": ">=0.
|
|
21
|
+
"@kolkrabbi/kol-component": ">=0.235.0",
|
|
22
22
|
"@kolkrabbi/kol-framework": ">=0.45.0",
|
|
23
|
-
"@kolkrabbi/kol-theme": ">=0.
|
|
23
|
+
"@kolkrabbi/kol-theme": ">=0.162.0",
|
|
24
24
|
"@kolkrabbi/kol-icons": ">=0.18.0",
|
|
25
|
+
"gsap": "^3.13.0",
|
|
25
26
|
"react": "^18.3.0 || ^19.0.0",
|
|
26
27
|
"react-dom": "^18.3.0 || ^19.0.0",
|
|
27
28
|
"react-router-dom": "^6.0.0 || ^7.0.0"
|
|
28
29
|
},
|
|
29
30
|
"devDependencies": {
|
|
30
|
-
"@kolkrabbi/kol-
|
|
31
|
-
"@kolkrabbi/kol-
|
|
32
|
-
"@kolkrabbi/kol-
|
|
33
|
-
"@kolkrabbi/kol-
|
|
31
|
+
"@kolkrabbi/kol-component": "^0.235.0",
|
|
32
|
+
"@kolkrabbi/kol-theme": "^0.162.0",
|
|
33
|
+
"@kolkrabbi/kol-framework": "^0.48.0",
|
|
34
|
+
"@kolkrabbi/kol-icons": "^0.32.0"
|
|
34
35
|
},
|
|
35
36
|
"files": [
|
|
36
37
|
"src",
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { useLayoutEffect, useRef, useState } from 'react'
|
|
2
|
-
import { Icon, Tag } from '@kolkrabbi/kol-component'
|
|
2
|
+
import { Badge, Icon, Tag } from '@kolkrabbi/kol-component'
|
|
3
|
+
import { getTagColor } from '@kolkrabbi/kol-markdown'
|
|
3
4
|
import { useTagMode } from '../tags'
|
|
4
5
|
import TagPath from '../tags/TagPath.jsx'
|
|
5
6
|
|
|
6
7
|
/* EVERY field carries an icon (user ruling 2026-08-01). Ten of these were
|
|
7
8
|
* hard-coded `null`, so two rows had a glyph and the rest sat at a ragged left
|
|
8
9
|
* edge — a column that is sometimes iconned is worse than one that never is.
|
|
9
|
-
* Every name below is verified present in kol-icon-set-
|
|
10
|
+
* Every name below is verified present in kol-icon-set-interface. */
|
|
10
11
|
const FIELD_ICONS = {
|
|
11
12
|
file: 'file',
|
|
12
13
|
title: 'type',
|
|
@@ -25,7 +26,7 @@ const FIELD_ICONS = {
|
|
|
25
26
|
tags: 'hash-02',
|
|
26
27
|
/* 2026-08-01: these three rendered iconless while the rule above promised
|
|
27
28
|
* every field carries one — the exact ragged edge that rule exists to stop.
|
|
28
|
-
* All verified present in kol-icon-set-
|
|
29
|
+
* All verified present in kol-icon-set-interface. */
|
|
29
30
|
id: 'hash-01',
|
|
30
31
|
reuses: 'repeat',
|
|
31
32
|
slug: 'external-link',
|
|
@@ -133,6 +134,8 @@ const HIDDEN = new Set(['related', 'aliases'])
|
|
|
133
134
|
* against the key, never with a text-transform. Every other value is content
|
|
134
135
|
* and is printed exactly as written. */
|
|
135
136
|
const CASED_VALUE_FIELDS = new Set(['type', 'status'])
|
|
137
|
+
/* a doc's lifecycle on Badge's status tones (W17): live reads success, in progress warning, gone error */
|
|
138
|
+
const STATUS_TONE = { active: 'success', canonical: 'info', draft: 'warning', archived: 'error', superseded: 'error', deprecated: 'error' }
|
|
136
139
|
|
|
137
140
|
/** Contract order first, then anything else the doc carries, alphabetically. */
|
|
138
141
|
const orderFields = (metadata) => {
|
|
@@ -210,7 +213,7 @@ const DocsFrontmatter = ({ metadata, docId }) => {
|
|
|
210
213
|
* `size` is omitted: `sm` is the default and the only size. */
|
|
211
214
|
<span className="flex flex-wrap gap-1.5">
|
|
212
215
|
{value.map((tag) => (
|
|
213
|
-
<Tag key={tag} onClick={() => openTagMode(tag)}>
|
|
216
|
+
<Tag key={tag} color={getTagColor(tag)} onClick={() => openTagMode(tag)}>
|
|
214
217
|
{/* the path renderer, not the raw string — namespace dims,
|
|
215
218
|
* leaf carries. Shared with the tag overlay so the two
|
|
216
219
|
* surfaces cannot spell a tag two ways. */}
|
|
@@ -220,6 +223,9 @@ const DocsFrontmatter = ({ metadata, docId }) => {
|
|
|
220
223
|
</span>
|
|
221
224
|
) : DATE_FIELDS.has(key) ? (
|
|
222
225
|
formatDate(String(value))
|
|
226
|
+
) : key === 'status' && STATUS_TONE[String(value).toLowerCase()] ? (
|
|
227
|
+
/* STATUS WEARS ITS TONE (W17, 2026-09-30 — user: "no active error warning nothing") */
|
|
228
|
+
<Badge variant={STATUS_TONE[String(value).toLowerCase()]} size="sm">{humanise(String(value))}</Badge>
|
|
223
229
|
) : CASED_VALUE_FIELDS.has(key) ? (
|
|
224
230
|
humanise(String(value))
|
|
225
231
|
) : Array.isArray(value) ? (
|
|
@@ -197,7 +197,22 @@ const DocumentationReader = ({
|
|
|
197
197
|
<div key={blockKey} className="max-w-[var(--kol-content-panel)]">
|
|
198
198
|
<Table
|
|
199
199
|
variant="simple"
|
|
200
|
-
|
|
200
|
+
/* THE CELL ROLE PER COLUMN (the showcase review W8, 2026-09-30 — the Icons home's third
|
|
201
|
+
* column clipped). Every cell took Table's default `kol-table-cell-text`, which never
|
|
202
|
+
* wraps, inside a wrapper whose scrollbar is hidden: a wide markdown table lost its right
|
|
203
|
+
* edge silently on every doc. A column whose cells all fit in three words and 24 characters is a TOKEN
|
|
204
|
+
* column (single line); any longer cell makes it COPY, which wraps with real leading —
|
|
205
|
+
* the two roles kol-type-roles.css already defines for exactly this split. */
|
|
206
|
+
columns={block.headers.map((header, i) => ({
|
|
207
|
+
accessor: `c${i}`,
|
|
208
|
+
header,
|
|
209
|
+
className: block.rows.some((row) => {
|
|
210
|
+
const text = String(row[i]?.content ?? '')
|
|
211
|
+
return text.split(/\s+/).filter(Boolean).length > 3 || text.length > 24
|
|
212
|
+
})
|
|
213
|
+
? 'kol-doc-table-copy'
|
|
214
|
+
: 'kol-doc-table-token',
|
|
215
|
+
}))}
|
|
201
216
|
rows={block.rows.map((row, rowIndex) => ({
|
|
202
217
|
id: rowIndex,
|
|
203
218
|
...Object.fromEntries(row.map((cell, cellIndex) => [
|
|
@@ -316,7 +331,9 @@ const DocumentationReader = ({
|
|
|
316
331
|
setTocContent(
|
|
317
332
|
<DocReaderSidebar
|
|
318
333
|
key={docId}
|
|
319
|
-
|
|
334
|
+
/* THE TITLE LEADS THE OUTLINE (2026-10-01 — user: "something is always on this page"): a
|
|
335
|
+
* chapter index with no h2 used to hand the rail an empty list */
|
|
336
|
+
toc={docTitle ? [{ id: 'page-top', label: docTitle }, ...toc] : toc}
|
|
320
337
|
allTags={allTags}
|
|
321
338
|
topTags={topTags}
|
|
322
339
|
related={related}
|
|
@@ -327,7 +344,7 @@ const DocumentationReader = ({
|
|
|
327
344
|
/>
|
|
328
345
|
)
|
|
329
346
|
return () => setTocContent(null)
|
|
330
|
-
}, [rail, setTocContent, docId, toc, allTags, topTags, related, docsIndex, components, docFilePath])
|
|
347
|
+
}, [rail, setTocContent, docId, docTitle, toc, allTags, topTags, related, docsIndex, components, docFilePath])
|
|
331
348
|
|
|
332
349
|
if (!doc) {
|
|
333
350
|
return (
|
|
@@ -350,7 +367,7 @@ const DocumentationReader = ({
|
|
|
350
367
|
<DocsArticle>
|
|
351
368
|
{showFrontmatter && <DocsFrontmatter metadata={doc.metadata} docId={docId} />}
|
|
352
369
|
{docTitle && (
|
|
353
|
-
<h1 className="kol-doc-heading">{docTitle}</h1>
|
|
370
|
+
<h1 id="page-top" className="kol-doc-heading scroll-mt-20">{docTitle}</h1>
|
|
354
371
|
)}
|
|
355
372
|
{/* Render intro blocks (excluding H1 which is docTitle) */}
|
|
356
373
|
{introBlocks.filter(b => b.type !== 'heading1').map((block, index) =>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* ExhibitCard — the specimen header inside an exhibit section: what this
|
|
2
|
+
* ExhibitCard — The header of an exhibit section. the specimen header inside an exhibit section: what this
|
|
3
3
|
* variant is called, what it does, and (optionally) its technical line and its
|
|
4
4
|
* prop signature. Recreated from kol-website's local `DesCard` (37L), which
|
|
5
5
|
* every showcase page in that repo imported by relative path.
|
|
@@ -2,7 +2,7 @@ import { Link } from 'react-router-dom'
|
|
|
2
2
|
import { Icon } from '@kolkrabbi/kol-component'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* ExhibitLinkCard — the card an exhibit's landing page uses for each child
|
|
5
|
+
* ExhibitLinkCard — A card linking to an exhibit page. the card an exhibit's landing page uses for each child
|
|
6
6
|
* page. Recreated from kol-website's `OverviewCard`, which carried a standing
|
|
7
7
|
* note on itself: *"vendored verbatim from elder @kol/ui (no DS twin as of
|
|
8
8
|
* kol-dashboards 0.2.0); lobby to the DS or fold into kol-dashboards if a
|
|
@@ -3,7 +3,7 @@ import ExhibitLinkCard from './ExhibitLinkCard.jsx'
|
|
|
3
3
|
import useExhibitToc from './useExhibitToc.js'
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* ExhibitOverview — an exhibit's landing page: what this section is, then every
|
|
6
|
+
* ExhibitOverview — An exhibit's landing page. an exhibit's landing page: what this section is, then every
|
|
7
7
|
* child page as a card.
|
|
8
8
|
*
|
|
9
9
|
* The overview contract kol-website's DashboardOverview implements by hand —
|
|
@@ -4,7 +4,7 @@ import RailSection from '../shell/RailSection.jsx'
|
|
|
4
4
|
import RailRow from '../shell/RailRow.jsx'
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* ExhibitSidebar — the rail block an exhibit page registers into the shell's
|
|
7
|
+
* ExhibitSidebar — An exhibit page's rail block. the rail block an exhibit page registers into the shell's
|
|
8
8
|
* TOC slot: on-this-page, the section's documentation links, and quick actions.
|
|
9
9
|
* Recreated from kol-website's local `WorkshopSidebarContent`.
|
|
10
10
|
*
|
package/src/search/ResultRow.jsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Link } from 'react-router-dom'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* ResultRow — one search result: a title, a meta line, an optional description, the whole row a
|
|
4
|
+
* ResultRow — One search result. one search result: a title, a meta line, an optional description, the whole row a
|
|
5
5
|
* link (2026-09-30, open questions Round 2 — the user: *"I like both, is this a component? if it
|
|
6
6
|
* is I would just have it available as variants … underline is more appealing currently"*).
|
|
7
7
|
*
|
|
@@ -17,7 +17,9 @@ import { Link } from 'react-router-dom'
|
|
|
17
17
|
* @param {'underline'|'wash'} [variant='underline']
|
|
18
18
|
*/
|
|
19
19
|
const VARIANTS = {
|
|
20
|
-
|
|
20
|
+
/* full ink at rest (W11, 2026-09-30 — user: "Results item is also so low opacity"); the ROLES carry
|
|
21
|
+
* the hierarchy — title emphasis, meta, body — not an opacity on the whole row */
|
|
22
|
+
underline: { row: 'group flex flex-col gap-1 py-3', title: 'group-hover:underline underline-offset-4' },
|
|
21
23
|
wash: { row: 'flex flex-col gap-1 px-3 py-3 hover:bg-fg-04', title: '' },
|
|
22
24
|
}
|
|
23
25
|
|
|
@@ -26,8 +28,8 @@ export default function ResultRow({ to, title, meta, description, variant = 'und
|
|
|
26
28
|
return (
|
|
27
29
|
<Link to={to} className={v.row}>
|
|
28
30
|
<span className={`kol-doc-body text-emphasis ${v.title}`.trim()}>{title}</span>
|
|
29
|
-
{meta && <span className="kol-helper-12 text-
|
|
30
|
-
{description && <span className="kol-doc-body">{description}</span>}
|
|
31
|
+
{meta && <span className="kol-helper-12 text-meta">{meta}</span>}
|
|
32
|
+
{description && <span className="kol-doc-body text-body">{description}</span>}
|
|
31
33
|
</Link>
|
|
32
34
|
)
|
|
33
35
|
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import { Fragment, useMemo } from 'react'
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { Fragment, useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
+
import { getTagColor } from '@kolkrabbi/kol-markdown'
|
|
3
|
+
import { useNavigate, useSearchParams } from 'react-router-dom'
|
|
4
|
+
import { EmptyState, Input, Tag } from '@kolkrabbi/kol-component'
|
|
4
5
|
import ResultRow from './ResultRow.jsx'
|
|
5
6
|
import { createIndex, search, parseQuery } from '@kolkrabbi/kol-search'
|
|
6
|
-
import { DocHeader, DocSection } from '../docs/DocKit.jsx'
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
* SearchPage — THE results page (2026-09-28). Enter in the shell's palette
|
|
9
|
+
* SearchPage — The search results page. THE results page (2026-09-28). Enter in the shell's palette
|
|
10
10
|
* lands here with `?q=`; so does a tag clicked anywhere (`#tag`). The WHOLE
|
|
11
11
|
* state is the query string in the URL — scope and facet picks are written back
|
|
12
12
|
* into it as `in:` · `kind:` · `cat:` · `tag:` tokens — so Back returns to the
|
|
@@ -19,8 +19,10 @@ import { DocHeader, DocSection } from '../docs/DocKit.jsx'
|
|
|
19
19
|
*
|
|
20
20
|
* @param {Array} items kol-search items: { id, title, href, kind?, space?, category?, tags?, headings?, keywords?, description?, date? }
|
|
21
21
|
* @param {Array} [spaces] the scope row, `[{ value, label }]` in header order — `value` is an item's `space`
|
|
22
|
-
* @param {Array} [facets] `[[field, label]]`
|
|
22
|
+
* @param {Array} [facets] `[[field, label]]` — the FIRST is the visible filter row, the rest fold behind `+`
|
|
23
|
+
* with the scope and the read-as line (default kind · category · tags)
|
|
23
24
|
* @param {number} [limit=60]
|
|
25
|
+
* @param {string} [resultsPath] the route that shows results, when this box can also sit on another page
|
|
24
26
|
*/
|
|
25
27
|
const DEFAULT_FACETS = [['kind', 'Kind'], ['category', 'Category'], ['tags', 'Tags']]
|
|
26
28
|
const PREFIX = { space: 'in', kind: 'kind', category: 'cat', tags: 'tag' }
|
|
@@ -45,13 +47,18 @@ const readToken = (t) => (t.kind === 'term'
|
|
|
45
47
|
? `${t.field} ${t.value}`
|
|
46
48
|
: `${t.negate ? 'not ' : ''}${t.field === 'tags' ? 'tag' : t.field}: ${t.value}${t.via === 'smart' ? ' (from the word)' : ''}`)
|
|
47
49
|
|
|
48
|
-
export default function SearchPage({ items = [], spaces = [], facets = DEFAULT_FACETS, limit = 60, rowVariant = 'underline' }) {
|
|
50
|
+
export default function SearchPage({ items = [], spaces = [], facets = DEFAULT_FACETS, limit = 60, rowVariant = 'underline', resultsPath }) {
|
|
49
51
|
const [params, setParams] = useSearchParams()
|
|
52
|
+
const navigate = useNavigate()
|
|
50
53
|
const q = params.get('q') ?? ''
|
|
51
54
|
const index = useMemo(() => createIndex(items), [items])
|
|
52
55
|
const out = useMemo(() => search(index, q, { limit }), [index, q, limit])
|
|
53
56
|
|
|
54
|
-
|
|
57
|
+
/* `resultsPath`: the query is written to THAT route — a box on a search home sends its first
|
|
58
|
+
* keystroke straight to the results page instead of filtering in place */
|
|
59
|
+
const setQ = (next) => (resultsPath
|
|
60
|
+
? navigate(next.trim() ? `${resultsPath}?q=${encodeURIComponent(next)}` : resultsPath, { replace: true })
|
|
61
|
+
: setParams(next.trim() ? { q: next } : {}, { replace: true }))
|
|
55
62
|
/* a pick adds its token; picking it again takes the token back out */
|
|
56
63
|
const toggleToken = (field, value) => {
|
|
57
64
|
const tok = token(field, value)
|
|
@@ -60,84 +67,112 @@ export default function SearchPage({ items = [], spaces = [], facets = DEFAULT_F
|
|
|
60
67
|
setQ((has ? parts.filter((p) => p !== tok) : [...parts, tok]).join(' '))
|
|
61
68
|
}
|
|
62
69
|
const removeToken = (raw) => setQ(q.split(/\s+/).filter((p) => p && p !== raw).join(' '))
|
|
70
|
+
/* THE BOX OWNS ITS TEXT (2026-10-01). The URL is still the state, but a navigation lands a beat
|
|
71
|
+
* after the keystroke, and an input controlled by the URL alone dropped the letter typed in
|
|
72
|
+
* between ("tag" arrived as "tg"). The box shows what was typed; the URL follows; a query that
|
|
73
|
+
* changes from OUTSIDE (a chip, Back, a link) is read back into the box. */
|
|
74
|
+
const [text, setText] = useState(q)
|
|
75
|
+
const pushed = useRef(q)
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (q !== pushed.current) { pushed.current = q; setText(q) }
|
|
78
|
+
}, [q])
|
|
79
|
+
const type = (next) => { pushed.current = next.trim() ? next : ''; setText(next); setQ(next) }
|
|
63
80
|
const inSpace = parseQuery(q).filters.space ?? []
|
|
64
81
|
|
|
82
|
+
/* ONE FILTER ROW (2026-10-01 — user: "why are there so many categories and options, is it relevant
|
|
83
|
+
* to the results we are seeking as a 'searcher'"; ruled: kind stays, the rest folds behind a `+`).
|
|
84
|
+
* The page was a scope row, a "read as" row, a tab strip and its chips before the first result.
|
|
85
|
+
* Now: the box, one row of kind chips, the results. Scope · read-as · category · tags are the
|
|
86
|
+
* same controls, behind the `+` — and open by themselves while one of them is narrowing the list,
|
|
87
|
+
* so an active filter is never hidden. Reverses W11's tab strip (2026-09-30). */
|
|
88
|
+
const [first, ...rest] = facets.filter(([field]) => (out.facets[field] ?? []).length)
|
|
89
|
+
const tokens = out.query.tokens
|
|
90
|
+
const narrowed = inSpace.length > 0 || tokens.some((t) => t.kind !== 'term' && t.field !== first?.[0])
|
|
91
|
+
const [more, setMore] = useState(false)
|
|
92
|
+
const open = more || narrowed
|
|
93
|
+
const chips = ([field]) => (out.facets[field] ?? []).slice(0, 24).map((f) => (
|
|
94
|
+
<Tag key={f.value} hash={field === 'tags'} color={field === 'tags' ? getTagColor(f.value) : undefined} active={f.selected} onClick={() => toggleToken(field, f.value)}>
|
|
95
|
+
{f.value}<span className="ml-1.5 text-subtle">{f.count}</span>
|
|
96
|
+
</Tag>
|
|
97
|
+
))
|
|
98
|
+
|
|
65
99
|
return (
|
|
66
|
-
<div className="flex flex-col gap-
|
|
67
|
-
|
|
68
|
-
eyebrow
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
100
|
+
<div className="flex flex-col gap-6">
|
|
101
|
+
{/* ONE LINE, AND ONLY ONCE THERE IS A QUERY (2026-10-01 — user: "section is search and the title
|
|
102
|
+
* is search, seems like redundant noise"): the eyebrow, the title and the count were three
|
|
103
|
+
* lines, and the count was printed again on the results heading. Without a query the page is
|
|
104
|
+
* its home's (the consumer renders it above) and this is just the box. */}
|
|
105
|
+
{q && <h1 className="kol-doc-section-title">{`${out.total} result${out.total === 1 ? '' : 's'} for “${q}”.`}</h1>}
|
|
72
106
|
|
|
73
|
-
<div className="flex flex-col gap-
|
|
107
|
+
<div className="flex flex-col gap-3">
|
|
108
|
+
{/* md, the ladder's default — lg towered over the page (W11) */}
|
|
74
109
|
<Input
|
|
75
|
-
value={
|
|
76
|
-
onChange={(e) =>
|
|
110
|
+
value={text}
|
|
111
|
+
onChange={(e) => type(e.target.value)}
|
|
77
112
|
placeholder="Search — try atom, tag:pattern/input, in:docs, -legacy"
|
|
78
113
|
iconLeft="search"
|
|
79
|
-
size="
|
|
114
|
+
size="md"
|
|
80
115
|
aria-label="Search query"
|
|
81
116
|
autoFocus
|
|
82
117
|
/>
|
|
83
|
-
{
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
{
|
|
87
|
-
|
|
88
|
-
{sp.label}
|
|
89
|
-
</Button>
|
|
90
|
-
))}
|
|
118
|
+
{/* every chip on the page is THE Tag, one size (W11 — they were ghost Buttons and md Pills) */}
|
|
119
|
+
{q && (first || spaces.length > 0) && (
|
|
120
|
+
<div className="flex flex-wrap items-center gap-2" role="group" aria-label={first?.[1] ?? 'Filters'}>
|
|
121
|
+
{first && chips(first)}
|
|
122
|
+
<Tag hash={false} variant="secondary" active={open} onClick={() => setMore((m) => !m)} aria-expanded={open} aria-label="More filters">{open ? '−' : '+'}</Tag>
|
|
91
123
|
</div>
|
|
92
124
|
)}
|
|
93
|
-
{
|
|
94
|
-
<div className="flex flex-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
<
|
|
99
|
-
|
|
125
|
+
{q && open && (
|
|
126
|
+
<div className="flex flex-col gap-3">
|
|
127
|
+
{spaces.length > 0 && (
|
|
128
|
+
<div className="flex flex-wrap items-center gap-2" role="group" aria-label="Scope">
|
|
129
|
+
<span className="kol-doc-eyebrow">Scope</span>
|
|
130
|
+
<Tag hash={false} active={!inSpace.length} onClick={() => setQ(q.split(/\s+/).filter((p) => p && !/^in:/.test(p)).join(' '))}>Everything</Tag>
|
|
131
|
+
{spaces.map((sp) => (
|
|
132
|
+
<Tag key={sp.value} hash={false} active={inSpace.includes(sp.value)} onClick={() => toggleToken('space', sp.value)}>{sp.label}</Tag>
|
|
133
|
+
))}
|
|
134
|
+
</div>
|
|
135
|
+
)}
|
|
136
|
+
{rest.map((facet) => (
|
|
137
|
+
<div key={facet[0]} className="flex flex-wrap items-center gap-2" role="group" aria-label={facet[1]}>
|
|
138
|
+
<span className="kol-doc-eyebrow">{facet[1]}</span>
|
|
139
|
+
{chips(facet)}
|
|
140
|
+
</div>
|
|
100
141
|
))}
|
|
142
|
+
{tokens.length > 0 && (
|
|
143
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
144
|
+
<span className="kol-doc-eyebrow">Read as</span>
|
|
145
|
+
{tokens.map((t, i) => (
|
|
146
|
+
<Tag key={`${t.raw}-${i}`} hash={false} variant="secondary" onRemove={() => removeToken(t.raw)} aria-label={`Remove ${t.raw}`}>{readToken(t)}</Tag>
|
|
147
|
+
))}
|
|
148
|
+
</div>
|
|
149
|
+
)}
|
|
101
150
|
</div>
|
|
102
151
|
)}
|
|
103
152
|
</div>
|
|
104
153
|
|
|
105
|
-
{
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
<
|
|
111
|
-
{
|
|
112
|
-
<
|
|
113
|
-
|
|
114
|
-
|
|
154
|
+
{q && (
|
|
155
|
+
<section id="results" className="flex flex-col gap-4 scroll-mt-20">
|
|
156
|
+
{out.results.length === 0 ? (
|
|
157
|
+
<EmptyState eyebrow="No results" title="Nothing matches." body="Remove a token above, or try fewer words." />
|
|
158
|
+
) : (
|
|
159
|
+
<ol className="flex flex-col border-t border-fg-08">
|
|
160
|
+
{out.results.map((r) => (
|
|
161
|
+
<li key={r.item.id} className="border-t border-fg-08 first:border-t-0">
|
|
162
|
+
<ResultRow
|
|
163
|
+
to={r.item.href}
|
|
164
|
+
variant={rowVariant}
|
|
165
|
+
title={<Highlight text={r.item.title} ranges={r.highlights.title} />}
|
|
166
|
+
meta={[r.item.kind, r.item.category, r.item.space, r.item.date].filter(Boolean).join(' · ')}
|
|
167
|
+
description={r.item.description}
|
|
168
|
+
/>
|
|
169
|
+
</li>
|
|
115
170
|
))}
|
|
116
|
-
</
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
<DocSection id="results" title={`Results (${out.total})`}>
|
|
122
|
-
{out.results.length === 0 ? (
|
|
123
|
-
<EmptyState eyebrow={q ? 'No results' : 'Search'} title={q ? 'Nothing matches.' : 'Type to search.'} body={q ? 'Remove a token above, or try fewer words.' : undefined} />
|
|
124
|
-
) : (
|
|
125
|
-
<ol className="flex flex-col">
|
|
126
|
-
{out.results.map((r) => (
|
|
127
|
-
<li key={r.item.id} className="border-t border-fg-08 first:border-t-0">
|
|
128
|
-
<ResultRow
|
|
129
|
-
to={r.item.href}
|
|
130
|
-
variant={rowVariant}
|
|
131
|
-
title={<Highlight text={r.item.title} ranges={r.highlights.title} />}
|
|
132
|
-
meta={[r.item.kind, r.item.category, r.item.space, r.item.date].filter(Boolean).join(' · ')}
|
|
133
|
-
description={r.item.description}
|
|
134
|
-
/>
|
|
135
|
-
</li>
|
|
136
|
-
))}
|
|
137
|
-
</ol>
|
|
138
|
-
)}
|
|
139
|
-
{out.total > out.results.length && <p className="kol-helper-12 text-subtle">{out.total - out.results.length} more — narrow the query.</p>}
|
|
140
|
-
</DocSection>
|
|
171
|
+
</ol>
|
|
172
|
+
)}
|
|
173
|
+
{out.total > out.results.length && <p className="kol-helper-12 text-meta">{out.total - out.results.length} more — narrow the query.</p>}
|
|
174
|
+
</section>
|
|
175
|
+
)}
|
|
141
176
|
</div>
|
|
142
177
|
)
|
|
143
178
|
}
|
|
@@ -128,6 +128,7 @@ export default function RailSection({
|
|
|
128
128
|
* one click from doing both. */
|
|
129
129
|
<Link
|
|
130
130
|
to={to}
|
|
131
|
+
className="truncate"
|
|
131
132
|
onClick={(e) => {
|
|
132
133
|
/* the label opens the page, the chevron folds (audit § Homes) */
|
|
133
134
|
e.stopPropagation()
|
|
@@ -137,19 +138,20 @@ export default function RailSection({
|
|
|
137
138
|
{label}
|
|
138
139
|
</Link>
|
|
139
140
|
) : (
|
|
140
|
-
<span>{label}</span>
|
|
141
|
+
<span className="truncate">{label}</span>
|
|
141
142
|
)
|
|
142
143
|
|
|
143
144
|
const inner = (
|
|
144
145
|
<>
|
|
145
|
-
|
|
146
|
+
{/* the label truncates like a row does (W4, 2026-09-30: long set names wrapped to two lines) */}
|
|
147
|
+
<span className="flex items-center gap-2 min-w-0">
|
|
146
148
|
{chevron}
|
|
147
149
|
{/* L2 rows without a caret keep the caret's width as space, so an
|
|
148
150
|
* expandable row and a childless one share a left edge. */}
|
|
149
151
|
{level === 2 && !chevron && <span aria-hidden="true" style={{ width: 12 }} />}
|
|
150
152
|
{labelNode}
|
|
151
153
|
</span>
|
|
152
|
-
<span className="flex items-center gap-2">
|
|
154
|
+
<span className="flex items-center gap-2 shrink-0">
|
|
153
155
|
{countNode}
|
|
154
156
|
{l1State}
|
|
155
157
|
</span>
|
package/src/shell/RightRail.jsx
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { useState } from 'react'
|
|
2
|
+
import { getTagColor } from '@kolkrabbi/kol-markdown'
|
|
2
3
|
import { useLocation } from 'react-router-dom'
|
|
3
4
|
import RailSection from './RailSection.jsx'
|
|
4
5
|
import RailRow from './RailRow.jsx'
|
|
5
6
|
|
|
7
|
+
const hueOf = (tag) => { const c = getTagColor(tag); return c === 'dark' ? undefined : `var(--kol-palette-${c})` }
|
|
8
|
+
|
|
6
9
|
/**
|
|
7
10
|
* RightRail — THE right rail. One component, every route.
|
|
8
11
|
*
|
|
@@ -81,6 +84,8 @@ export default function RightRail({
|
|
|
81
84
|
tags = [],
|
|
82
85
|
renderTag,
|
|
83
86
|
onTagClick,
|
|
87
|
+
/* where every tag in use is listed (the showcase's /search/tags) — a row in the Tags section */
|
|
88
|
+
tagsHref,
|
|
84
89
|
icon: IconComponent,
|
|
85
90
|
}) {
|
|
86
91
|
/* One collapse map, not a useState per section — a section added later must
|
|
@@ -100,10 +105,19 @@ export default function RightRail({
|
|
|
100
105
|
setPins(next)
|
|
101
106
|
writePins(next)
|
|
102
107
|
}
|
|
108
|
+
/* TAGS ARE THEIR OWN SECTION (the showcase review W15, 2026-09-30 — user: "right sidebar has this
|
|
109
|
+
* page, links, and inside links is tags? feel like maybe tags is its own thing? with tag graph and
|
|
110
|
+
* all that"). The tag graph is a TAG view, so the caller's `graph` action moves out of Quick actions
|
|
111
|
+
* and leads the Tags section; every caller already passes it under that id. */
|
|
112
|
+
const graphAction = actions.find((a) => a.id === 'graph')
|
|
103
113
|
const allActions = [
|
|
104
|
-
...actions,
|
|
114
|
+
...actions.filter((a) => a.id !== 'graph'),
|
|
105
115
|
{ id: 'pin', label: isPinned ? 'Unpin' : 'Pin', icon: IconComponent ? <IconComponent name="pushpin" size={14} /> : null, onClick: togglePin },
|
|
106
116
|
]
|
|
117
|
+
const tagViews = [
|
|
118
|
+
...(graphAction ? [graphAction] : []),
|
|
119
|
+
...(tagsHref ? [{ id: 'all-tags', label: 'All tags', to: tagsHref, icon: IconComponent ? <IconComponent name="hash-02" size={14} /> : null }] : []),
|
|
120
|
+
]
|
|
107
121
|
const toggle = (key) => setCollapsed((c) => ({ ...c, [key]: !c[key] }))
|
|
108
122
|
|
|
109
123
|
/* OWN TAGS ONLY, GROUPED BY NAMESPACE (2026-09-30, the names audit — ruled on the
|
|
@@ -153,7 +167,7 @@ export default function RightRail({
|
|
|
153
167
|
</div>
|
|
154
168
|
</RailSection>
|
|
155
169
|
|
|
156
|
-
<RailSection level={1} label="Links" count={pins.length + allActions.length +
|
|
170
|
+
<RailSection level={1} label="Links" count={pins.length + allActions.length + related.length}>
|
|
157
171
|
<div className="shell-rail-stack-inner">
|
|
158
172
|
{group('pinned', 'Pinned', pins.length,
|
|
159
173
|
pins.map((p) => (
|
|
@@ -171,26 +185,6 @@ export default function RightRail({
|
|
|
171
185
|
))
|
|
172
186
|
)}
|
|
173
187
|
|
|
174
|
-
{group('tags', 'Tags', tags.length, (
|
|
175
|
-
<>
|
|
176
|
-
{Object.entries(byNamespace).map(([ns, list]) => (
|
|
177
|
-
<div key={`ns-${ns}`}>
|
|
178
|
-
{ns && <RailRow className="shell-nav-item--muted">{ns.charAt(0).toUpperCase() + ns.slice(1)}</RailRow>}
|
|
179
|
-
{list.map(({ tag, leaf }) => (
|
|
180
|
-
<RailRow
|
|
181
|
-
key={`own-${tag}`}
|
|
182
|
-
sub={!!ns}
|
|
183
|
-
onClick={() => onTagClick?.(tag)}
|
|
184
|
-
icon={IconComponent ? <IconComponent name="hash-02" size={14} /> : null}
|
|
185
|
-
>
|
|
186
|
-
{leaf}
|
|
187
|
-
</RailRow>
|
|
188
|
-
))}
|
|
189
|
-
</div>
|
|
190
|
-
))}
|
|
191
|
-
</>
|
|
192
|
-
))}
|
|
193
|
-
|
|
194
188
|
{/* Related carries SOURCES too (user ruling 2026-08-01) — a repo URL
|
|
195
189
|
* or an external reference is another document this one names, and
|
|
196
190
|
* splitting them made two sections that answer the same question. */}
|
|
@@ -201,6 +195,33 @@ export default function RightRail({
|
|
|
201
195
|
)}
|
|
202
196
|
</div>
|
|
203
197
|
</RailSection>
|
|
198
|
+
|
|
199
|
+
{/* TAGS — the views of the tag system, then this page's tags, a group per namespace */}
|
|
200
|
+
{(tagViews.length > 0 || tags.length > 0) && (
|
|
201
|
+
<RailSection level={1} label="Tags" count={tags.length}>
|
|
202
|
+
<div className="shell-rail-stack-inner">
|
|
203
|
+
{tagViews.length > 0 && (
|
|
204
|
+
<nav className="shell-nav-items">
|
|
205
|
+
{tagViews.map((a) => (
|
|
206
|
+
<RailRow key={a.id} to={a.to} onClick={a.onClick} icon={a.icon}>{a.label}</RailRow>
|
|
207
|
+
))}
|
|
208
|
+
</nav>
|
|
209
|
+
)}
|
|
210
|
+
{Object.entries(byNamespace).map(([ns, list]) => <div key={`tags-${ns || 'flat'}`}>{group(`tags-${ns || 'flat'}`, ns ? ns.charAt(0).toUpperCase() + ns.slice(1) : 'Tags', list.length,
|
|
211
|
+
list.map(({ tag, leaf }) => (
|
|
212
|
+
<RailRow
|
|
213
|
+
key={`own-${tag}`}
|
|
214
|
+
onClick={() => onTagClick?.(tag)}
|
|
215
|
+
/* the hash wears the tag's namespace color (W17), the same hue its chip and graph node wear */
|
|
216
|
+
icon={IconComponent ? <span className="inline-flex" style={{ color: hueOf(tag) }}><IconComponent name="hash-02" size={14} /></span> : null}
|
|
217
|
+
>
|
|
218
|
+
{leaf}
|
|
219
|
+
</RailRow>
|
|
220
|
+
))
|
|
221
|
+
)}</div>)}
|
|
222
|
+
</div>
|
|
223
|
+
</RailSection>
|
|
224
|
+
)}
|
|
204
225
|
</div>
|
|
205
226
|
)
|
|
206
227
|
}
|