@shortlink-org/portolan 0.2.2 → 0.2.4
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/README.md +7 -2
- package/cli/portolan.mjs +36 -6
- package/package.json +4 -4
- package/plugins/README.md +5 -0
- package/plugins/extract-python-kafka/README.md +6 -0
- package/plugins/extract-python-kafka/extract.py +2 -2
- package/plugins/extract-python-kafka/extract_test.py +17 -1
- package/plugins/portolan-go.wasm +0 -0
- package/plugins/pyplugin/catalog.py +12 -1
- package/plugins/pyplugin/kafka.py +74 -3
- package/scripts/builtin-plugins.mjs +3 -2
- package/scripts/catalog-sources.mjs +2 -1
- package/scripts/catalog-sources.test.mjs +11 -1
- package/scripts/delivery-presets.mjs +207 -24
- package/scripts/diff.mjs +5 -1
- package/scripts/gen-likec4.mjs +78 -20
- package/scripts/gen-likec4.test.mjs +25 -2
- package/scripts/local-api.mjs +26 -377
- package/scripts/local-api.test.mjs +50 -0
- package/scripts/local-discovery.mjs +378 -0
- package/scripts/manifest.mjs +42 -1
- package/scripts/manifest.test.mjs +24 -1
- package/scripts/run-builtin.mjs +4 -2
- package/scripts/schema.mjs +4 -0
- package/scripts/site-docs.mjs +2 -2
- package/src/app/Breadcrumbs.test.ts +4 -0
- package/src/app/Breadcrumbs.tsx +1 -0
- package/src/app/CatalogApp.tsx +4 -4
- package/src/app/Sidebar.tsx +13 -730
- package/src/app/SidebarFlowSections.tsx +251 -0
- package/src/app/SidebarFooter.tsx +160 -0
- package/src/app/SidebarTree.tsx +322 -0
- package/src/catalog-index.ts +485 -0
- package/src/catalog-model.ts +1360 -0
- package/src/catalog-stores.test.ts +17 -0
- package/src/catalog-validation.ts +1611 -0
- package/src/catalog.test.ts +29 -2
- package/src/catalog.ts +6 -3306
- package/src/components/ChannelRows.messagepack.test.tsx +28 -0
- package/src/components/ChannelRows.test.tsx +54 -0
- package/src/components/ChannelRows.tsx +57 -10
- package/src/components/LifecycleDiagram.tsx +28 -12
- package/src/components/{PageHeader.test.ts → PageHeader.test.tsx} +9 -10
- package/src/components/ProblemRow.tsx +7 -0
- package/src/components/SourcePreview.tsx +1 -1
- package/src/components/WhatLinksHere.tsx +6 -4
- package/src/enrich.test.ts +4 -5
- package/src/flow/StepDetail.tsx +98 -54
- package/src/flow/answers.test.ts +18 -1
- package/src/flow/answers.ts +37 -8
- package/src/index.css +0 -17
- package/src/landing/LandingPage.tsx +4 -4
- package/src/lib/all-problems.ts +1 -1
- package/src/lib/backlinks.test.ts +16 -1
- package/src/lib/backlinks.ts +20 -0
- package/src/lib/catalog-diff.test.ts +18 -0
- package/src/lib/catalog-diff.ts +19 -1
- package/src/lib/derive.ts +2 -0
- package/src/lib/kafka-ui.test.ts +87 -0
- package/src/lib/kafka-ui.ts +105 -0
- package/src/lib/local-api.ts +19 -7
- package/src/lib/motion.test.ts +4 -2
- package/src/lib/motion.tsx +5 -4
- package/src/lib/proto-problems.test.ts +170 -3
- package/src/lib/proto-problems.ts +176 -4
- package/src/lib/wire-problems.test.ts +21 -0
- package/src/lib/wire-problems.ts +62 -1
- package/src/likec4/FlowView.tsx +2 -6
- package/src/likec4/flow-edges.test.ts +64 -1
- package/src/likec4/flow-edges.ts +43 -7
- package/src/likec4/view-index.ts +8 -2
- package/src/merge.test.ts +69 -0
- package/src/merge.ts +52 -2
- package/src/pages/CatalogFailure.tsx +2 -2
- package/src/pages/Settings.tsx +12 -129
- package/src/pages/settings/AboutSettings.tsx +129 -0
- package/src/pages/settings/DeliverySettings.tsx +71 -15
- package/src/pages/settings/IntegrationsSettings.tsx +117 -0
- package/src/routes.test.ts +2 -0
- package/src/routes.ts +2 -1
- package/src/selection/DetailPanel.tsx +46 -1
- package/src/selection/pages.test.ts +14 -1
- package/src/selection/pages.ts +9 -3
- package/vite.config.ts +3 -1
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { useMemo, useRef, useState } from "react";
|
|
2
|
+
import { GripVertical, PinOff, TriangleAlert } from "lucide-react";
|
|
3
|
+
import { NavLink } from "react-router";
|
|
4
|
+
|
|
5
|
+
import { KindIcon } from "../components/kind";
|
|
6
|
+
import { contextName, ctxStyle } from "../lib/context-color";
|
|
7
|
+
import {
|
|
8
|
+
FLOW_HEALTH_NOTE,
|
|
9
|
+
reachDots,
|
|
10
|
+
visibleEntries,
|
|
11
|
+
} from "../lib/flow-tree";
|
|
12
|
+
import type { FlowEntry, FlowHealth, FlowGroup } from "../lib/flow-tree";
|
|
13
|
+
import { Unfold } from "../lib/motion";
|
|
14
|
+
import { paths } from "../routes";
|
|
15
|
+
import { resolvePin, usePinsStore } from "./pins";
|
|
16
|
+
import { Chevron, Leaf, Section, indent } from "./SidebarTree";
|
|
17
|
+
|
|
18
|
+
const HEALTH_COLOR: Record<FlowHealth, string> = {
|
|
19
|
+
unresolved: "var(--status-unresolved)",
|
|
20
|
+
declared: "var(--fg-faint)",
|
|
21
|
+
verified: "var(--status-verified)",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* One flow. Its name is what the row is for; the two marks at the end answer
|
|
26
|
+
* the two questions a reader has before opening it - how far does it travel,
|
|
27
|
+
* and can I believe it.
|
|
28
|
+
*/
|
|
29
|
+
function FlowRow({ entry }: { entry: FlowEntry }) {
|
|
30
|
+
const { flow, health, reach } = entry;
|
|
31
|
+
const { dots, more } = reachDots(reach);
|
|
32
|
+
const crosses =
|
|
33
|
+
reach.length === 0
|
|
34
|
+
? "stays inside its own context"
|
|
35
|
+
: `crosses into ${reach.join(", ")}`;
|
|
36
|
+
return (
|
|
37
|
+
<Leaf
|
|
38
|
+
to={paths.flow(flow.slug)}
|
|
39
|
+
depth={1}
|
|
40
|
+
title={`${flow.slug} — ${crosses}`}
|
|
41
|
+
>
|
|
42
|
+
<KindIcon kind="flow" />
|
|
43
|
+
<span className="truncate">{flow.name}</span>
|
|
44
|
+
<span className="ml-auto flex shrink-0 items-center gap-2 pl-2">
|
|
45
|
+
<span className="flex items-center gap-0.5" aria-hidden>
|
|
46
|
+
{dots.map((c) => (
|
|
47
|
+
<span key={c} className="sb-reach" style={ctxStyle(c)} />
|
|
48
|
+
))}
|
|
49
|
+
{more > 0 ? (
|
|
50
|
+
<span className="mono" style={{ color: "var(--fg-faint)" }}>
|
|
51
|
+
+{more}
|
|
52
|
+
</span>
|
|
53
|
+
) : null}
|
|
54
|
+
</span>
|
|
55
|
+
<span
|
|
56
|
+
className="sb-health"
|
|
57
|
+
title={FLOW_HEALTH_NOTE[health]}
|
|
58
|
+
style={{ background: HEALTH_COLOR[health] }}
|
|
59
|
+
/>
|
|
60
|
+
</span>
|
|
61
|
+
</Leaf>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The flows one context owns. Grouping them by owner rather than listing them
|
|
66
|
+
* flat is what makes the section answer "whose flows are these" - and the
|
|
67
|
+
* unowned group is a defect report: with a valid catalog it is never drawn.
|
|
68
|
+
*/
|
|
69
|
+
export function FlowGroupNode({
|
|
70
|
+
group,
|
|
71
|
+
open,
|
|
72
|
+
onToggle,
|
|
73
|
+
}: {
|
|
74
|
+
group: FlowGroup;
|
|
75
|
+
open: boolean;
|
|
76
|
+
onToggle: () => void;
|
|
77
|
+
}) {
|
|
78
|
+
const { shown, hidden } = visibleEntries(group.entries);
|
|
79
|
+
const owner = group.owner;
|
|
80
|
+
const label = owner === null ? "unowned" : contextName(owner);
|
|
81
|
+
return (
|
|
82
|
+
<div>
|
|
83
|
+
<button
|
|
84
|
+
type="button"
|
|
85
|
+
onClick={onToggle}
|
|
86
|
+
aria-expanded={open}
|
|
87
|
+
style={{ paddingLeft: indent(0) }}
|
|
88
|
+
title={
|
|
89
|
+
owner === null
|
|
90
|
+
? "these flows name no owner — a valid catalog has none of these"
|
|
91
|
+
: `flows owned by ${owner}`
|
|
92
|
+
}
|
|
93
|
+
className="tree-row flex w-full items-center gap-1.5 py-[3px] pr-2 text-left t-micro transition-colors hover:bg-surface"
|
|
94
|
+
>
|
|
95
|
+
<Chevron open={open} />
|
|
96
|
+
{owner === null ? (
|
|
97
|
+
<TriangleAlert
|
|
98
|
+
size={13}
|
|
99
|
+
aria-hidden
|
|
100
|
+
className="block shrink-0 text-unresolved"
|
|
101
|
+
/>
|
|
102
|
+
) : (
|
|
103
|
+
<KindIcon kind="context" contextId={owner} />
|
|
104
|
+
)}
|
|
105
|
+
<span
|
|
106
|
+
className="truncate"
|
|
107
|
+
style={owner === null ? { color: "var(--status-unresolved)" } : {}}
|
|
108
|
+
>
|
|
109
|
+
{label}
|
|
110
|
+
</span>
|
|
111
|
+
<span className="sb-count">{group.entries.length}</span>
|
|
112
|
+
</button>
|
|
113
|
+
<Unfold open={open}>
|
|
114
|
+
<>
|
|
115
|
+
{shown.map((entry) => (
|
|
116
|
+
<FlowRow key={entry.flow.slug} entry={entry} />
|
|
117
|
+
))}
|
|
118
|
+
{hidden > 0 && owner !== null ? (
|
|
119
|
+
<NavLink
|
|
120
|
+
to={`${paths.flows()}?owner=${encodeURIComponent(owner)}`}
|
|
121
|
+
data-nav-item
|
|
122
|
+
style={{ paddingLeft: indent(1) }}
|
|
123
|
+
className="tree-row mono flex items-center py-[3px] pr-2 text-accent hover:bg-surface"
|
|
124
|
+
>
|
|
125
|
+
view all {group.entries.length} →
|
|
126
|
+
</NavLink>
|
|
127
|
+
) : null}
|
|
128
|
+
</>
|
|
129
|
+
</Unfold>
|
|
130
|
+
</div>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// Pins
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The reader's own shortlist, above everything the catalog decided. It only
|
|
140
|
+
* exists while there is something on it: an empty band with a header would
|
|
141
|
+
* teach the feature by taking up room, which is the one thing the pane cannot
|
|
142
|
+
* spare.
|
|
143
|
+
*/
|
|
144
|
+
export function PinnedSection({
|
|
145
|
+
open,
|
|
146
|
+
onToggle,
|
|
147
|
+
}: {
|
|
148
|
+
open: boolean;
|
|
149
|
+
onToggle: () => void;
|
|
150
|
+
}) {
|
|
151
|
+
const pins = usePinsStore((s) => s.pins);
|
|
152
|
+
const toggle = usePinsStore((s) => s.toggle);
|
|
153
|
+
const reorder = usePinsStore((s) => s.reorder);
|
|
154
|
+
// Which row is under the cursor is a ref, not state: a drop fires in the same
|
|
155
|
+
// gesture that started the drag, and a state write scheduled by `dragstart`
|
|
156
|
+
// is not guaranteed to have landed by the time `drop` reads it. The state
|
|
157
|
+
// beside it only dims the row, so it is allowed to arrive late.
|
|
158
|
+
const dragFrom = useRef<number | null>(null);
|
|
159
|
+
const [dragging, setDragging] = useState<number | null>(null);
|
|
160
|
+
|
|
161
|
+
// Resolved at render, so a pin taken against a build that no longer has the
|
|
162
|
+
// event simply stops being drawn rather than pointing into nothing.
|
|
163
|
+
const rows = useMemo(
|
|
164
|
+
() =>
|
|
165
|
+
pins
|
|
166
|
+
.map((pin, at) => ({ at, resolved: resolvePin(pin) }))
|
|
167
|
+
.filter(
|
|
168
|
+
(r): r is { at: number; resolved: NonNullable<typeof r.resolved> } =>
|
|
169
|
+
r.resolved !== null,
|
|
170
|
+
),
|
|
171
|
+
[pins],
|
|
172
|
+
);
|
|
173
|
+
if (rows.length === 0) return null;
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<Section
|
|
177
|
+
title="Pinned"
|
|
178
|
+
count={rows.length}
|
|
179
|
+
open={open}
|
|
180
|
+
onToggle={onToggle}
|
|
181
|
+
first
|
|
182
|
+
>
|
|
183
|
+
{rows.map(({ at, resolved }) => (
|
|
184
|
+
<div
|
|
185
|
+
key={`${resolved.pin.kind}:${resolved.pin.id}`}
|
|
186
|
+
draggable
|
|
187
|
+
onDragStart={(e) => {
|
|
188
|
+
dragFrom.current = at;
|
|
189
|
+
e.dataTransfer.effectAllowed = "move";
|
|
190
|
+
setDragging(at);
|
|
191
|
+
}}
|
|
192
|
+
onDragEnd={() => {
|
|
193
|
+
dragFrom.current = null;
|
|
194
|
+
setDragging(null);
|
|
195
|
+
}}
|
|
196
|
+
onDragOver={(e) => {
|
|
197
|
+
e.preventDefault();
|
|
198
|
+
e.dataTransfer.dropEffect = "move";
|
|
199
|
+
}}
|
|
200
|
+
onDrop={(e) => {
|
|
201
|
+
e.preventDefault();
|
|
202
|
+
const from = dragFrom.current;
|
|
203
|
+
if (from !== null) reorder(from, at);
|
|
204
|
+
dragFrom.current = null;
|
|
205
|
+
setDragging(null);
|
|
206
|
+
}}
|
|
207
|
+
className={`group relative flex items-stretch ${
|
|
208
|
+
dragging === at ? "opacity-50" : ""
|
|
209
|
+
}`}
|
|
210
|
+
>
|
|
211
|
+
<NavLink
|
|
212
|
+
to={resolved.path}
|
|
213
|
+
end
|
|
214
|
+
draggable={false}
|
|
215
|
+
data-nav-item
|
|
216
|
+
title={resolved.title}
|
|
217
|
+
style={({ isActive }) => ({
|
|
218
|
+
paddingLeft: indent(0),
|
|
219
|
+
background: isActive ? "var(--surface-2)" : undefined,
|
|
220
|
+
borderLeftWidth: 2,
|
|
221
|
+
borderLeftStyle: "solid",
|
|
222
|
+
borderLeftColor: isActive ? "var(--accent)" : "transparent",
|
|
223
|
+
})}
|
|
224
|
+
className="tree-row flex min-w-0 flex-1 items-center gap-1.5 py-[3px] pr-2 t-micro transition-colors hover:bg-surface"
|
|
225
|
+
>
|
|
226
|
+
<GripVertical
|
|
227
|
+
size={12}
|
|
228
|
+
aria-hidden
|
|
229
|
+
className="block shrink-0 opacity-0 group-hover:opacity-100"
|
|
230
|
+
style={{ color: "var(--fg-faint)" }}
|
|
231
|
+
/>
|
|
232
|
+
<KindIcon
|
|
233
|
+
kind={resolved.kind}
|
|
234
|
+
{...(resolved.contextId ? { contextId: resolved.contextId } : {})}
|
|
235
|
+
/>
|
|
236
|
+
<span className="truncate">{resolved.name}</span>
|
|
237
|
+
</NavLink>
|
|
238
|
+
<button
|
|
239
|
+
type="button"
|
|
240
|
+
onClick={() => toggle(resolved.pin)}
|
|
241
|
+
aria-label={`Unpin ${resolved.name}`}
|
|
242
|
+
title={`Unpin ${resolved.name}`}
|
|
243
|
+
className="flex shrink-0 items-center px-2 text-muted opacity-0 t-micro transition-opacity hover:text-ink group-hover:opacity-100 focus-visible:opacity-100"
|
|
244
|
+
>
|
|
245
|
+
<PinOff size={13} aria-hidden />
|
|
246
|
+
</button>
|
|
247
|
+
</div>
|
|
248
|
+
))}
|
|
249
|
+
</Section>
|
|
250
|
+
);
|
|
251
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import { NavLink } from "react-router";
|
|
3
|
+
import {
|
|
4
|
+
Check,
|
|
5
|
+
PanelLeftOpen,
|
|
6
|
+
Settings2,
|
|
7
|
+
TriangleAlert,
|
|
8
|
+
} from "lucide-react";
|
|
9
|
+
|
|
10
|
+
import { allModules } from "../catalog";
|
|
11
|
+
import { KindIcon } from "../components/kind";
|
|
12
|
+
import { CompassRose } from "../components/logo";
|
|
13
|
+
import { catalog, index } from "../data";
|
|
14
|
+
import { allProblems } from "../lib/all-problems";
|
|
15
|
+
import type { Kind } from "../lib/kinds";
|
|
16
|
+
import { paths } from "../routes";
|
|
17
|
+
|
|
18
|
+
const indent = (depth: number) => 8 + depth * 12;
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// The bottom group. A sibling of the scroller rather than a row inside it, so
|
|
22
|
+
// "pinned to the bottom" is a fact about the layout and not about how far the
|
|
23
|
+
// tree happens to have been scrolled.
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
export function SidebarBottomGroup() {
|
|
27
|
+
// The same complete list as the Problems page, because that is what the row
|
|
28
|
+
// opens. The badge is red while anything on it is an error and amber when
|
|
29
|
+
// only the schema disagrees with itself - a page of warnings is not a clean
|
|
30
|
+
// estate, and a green tick over it would be the one lie the row can tell.
|
|
31
|
+
const found = useMemo(() => allProblems(catalog, index), []);
|
|
32
|
+
const errors = found.filter((p) => p.severity === "error").length;
|
|
33
|
+
const colour =
|
|
34
|
+
errors > 0 ? "var(--status-unresolved)" : "var(--status-declared)";
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div className="shrink-0 border-t bg-canvas border-line py-0.5">
|
|
38
|
+
<NavLink
|
|
39
|
+
to={paths.problems()}
|
|
40
|
+
end
|
|
41
|
+
data-nav-item
|
|
42
|
+
aria-keyshortcuts="g p"
|
|
43
|
+
title={
|
|
44
|
+
found.length === 0
|
|
45
|
+
? "Problems — every edge in the catalog lands somewhere"
|
|
46
|
+
: `Problems — ${found.length} to look at`
|
|
47
|
+
}
|
|
48
|
+
style={({ isActive }) => ({
|
|
49
|
+
paddingLeft: indent(0),
|
|
50
|
+
background: isActive ? "var(--surface-2)" : undefined,
|
|
51
|
+
borderLeftWidth: 2,
|
|
52
|
+
borderLeftStyle: "solid",
|
|
53
|
+
borderLeftColor: isActive ? "var(--accent)" : "transparent",
|
|
54
|
+
})}
|
|
55
|
+
className="tree-row flex items-center gap-1.5 py-[3px] pr-2 t-micro transition-colors hover:bg-surface"
|
|
56
|
+
>
|
|
57
|
+
<TriangleAlert
|
|
58
|
+
size={14}
|
|
59
|
+
aria-hidden
|
|
60
|
+
className="block shrink-0"
|
|
61
|
+
style={{ color: found.length === 0 ? "var(--fg-muted)" : colour }}
|
|
62
|
+
/>
|
|
63
|
+
<span className="truncate">Problems</span>
|
|
64
|
+
{found.length === 0 ? (
|
|
65
|
+
<Check
|
|
66
|
+
size={13}
|
|
67
|
+
aria-hidden
|
|
68
|
+
className="ml-auto block shrink-0 text-muted"
|
|
69
|
+
/>
|
|
70
|
+
) : (
|
|
71
|
+
<span
|
|
72
|
+
className="mono tnum ml-auto shrink-0"
|
|
73
|
+
style={{ color: colour }}
|
|
74
|
+
>
|
|
75
|
+
{found.length}
|
|
76
|
+
</span>
|
|
77
|
+
)}
|
|
78
|
+
</NavLink>
|
|
79
|
+
<NavLink
|
|
80
|
+
to={paths.settings()}
|
|
81
|
+
data-nav-item
|
|
82
|
+
title="Settings — projects, plugins and appearance"
|
|
83
|
+
style={({ isActive }) => ({
|
|
84
|
+
paddingLeft: indent(0),
|
|
85
|
+
background: isActive ? "var(--surface-2)" : undefined,
|
|
86
|
+
borderLeftWidth: 2,
|
|
87
|
+
borderLeftStyle: "solid",
|
|
88
|
+
borderLeftColor: isActive ? "var(--accent)" : "transparent",
|
|
89
|
+
})}
|
|
90
|
+
className="tree-row flex items-center gap-1.5 py-[3px] pr-2 text-muted t-micro transition-colors hover:bg-surface hover:text-ink"
|
|
91
|
+
>
|
|
92
|
+
<Settings2 size={14} aria-hidden className="block shrink-0" />
|
|
93
|
+
<span className="truncate">Settings</span>
|
|
94
|
+
</NavLink>
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* What is left of the tree at 48px. Not a menu: every button here does the one
|
|
101
|
+
* thing the rail can honestly offer, which is to give the tree its width back
|
|
102
|
+
* and land the reader on the section they pointed at.
|
|
103
|
+
*/
|
|
104
|
+
export function SidebarIconRail({ onExpand }: { onExpand: () => void }) {
|
|
105
|
+
const sections: { key: string; kind: Kind; label: string }[] = [
|
|
106
|
+
{ key: "flows", kind: "flow", label: "Flows" },
|
|
107
|
+
{ key: "domains", kind: "context", label: "Contexts" },
|
|
108
|
+
// Conditional for the same reason the band is: at 48px a button that opens
|
|
109
|
+
// an empty section is worse than no button.
|
|
110
|
+
...(allModules(catalog).length > 0
|
|
111
|
+
? [{ key: "registry", kind: "module" as Kind, label: "Registry" }]
|
|
112
|
+
: []),
|
|
113
|
+
{ key: "adrs", kind: "adr", label: "Decisions" },
|
|
114
|
+
];
|
|
115
|
+
return (
|
|
116
|
+
<nav
|
|
117
|
+
className="flex h-full flex-col items-center gap-1 border-r py-3 border-line bg-canvas"
|
|
118
|
+
aria-label="Catalog (collapsed)"
|
|
119
|
+
>
|
|
120
|
+
{/* The mark survives the collapse; the wordmark does not fit and is not
|
|
121
|
+
missed - at 48px the rose is the whole identity. */}
|
|
122
|
+
<CompassRose size={18} className="mb-1 text-ink" />
|
|
123
|
+
<button
|
|
124
|
+
type="button"
|
|
125
|
+
onClick={onExpand}
|
|
126
|
+
title="Expand the catalog"
|
|
127
|
+
aria-label="Expand the catalog"
|
|
128
|
+
aria-expanded={false}
|
|
129
|
+
className="flex size-8 items-center justify-center rounded-control text-muted t-micro transition-colors hover:bg-surface hover:text-ink"
|
|
130
|
+
>
|
|
131
|
+
<PanelLeftOpen size={16} aria-hidden />
|
|
132
|
+
</button>
|
|
133
|
+
<span aria-hidden className="my-1 h-px w-6 bg-line" />
|
|
134
|
+
{sections.map(({ key, kind, label }) => (
|
|
135
|
+
<button
|
|
136
|
+
key={key}
|
|
137
|
+
type="button"
|
|
138
|
+
onClick={onExpand}
|
|
139
|
+
title={`${label} — expand the catalog`}
|
|
140
|
+
aria-label={`${label} — expand the catalog`}
|
|
141
|
+
className="flex size-8 items-center justify-center rounded-control t-micro transition-colors hover:bg-surface"
|
|
142
|
+
>
|
|
143
|
+
<KindIcon kind={kind} size={16} />
|
|
144
|
+
</button>
|
|
145
|
+
))}
|
|
146
|
+
<NavLink
|
|
147
|
+
to={paths.settings()}
|
|
148
|
+
title="Settings"
|
|
149
|
+
aria-label="Settings"
|
|
150
|
+
className={({ isActive }) =>
|
|
151
|
+
`mt-auto flex size-8 items-center justify-center rounded-control t-micro transition-colors hover:bg-surface ${
|
|
152
|
+
isActive ? "bg-surface text-accent" : "text-muted hover:text-ink"
|
|
153
|
+
}`
|
|
154
|
+
}
|
|
155
|
+
>
|
|
156
|
+
<Settings2 size={16} aria-hidden />
|
|
157
|
+
</NavLink>
|
|
158
|
+
</nav>
|
|
159
|
+
);
|
|
160
|
+
}
|