@kolkrabbi/kol-workshop 0.5.1 → 0.11.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.
@@ -1,6 +1,8 @@
1
1
  import { useState, useEffect } from 'react'
2
- import { NavLink, Link, useLocation } from 'react-router-dom'
3
- import { Icon, Tooltip } from '@kolkrabbi/kol-component'
2
+ import { useLocation } from 'react-router-dom'
3
+ import { Icon } from '@kolkrabbi/kol-component'
4
+ import RailSection from './RailSection.jsx'
5
+ import RailRow from './RailRow.jsx'
4
6
 
5
7
  const getSectionRootPath = (route, basePath) => {
6
8
  if (route.path !== undefined && route.path !== null) {
@@ -63,115 +65,58 @@ const ShellSidebar = ({ routes = [], basePath = '/', onNavigate, label = 'Naviga
63
65
  setCollapsedSections((prev) => ({ ...prev, [route.id]: !prev[route.id] }))
64
66
  }
65
67
 
68
+ /* No count at L1 — the eyebrow names a body of material and the tally lives
69
+ * on the groups inside it (user ruling 2026-08-01). RailSection refuses one
70
+ * at this rung, so there is nothing to compute here. */
66
71
  return (
67
72
  <div className="space-y-4">
68
- {/* kol-helper-10 types the wrapper box itself (was an untyped 16px box) */}
69
- <div className="shell-sidebar-toggle shell-sidebar-label kol-doc-eyebrow" style={{ justifyContent: 'space-between', paddingRight: '4px' }}>
70
- {labelTo ? (
71
- <Link to={labelTo} className="shell-sidebar-label kol-doc-eyebrow" onClick={(e) => {
72
- if (navCollapsed && handleToggle) handleToggle()
73
- if (onNavigate) onNavigate(e)
74
- }}>
75
- {label}
76
- </Link>
77
- ) : (
78
- /* Same voice as the labelTo branch. This read `kol-helper-10
79
- * text-meta`, so the one rail mounted without a labelTo (the vault's
80
- * "Documentation") rendered its label at a different size and colour
81
- * from its two siblings — a type fork caused purely by which props
82
- * the consumer happened to pass. */
83
- <button type="button" className="shell-sidebar-label kol-doc-eyebrow" onClick={handleToggle}>{label}</button>
84
- )}
85
- <Tooltip label={navCollapsed ? `Expand ${label}` : `Collapse ${label}`}>
86
- <button
87
- type="button"
88
- onClick={handleToggle}
89
- aria-label={navCollapsed ? `Expand ${label}` : `Collapse ${label}`}
90
- aria-expanded={!navCollapsed}
91
- className="flex items-center justify-center"
92
- style={{ height: '16.5px', marginBottom: '8px' }}
93
- >
94
- <Icon
95
- name="chevron-down"
96
- size={10}
97
- className={`stroke-[2.5] transition-transform ${navCollapsed ? '' : 'rotate-180'}`}
98
- />
99
- </button>
100
- </Tooltip>
101
- </div>
73
+ <RailSection
74
+ level={1}
75
+ label={label}
76
+ to={labelTo}
77
+ collapsed={navCollapsed}
78
+ onToggle={handleToggle}
79
+ onNavigate={onNavigate}
80
+ >
81
+ <div className="space-y-4">
82
+ {routes.map((route) => {
83
+ /* A group with no children is not a group — it is a link. It used
84
+ * to render as a header anyway: a chevron that rotated over an
85
+ * empty body, no count, and no navigation, so clicking "Icons" or
86
+ * "Components" in the tree did nothing at all while looking like
87
+ * it should. `collapsible` is what that distinction is now. */
88
+ const hasChildren = route.children?.length > 0
102
89
 
103
- {!navCollapsed && <div className="space-y-4">
104
- {routes.map((route) => {
105
- const isExpanded = !collapsedSections[route.id]
106
-
107
- /* A group with no children is not a group — it is a link. It used to
108
- * render as a header anyway: a chevron that rotated over an empty
109
- * body, no count, and no navigation, so clicking "Icons" or
110
- * "Components" in the tree did nothing at all while looking like it
111
- * should. Now the chevron and the toggle only appear when there is
112
- * something to expand, and a childless row goes where it says. */
113
- const hasChildren = route.children?.length > 0
114
- const headerClass = 'shell-nav-group-header w-full text-left kol-mono-14 text-body'
115
-
116
- return (
117
- <div key={route.id} className="shell-nav-group">
118
- {hasChildren ? (
119
- <button
120
- type="button"
121
- className={headerClass}
122
- onClick={() => handleSectionClick(route)}
123
- aria-expanded={isExpanded}
90
+ return (
91
+ <div key={route.id} className="shell-nav-group">
92
+ <RailSection
93
+ level={2}
94
+ label={route.label}
95
+ count={hasChildren ? route.children.length : undefined}
96
+ to={hasChildren ? undefined : getSectionRootPath(route, basePath)}
97
+ collapsible={hasChildren}
98
+ collapsed={!!collapsedSections[route.id]}
99
+ onToggle={() => handleSectionClick(route)}
100
+ onNavigate={onNavigate}
101
+ icon={Icon}
124
102
  >
125
- <span className="flex items-center gap-2">
126
- <Icon
127
- name="chevron-right"
128
- size={12}
129
- className={`transition-transform ${isExpanded ? 'rotate-90' : ''}`}
130
- />
131
- {route.label}
132
- </span>
133
- <span className="kol-mono-14 text-subtle">({route.children.length})</span>
134
- </button>
135
- ) : (
136
- <Link
137
- to={getSectionRootPath(route, basePath)}
138
- className={headerClass}
139
- onClick={onNavigate}
140
- >
141
- {/* aligned with the expandable rows' label column — the
142
- * chevron's width, kept as space so the two kinds of row
143
- * share a left edge */}
144
- <span className="flex items-center gap-2">
145
- <span aria-hidden="true" style={{ width: 12 }} />
146
- {route.label}
147
- </span>
148
- </Link>
149
- )}
150
-
151
- {isExpanded && route.children?.length > 0 && (
152
- <div className="shell-nav-items">
153
- {route.children.map((child) => {
154
- const childPath = getChildPath(child, basePath)
155
- return (
156
- <NavLink
103
+ <div className="shell-nav-items">
104
+ {(route.children ?? []).map((child) => (
105
+ <RailRow
157
106
  key={child.id}
158
- to={childPath}
159
- end
160
- className={({ isActive }) =>
161
- `shell-nav-item kol-mono-14 ${isActive ? 'text-emphasis' : 'text-body'}`
162
- }
163
- onClick={onNavigate}
107
+ to={getChildPath(child, basePath)}
108
+ onNavigate={onNavigate}
164
109
  >
165
- <span className="shell-nav-item-title">{child.label}</span>
166
- </NavLink>
167
- )
168
- })}
169
- </div>
170
- )}
171
- </div>
172
- )
173
- })}
174
- </div>}
110
+ {child.label}
111
+ </RailRow>
112
+ ))}
113
+ </div>
114
+ </RailSection>
115
+ </div>
116
+ )
117
+ })}
118
+ </div>
119
+ </RailSection>
175
120
  </div>
176
121
  )
177
122
  }
@@ -9,3 +9,10 @@
9
9
  export { default as ShellLayout } from './ShellLayout.jsx'
10
10
  export { ShellTocContext, ShellFullHeightContext, ShellTocCollapsedContext } from './ShellLayout.jsx'
11
11
  export { default as ShellSidebar } from './ShellSidebar.jsx'
12
+ /* RailSection — THE rail ladder. Every rail header at every rung comes from
13
+ * here, in both rails; see the file header for why a class alone was not
14
+ * enough. `pnpm validate:rails` R4 asserts it. */
15
+ export { default as RailSection } from './RailSection.jsx'
16
+ /* RailRow — THE L3 rail row. The look lives in `.shell-nav-item`; this owns the
17
+ * markup so nine hand-written utility stacks cannot come back. R4 asserts it. */
18
+ export { default as RailRow } from './RailRow.jsx'
@@ -22,14 +22,43 @@ export const TagModeProvider = ({
22
22
  docHref = defaultDocHref,
23
23
  tagHref = defaultTagHref
24
24
  }) => {
25
- const [state, setState] = useState({ isOpen: false, activeTags: [] })
25
+ /* `view` lives in the CONTEXT, not in the overlay (2026-08-01). The overlay
26
+ * held its own `viewMode` state, so the graph could only be reached by
27
+ * finding an unlabelled glyph inside the already-open overlay — a caller
28
+ * could open tag mode but not say which mode. The rail's "Graph view" row
29
+ * needs exactly that. */
30
+ const [state, setState] = useState({ isOpen: false, activeTags: [], view: 'list', text: '', expanded: false })
31
+
32
+ /* ONE QUERY (user ruling 2026-08-01: "a system that accepts both tags and
33
+ * keywords"). `text` + `activeTags` are two facets of the SAME query, not
34
+ * two searches — ShellLayout used to hold `searchQuery` in its own local
35
+ * state while tags lived here, which is the whole reason the app had two
36
+ * surfaces. `expanded` is the palette's second state: Enter commits the
37
+ * query and opens the results body, Escape collapses it. */
38
+ const openTagMode = useCallback((tag = null, { view = 'list', expanded = false } = {}) => {
39
+ setState((prev) => ({
40
+ isOpen: true,
41
+ activeTags: tag ? [tag] : prev.activeTags,
42
+ view,
43
+ text: '',
44
+ expanded: expanded || !!tag,
45
+ }))
46
+ }, [])
47
+
48
+ const setText = useCallback((text) => {
49
+ setState((prev) => ({ ...prev, isOpen: true, text }))
50
+ }, [])
51
+
52
+ const setExpanded = useCallback((expanded) => {
53
+ setState((prev) => ({ ...prev, expanded }))
54
+ }, [])
26
55
 
27
- const openTagMode = useCallback((tag = null) => {
28
- setState({ isOpen: true, activeTags: tag ? [tag] : [] })
56
+ const setView = useCallback((view) => {
57
+ setState((prev) => ({ ...prev, view }))
29
58
  }, [])
30
59
 
31
60
  const closeTagMode = useCallback(() => {
32
- setState({ isOpen: false, activeTags: [] })
61
+ setState({ isOpen: false, activeTags: [], view: 'list', text: '', expanded: false })
33
62
  }, [])
34
63
 
35
64
  const toggleTag = useCallback((tag) => {
@@ -63,7 +92,7 @@ export const TagModeProvider = ({
63
92
  // Close on any route change
64
93
  const location = useLocation()
65
94
  useEffect(() => {
66
- if (state.isOpen) setState({ isOpen: false, activeTags: [] })
95
+ if (state.isOpen) setState({ isOpen: false, activeTags: [], view: 'list', text: '', expanded: false })
67
96
  }, [location.pathname])
68
97
 
69
98
  useEffect(() => {
@@ -79,9 +108,19 @@ export const TagModeProvider = ({
79
108
  }, [state.isOpen, closeTagMode])
80
109
 
81
110
  const value = useMemo(() => ({
111
+ /* Lets a consumer tell a real provider from the inert fallback, so
112
+ * ShellLayout can own its own palette state when mounted without one
113
+ * instead of silently no-op'ing every keystroke. */
114
+ isProvided: true,
82
115
  isOpen: state.isOpen,
83
116
  activeTags: state.activeTags,
84
117
  activeTag,
118
+ view: state.view,
119
+ setView,
120
+ text: state.text,
121
+ setText,
122
+ expanded: state.expanded,
123
+ setExpanded,
85
124
  openTagMode,
86
125
  closeTagMode,
87
126
  toggleTag,
@@ -91,7 +130,7 @@ export const TagModeProvider = ({
91
130
  inventory,
92
131
  docHref,
93
132
  tagHref
94
- }), [state.isOpen, state.activeTags, activeTag, openTagMode, closeTagMode, toggleTag, removeTag, clearTags, setActiveTag, inventory, docHref, tagHref])
133
+ }), [state.isOpen, state.activeTags, state.view, state.text, state.expanded, setView, setText, setExpanded, activeTag, openTagMode, closeTagMode, toggleTag, removeTag, clearTags, setActiveTag, inventory, docHref, tagHref])
95
134
 
96
135
  return (
97
136
  <TagModeContext.Provider value={value}>
@@ -106,9 +145,16 @@ export const TagModeProvider = ({
106
145
  */
107
146
  const noop = () => {}
108
147
  const DEFAULT_TAG_MODE = {
148
+ isProvided: false,
109
149
  isOpen: false,
110
150
  activeTags: [],
111
151
  activeTag: null,
152
+ view: 'list',
153
+ setView: noop,
154
+ text: '',
155
+ setText: noop,
156
+ expanded: false,
157
+ setExpanded: noop,
112
158
  openTagMode: noop,
113
159
  closeTagMode: noop,
114
160
  toggleTag: noop,
@@ -1,26 +1,36 @@
1
- import { useMemo, useState } from 'react'
1
+ import { useMemo } from 'react'
2
2
  import { Link } from 'react-router-dom'
3
- import { Input, Tag, Button, Tooltip } from '@kolkrabbi/kol-component'
3
+ import { Button, Tooltip } from '@kolkrabbi/kol-component'
4
+ import TagPath from './TagPath.jsx'
4
5
  import { useTagMode } from './TagModeContext.jsx'
5
6
  import TagGraph from './TagGraph.jsx'
6
- import { extractDocNumber, cleanTitle, getTagColor } from '../engine/index.js'
7
+ import RailRow from '../shell/RailRow.jsx'
8
+ import { extractDocNumber, cleanTitle } from '../engine/index.js'
7
9
  import { buildTagCounts } from '../engine/tags.js'
8
10
 
11
+ /**
12
+ * TagModeOverlay - the tag BROWSER: the list and the graph. It does not search.
13
+ *
14
+ * ONE SEARCH (user ruling 2026-08-01: "search SHOULD be ONE system not 2").
15
+ * This component owned a second one - a raw `Input` plus
16
+ * `tag.toLowerCase().includes(q)` in a useMemo - over a corpus the shell's own
17
+ * index already covered. Two matchers, two behaviours, and this one never
18
+ * called the engine's `matchSearchItems` at all. Deleted rather than aligned:
19
+ * tags are rows in the shell search index now, so the one modal finds them
20
+ * like it finds everything else.
21
+ *
22
+ * `view` comes from the CONTEXT, not local state - the rail's "Graph view" row
23
+ * has to say which mode it wants before this component exists.
24
+ */
9
25
  const TagModeOverlay = () => {
10
- const { activeTags, activeTag, toggleTag, removeTag, clearTags, closeTagMode, inventory, docHref, tagHref } = useTagMode()
11
- const [viewMode, setViewMode] = useState('list')
12
- const [search, setSearch] = useState('')
26
+ const { activeTags, activeTag, toggleTag, clearTags, closeTagMode, inventory, docHref, tagHref, view, setView } = useTagMode()
13
27
 
14
28
  const allTagsWithCount = useMemo(() => buildTagCounts(inventory), [inventory])
15
29
 
16
- const visibleTags = useMemo(() => {
17
- let tags = allTagsWithCount.filter(({ tag }) => !activeTags.includes(tag))
18
- if (search.trim()) {
19
- const q = search.toLowerCase()
20
- tags = tags.filter(({ tag }) => tag.toLowerCase().includes(q))
21
- }
22
- return tags
23
- }, [allTagsWithCount, search, activeTags])
30
+ const visibleTags = useMemo(
31
+ () => allTagsWithCount.filter(({ tag }) => !activeTags.includes(tag)),
32
+ [allTagsWithCount, activeTags]
33
+ )
24
34
 
25
35
  const filteredDocs = useMemo(() => {
26
36
  if (activeTags.length === 0) return []
@@ -33,102 +43,49 @@ const TagModeOverlay = () => {
33
43
  const hasFilters = activeTags.length > 0
34
44
 
35
45
  return (
36
- <article className="docs-article">
37
- {/* was max-w-[864px] — an improvised value in the ~900–1200 band the
38
- * panel token exists to settle (kol-theme "Content widths"). */}
39
- <div className="mx-auto max-w-[var(--kol-content-panel)]">
40
- <div className="flex items-center justify-start gap-1 mb-3">
41
- {hasFilters && (
42
- <Tooltip label={viewMode === 'graph' ? 'List view' : 'Graph view'}>
43
- <Button
44
- variant="outline"
45
- quiet
46
- size="sm"
47
- iconOnly={viewMode === 'graph' ? 'view-list' : 'polygon'}
48
- onClick={() => setViewMode(viewMode === 'graph' ? 'list' : 'graph')}
49
- aria-label={viewMode === 'graph' ? 'List view' : 'Graph view'}
50
- />
51
- </Tooltip>
52
- )}
53
- <Tooltip label="Close tag mode">
54
- <Button
55
- variant="outline"
56
- quiet
57
- size="sm"
58
- iconOnly="x"
59
- onClick={closeTagMode}
60
- aria-label="Close tag mode"
61
- />
62
- </Tooltip>
63
- </div>
64
-
65
- <div className="dash-card flex flex-col gap-8 -mt-4">
66
- <div className="flex items-center gap-2" style={{ width: '100%', alignSelf: 'stretch' }}>
67
- <Input
68
- type="text"
69
- aria-label="Search tags"
70
- placeholder="Search tags…"
71
- value={search}
72
- onChange={(e) => setSearch(e.target.value)}
73
- onKeyDown={(e) => {
74
- if (e.key === 'Enter' && visibleTags.length > 0) {
75
- toggleTag(visibleTags[0].tag)
76
- setSearch('')
77
- }
78
- }}
79
- size="md"
80
- iconLeft="search"
81
- className="w-full"
82
- autoFocus
83
- />
84
- {search && (
85
- <Tooltip label="Clear search">
86
- <Button
87
- variant="outline"
88
- quiet
89
- size="sm"
90
- iconOnly="x"
91
- onClick={() => setSearch('')}
92
- aria-label="Clear search"
93
- />
94
- </Tooltip>
95
- )}
96
- </div>
97
-
98
- <div className="flex flex-col gap-4 px-10">
46
+ /* NOT AN OVERLAY ANY MORE (user ruling 2026-08-01). This is the EXPANDED
47
+ * BODY of ShellSearchOverlay, rendered inside its panel — so it owns no
48
+ * scrim, no panel chrome and no close button. It was a sibling overlay
49
+ * with its own everything, which is what "two systems" meant. */
50
+ /* PLAIN BODY (user 2026-08-01). `.docs-article` is the PROSE wrapper — it
51
+ * centred every row and imposed a reading measure on a filter list, which
52
+ * is why "everything is center aligned". A results body is chrome, not
53
+ * prose. The node/graph button is gone too: the rail's Quick actions owns
54
+ * that entry, and a bare unlabelled hex floating at the corner was "not
55
+ * the place for it". */
56
+ <div className="flex flex-col gap-4 py-4">
57
+ <div className="flex flex-col gap-4">
58
+ {/* THE SEARCH BOX IS GONE (user ruling 2026-08-01). It was a raw
59
+ * `Input` driving `tag.toLowerCase().includes(q)` - a second search
60
+ * system over a corpus the shell index already holds. Tags are rows
61
+ * in that index now, so the ONE modal searches them. This overlay
62
+ * browses: the full list, and the graph. */}
63
+ <div className="flex flex-col gap-4">
99
64
  {hasFilters && (
100
65
  <>
101
66
  <div className="flex items-center justify-between">
102
- <Button
103
- variant="outline"
104
- quiet
105
- size="sm"
106
- onClick={clearTags}
107
- >
108
- clear filters
67
+ {/* PRIMARY (user 2026-08-01). `outline quiet` is the
68
+ * recessive rung — this is the one action in the body. */}
69
+ <Button variant="primary" size="sm" onClick={clearTags}>
70
+ Clear filters
109
71
  </Button>
110
- <div className="flex flex-wrap items-center gap-1.5">
111
- {activeTags.map((tag) => (
112
- <Tag
113
- key={tag}
114
- variant="solid"
115
- color={getTagColor(tag)}
116
- size="md"
117
- onRemove={() => removeTag(tag)}
118
- >
119
- {tag}
120
- </Tag>
121
- ))}
122
- </div>
72
+ {/* NO CHIPS HERE (2026-08-01). The palette renders the
73
+ * active tags in its input row — this block repeated them
74
+ * inside the body, so one filter showed as two chips. The
75
+ * query's facets belong to the query's input. */}
123
76
  </div>
124
77
  <div className="border-t border-fg-08 my-4" />
125
78
  </>
126
79
  )}
127
80
 
128
- {hasFilters && viewMode === 'graph' ? (
81
+ {/* `hasFilters` dropped from this condition too — with no tag
82
+ * active the graph renders the WHOLE map, which is the view worth
83
+ * opening cold. Gated, the button could be clicked and nothing
84
+ * changed. */}
85
+ {view === 'graph' ? (
129
86
  <div>
130
87
  <TagGraph
131
- docs={filteredDocs}
88
+ docs={hasFilters ? filteredDocs : inventory}
132
89
  allDocs={inventory}
133
90
  activeTag={activeTag}
134
91
  onTagClick={(tag) => toggleTag(tag)}
@@ -138,24 +95,28 @@ const TagModeOverlay = () => {
138
95
  ) : (
139
96
  <>
140
97
  <div className="flex flex-col">
98
+ {/* RAIL ROWS (user 2026-08-01: "reference the sidebars,
99
+ * dont introduce a different text treatment here"). These
100
+ * wore `.tag-list-item` / `.tag-list-count`, which have NO
101
+ * CSS RULE ANYWHERE — that is the whole reason they centred
102
+ * and carried no type. RailRow already is this row. */}
141
103
  {visibleTags.map(({ tag, count }) => (
142
- <button
104
+ <RailRow
143
105
  key={tag}
144
- type="button"
145
- className={`tag-list-item${activeTags.includes(tag) ? ' active' : ''}`}
146
106
  onClick={() => toggleTag(tag)}
107
+ active={activeTags.includes(tag)}
108
+ trailing={count}
147
109
  >
148
- <span>{tag}</span>
149
- <span className="tag-list-count">{count}</span>
150
- </button>
110
+ <TagPath tag={tag} />
111
+ </RailRow>
151
112
  ))}
152
113
  {visibleTags.length === 0 && (
153
- <p className="text-fg-48 kol-mono-12 py-4">No tags matching "{search}"</p>
114
+ <p className="text-fg-48 kol-mono-12 py-4">No tags</p>
154
115
  )}
155
116
  </div>
156
117
 
157
118
  {hasFilters && filteredDocs.length > 0 && (
158
- <div className="flex flex-col pt-4 border-t border-fg-08">
119
+ <div className="shell-nav-items pt-4 border-t border-fg-08">
159
120
  {filteredDocs.map((d) => (
160
121
  /* `d.href` wins over `docHref(d.id)` so ONE tag system can
161
122
  * span content types that live in different URL spaces —
@@ -163,24 +124,22 @@ const TagModeOverlay = () => {
163
124
  * at /components/:slug. Without it the inventory could
164
125
  * only ever hold one kind of thing, which is why the
165
126
  * graph saw the vault's 46 docs and nothing else. */
166
- <Link
127
+ <RailRow
167
128
  key={d.id}
168
129
  to={d.href ?? docHref(d.id)}
169
- className="tag-list-item"
170
- onClick={closeTagMode}
130
+ trailing={extractDocNumber(d.id)}
131
+ onNavigate={closeTagMode}
171
132
  >
172
- <span>{cleanTitle(d.title, d.id)}</span>
173
- <span className="tag-list-count">{extractDocNumber(d.id)}</span>
174
- </Link>
133
+ {cleanTitle(d.title, d.id)}
134
+ </RailRow>
175
135
  ))}
176
136
  </div>
177
137
  )}
178
138
  </>
179
139
  )}
180
140
  </div>
181
- </div>
182
141
  </div>
183
- </article>
142
+ </div>
184
143
  )
185
144
  }
186
145
 
@@ -0,0 +1,24 @@
1
+ /**
2
+ * TagPath — a tag is a PATH, not a string.
3
+ *
4
+ * User ruling 2026-08-01: "nested tags… its a feature not a shape."
5
+ * `domain/components/atoms` printed flat, so the namespace read as noise
6
+ * inside the name. The namespace dims and the LEAF carries — the same
7
+ * hierarchy the taxonomy already declares (ten top-level namespaces,
8
+ * `engine/doc-helpers.js` NAMESPACE_COLORS).
9
+ *
10
+ * Extracted out of TagModeOverlay 2026-08-01 when the frontmatter panel
11
+ * became the second surface that needed it — every place a tag renders reads
12
+ * from here rather than growing a second spelling.
13
+ */
14
+ export default function TagPath({ tag }) {
15
+ const parts = String(tag).replace(/^#/, '').split('/')
16
+ const leaf = parts.pop()
17
+ if (!parts.length) return leaf
18
+ return (
19
+ <>
20
+ <span className="text-fg-32">{parts.join('/')}/</span>
21
+ {leaf}
22
+ </>
23
+ )
24
+ }
package/src/tags/index.js CHANGED
@@ -9,3 +9,4 @@ export { TagModeProvider, useTagMode } from './TagModeContext.jsx'
9
9
  export { default as TagModeGate } from './TagModeGate.jsx'
10
10
  export { default as TagModeOverlay } from './TagModeOverlay.jsx'
11
11
  export { default as TagGraph } from './TagGraph.jsx'
12
+ export { default as TagPath } from './TagPath.jsx'