@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.
Files changed (84) hide show
  1. package/README.md +7 -2
  2. package/cli/portolan.mjs +36 -6
  3. package/package.json +4 -4
  4. package/plugins/README.md +5 -0
  5. package/plugins/extract-python-kafka/README.md +6 -0
  6. package/plugins/extract-python-kafka/extract.py +2 -2
  7. package/plugins/extract-python-kafka/extract_test.py +17 -1
  8. package/plugins/portolan-go.wasm +0 -0
  9. package/plugins/pyplugin/catalog.py +12 -1
  10. package/plugins/pyplugin/kafka.py +74 -3
  11. package/scripts/builtin-plugins.mjs +3 -2
  12. package/scripts/catalog-sources.mjs +2 -1
  13. package/scripts/catalog-sources.test.mjs +11 -1
  14. package/scripts/delivery-presets.mjs +207 -24
  15. package/scripts/diff.mjs +5 -1
  16. package/scripts/gen-likec4.mjs +78 -20
  17. package/scripts/gen-likec4.test.mjs +25 -2
  18. package/scripts/local-api.mjs +26 -377
  19. package/scripts/local-api.test.mjs +50 -0
  20. package/scripts/local-discovery.mjs +378 -0
  21. package/scripts/manifest.mjs +42 -1
  22. package/scripts/manifest.test.mjs +24 -1
  23. package/scripts/run-builtin.mjs +4 -2
  24. package/scripts/schema.mjs +4 -0
  25. package/scripts/site-docs.mjs +2 -2
  26. package/src/app/Breadcrumbs.test.ts +4 -0
  27. package/src/app/Breadcrumbs.tsx +1 -0
  28. package/src/app/CatalogApp.tsx +4 -4
  29. package/src/app/Sidebar.tsx +13 -730
  30. package/src/app/SidebarFlowSections.tsx +251 -0
  31. package/src/app/SidebarFooter.tsx +160 -0
  32. package/src/app/SidebarTree.tsx +322 -0
  33. package/src/catalog-index.ts +485 -0
  34. package/src/catalog-model.ts +1360 -0
  35. package/src/catalog-stores.test.ts +17 -0
  36. package/src/catalog-validation.ts +1611 -0
  37. package/src/catalog.test.ts +29 -2
  38. package/src/catalog.ts +6 -3306
  39. package/src/components/ChannelRows.messagepack.test.tsx +28 -0
  40. package/src/components/ChannelRows.test.tsx +54 -0
  41. package/src/components/ChannelRows.tsx +57 -10
  42. package/src/components/LifecycleDiagram.tsx +28 -12
  43. package/src/components/{PageHeader.test.ts → PageHeader.test.tsx} +9 -10
  44. package/src/components/ProblemRow.tsx +7 -0
  45. package/src/components/SourcePreview.tsx +1 -1
  46. package/src/components/WhatLinksHere.tsx +6 -4
  47. package/src/enrich.test.ts +4 -5
  48. package/src/flow/StepDetail.tsx +98 -54
  49. package/src/flow/answers.test.ts +18 -1
  50. package/src/flow/answers.ts +37 -8
  51. package/src/index.css +0 -17
  52. package/src/landing/LandingPage.tsx +4 -4
  53. package/src/lib/all-problems.ts +1 -1
  54. package/src/lib/backlinks.test.ts +16 -1
  55. package/src/lib/backlinks.ts +20 -0
  56. package/src/lib/catalog-diff.test.ts +18 -0
  57. package/src/lib/catalog-diff.ts +19 -1
  58. package/src/lib/derive.ts +2 -0
  59. package/src/lib/kafka-ui.test.ts +87 -0
  60. package/src/lib/kafka-ui.ts +105 -0
  61. package/src/lib/local-api.ts +19 -7
  62. package/src/lib/motion.test.ts +4 -2
  63. package/src/lib/motion.tsx +5 -4
  64. package/src/lib/proto-problems.test.ts +170 -3
  65. package/src/lib/proto-problems.ts +176 -4
  66. package/src/lib/wire-problems.test.ts +21 -0
  67. package/src/lib/wire-problems.ts +62 -1
  68. package/src/likec4/FlowView.tsx +2 -6
  69. package/src/likec4/flow-edges.test.ts +64 -1
  70. package/src/likec4/flow-edges.ts +43 -7
  71. package/src/likec4/view-index.ts +8 -2
  72. package/src/merge.test.ts +69 -0
  73. package/src/merge.ts +52 -2
  74. package/src/pages/CatalogFailure.tsx +2 -2
  75. package/src/pages/Settings.tsx +12 -129
  76. package/src/pages/settings/AboutSettings.tsx +129 -0
  77. package/src/pages/settings/DeliverySettings.tsx +71 -15
  78. package/src/pages/settings/IntegrationsSettings.tsx +117 -0
  79. package/src/routes.test.ts +2 -0
  80. package/src/routes.ts +2 -1
  81. package/src/selection/DetailPanel.tsx +46 -1
  82. package/src/selection/pages.test.ts +14 -1
  83. package/src/selection/pages.ts +9 -3
  84. package/vite.config.ts +3 -1
@@ -2,24 +2,9 @@ import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"
2
2
  import type { KeyboardEvent as ReactKeyboardEvent } from "react";
3
3
  import { TREE_KEYS, treeKey } from "./tree-keys";
4
4
  import type { TreeRow } from "./tree-keys";
5
- import { Link, NavLink, useLocation, useMatch } from "react-router";
6
- import {
7
- DURATION,
8
- LayoutGroup,
9
- Unfold,
10
- m,
11
- transitions,
12
- } from "../lib/motion";
13
- import {
14
- Check,
15
- ChevronRight,
16
- GripVertical,
17
- PanelLeftOpen,
18
- PinOff,
19
- Settings2,
20
- TriangleAlert,
21
- } from "lucide-react";
22
- import { catalog, index } from "../data";
5
+ import { Link, NavLink } from "react-router";
6
+ import { DURATION, LayoutGroup, Unfold } from "../lib/motion";
7
+ import { catalog } from "../data";
23
8
  import type {
24
9
  Aggregate,
25
10
  Block,
@@ -37,19 +22,10 @@ import type {
37
22
  } from "../catalog";
38
23
  import { allModules, allTerms, enumsOf, storeViews } from "../catalog";
39
24
  import { adrNumber, newestAccepted } from "../lib/adr";
40
- import { contextName, ctxStyle } from "../lib/context-color";
41
- import { contextStats, problems } from "../lib/derive";
42
- import { dataProblems } from "../lib/data-problems";
43
- import { protoProblems } from "../lib/proto-problems";
25
+ import { contextStats } from "../lib/derive";
44
26
  import { vocabularies } from "../language/cards";
45
- import {
46
- FLOW_HEALTH_NOTE,
47
- groupFlowsByOwner,
48
- reachDots,
49
- visibleEntries,
50
- } from "../lib/flow-tree";
51
- import type { FlowEntry, FlowHealth, FlowGroup } from "../lib/flow-tree";
52
- import type { Kind, LeafKind } from "../lib/kinds";
27
+ import { groupFlowsByOwner } from "../lib/flow-tree";
28
+ import type { LeafKind } from "../lib/kinds";
53
29
  import { plural } from "../lib/format";
54
30
  import {
55
31
  FLOW_GROUPS_KEY,
@@ -59,15 +35,15 @@ import {
59
35
  } from "../lib/sidebar-prefs";
60
36
  import type { Flags } from "../lib/sidebar-prefs";
61
37
  import { KindIcon } from "../components/kind";
62
- import { CompassRose, Wordmark } from "../components/logo";
38
+ import { Wordmark } from "../components/logo";
63
39
  import { isStruck } from "../components/primitives";
64
40
  import { packageAnchor, paths } from "../routes";
65
- import { selectionHash } from "../selection/hash";
66
41
  import { STORE_KIND_LABEL, StoreKindMark } from "../er/StoreHeader";
67
- import { resolvePin, usePinsStore } from "./pins";
68
- import { resolveSelection, selectionFor } from "../selection/model";
69
- import { selectsInPlace } from "../selection/pages";
42
+ import { resolveSelection } from "../selection/model";
70
43
  import { useSelectionStore } from "../selection/store";
44
+ import { SidebarBottomGroup, SidebarIconRail } from "./SidebarFooter";
45
+ import { FlowGroupNode, PinnedSection } from "./SidebarFlowSections";
46
+ import { Branch, Group, Leaf, Section, indent } from "./SidebarTree";
71
47
 
72
48
  // ---------------------------------------------------------------------------
73
49
  // The rows under a service, grouped the way the tree draws them. The tree
@@ -181,316 +157,7 @@ const CLASSIFICATION_NOTE: Record<Classification, string> = {
181
157
  // Rows
182
158
  // ---------------------------------------------------------------------------
183
159
 
184
- const indent = (depth: number) => 8 + depth * 12;
185
-
186
- /**
187
- * The selection's light: the raised background and the accent edge of the
188
- * row that is selected. One element for the whole tree - a new selection is
189
- * the same light arriving on another row, and it travels there on the settle
190
- * spring rather than going out here and coming on over there. `-left-0.5`
191
- * puts its edge over the row's own transparent 2px border, so the text does
192
- * not move.
193
- */
194
- function SelectionLight() {
195
- return (
196
- <m.span
197
- layoutId="selection"
198
- aria-hidden
199
- className="pointer-events-none absolute inset-y-0 right-0 -left-0.5 -z-10 border-l-2 border-accent"
200
- style={{ background: "var(--surface-2)" }}
201
- transition={transitions.settle}
202
- />
203
- );
204
- }
205
-
206
- function Chevron({ open }: { open: boolean }) {
207
- return (
208
- <ChevronRight
209
- size={14}
210
- aria-hidden
211
- className="block shrink-0 t-micro transition-transform"
212
- style={{
213
- transform: open ? "rotate(90deg)" : "none",
214
- color: "var(--fg-muted)",
215
- }}
216
- />
217
- );
218
- }
219
-
220
- /**
221
- * Clicking a row always selects. Whether it also navigates is the question the
222
- * page on screen answers: on a flow page, picking something that flow already
223
- * draws is a question about that sequence, so the reader stays put.
224
- *
225
- * Only the in-place case writes the store here. A row that navigates carries
226
- * its selection in the link's hash instead (see `rowTarget`): writing the
227
- * store first and then letting the link go would race SelectionSync, whose
228
- * `replace` lands with the pathname it rendered with - the page being left -
229
- * and undoes the navigation. A selection in the URL wins on arrival, so one
230
- * write does both jobs. The palette works the same way.
231
- */
232
- function useRowClick(selId: string | undefined) {
233
- const select = useSelectionStore((s) => s.select);
234
- const { pathname } = useLocation();
235
- return (e: React.MouseEvent) => {
236
- if (selId === undefined) return;
237
- if (!selectsInPlace(pathname, selectionFor(selId))) return;
238
- e.preventDefault();
239
- select(selId, "sidebar");
240
- };
241
- }
242
-
243
- /** Where a row's link goes: its page, with the row's selection in the hash. */
244
- function rowTarget(to: string, selId: string | undefined): string {
245
- if (selId === undefined || to.includes("#")) return to;
246
- return `${to}${selectionHash(selectionFor(selId))}`;
247
- }
248
-
249
- /**
250
- * A leaf: the whole row navigates. Rows that stand for a selectable entity also
251
- * carry `selId`, which makes them both a writer of the selection and a reader
252
- * of it - the row lights up whether it was clicked here, on a diagram, or in
253
- * the palette.
254
- */
255
- function Leaf({
256
- to,
257
- depth,
258
- children,
259
- title,
260
- selId,
261
- }: {
262
- to: string;
263
- depth: number;
264
- children: React.ReactNode;
265
- title?: string;
266
- selId?: string;
267
- }) {
268
- const selected = useSelectionStore(
269
- (s) => selId !== undefined && s.selection?.id === selId,
270
- );
271
- const onClick = useRowClick(selId);
272
- return (
273
- <NavLink
274
- to={rowTarget(to, selId)}
275
- end
276
- title={title}
277
- data-sel={selId}
278
- data-tree-row
279
- data-tree-depth={depth}
280
- onClick={onClick}
281
- style={({ isActive }) => ({
282
- paddingLeft: indent(depth),
283
- background: isActive && !selected ? "var(--surface-2)" : undefined,
284
- borderLeftWidth: 2,
285
- borderLeftStyle: "solid",
286
- borderLeftColor:
287
- isActive && !selected ? "var(--accent)" : "transparent",
288
- })}
289
- /* The edge is always 2px, transparent when idle: lighting a row must not
290
- shift the text beside it. The route's own row paints its light in
291
- place; the SELECTION's light is one element that slides from the row
292
- it was on to this one. */
293
- data-nav-item
294
- className="tree-row relative isolate flex items-center gap-1.5 py-[3px] pr-2 t-micro transition-colors hover:bg-surface"
295
- >
296
- {selected ? <SelectionLight /> : null}
297
- {children}
298
- </NavLink>
299
- );
300
- }
301
-
302
- /**
303
- * A branch: a disclosure triangle that only expands, next to a link that only
304
- * navigates. Keeping them separate means neither is nested inside the other.
305
- *
306
- * `end` is for controls that must be their own target - the unresolved-edge
307
- * count on a context, which goes somewhere else entirely. It sits OUTSIDE the
308
- * link, because an anchor may not contain another one, and the link gives up
309
- * its claim on the rest of the row to make room for it.
310
- */
311
- function Branch({
312
- to,
313
- depth,
314
- open,
315
- onToggle,
316
- label,
317
- children,
318
- right,
319
- end,
320
- under,
321
- selId,
322
- }: {
323
- to: string;
324
- depth: number;
325
- open: boolean;
326
- onToggle: () => void;
327
- label: string;
328
- children: React.ReactNode;
329
- right?: React.ReactNode;
330
- end?: React.ReactNode;
331
- under?: React.ReactNode;
332
- selId?: string;
333
- }) {
334
- const selected = useSelectionStore(
335
- (s) => selId !== undefined && s.selection?.id === selId,
336
- );
337
- const onClick = useRowClick(selId);
338
- const active = useMatch({ path: to, end: true }) !== null && !selected;
339
- return (
340
- <>
341
- <div
342
- className="tree-row group relative isolate flex items-stretch t-micro transition-colors hover:bg-surface"
343
- style={{
344
- paddingLeft: indent(depth),
345
- background: active ? "var(--surface-2)" : undefined,
346
- borderLeftWidth: 2,
347
- borderLeftStyle: "solid",
348
- borderLeftColor: active ? "var(--accent)" : "transparent",
349
- }}
350
- >
351
- {selected ? <SelectionLight /> : null}
352
- <button
353
- type="button"
354
- onClick={onToggle}
355
- aria-expanded={open}
356
- aria-label={`${open ? "Collapse" : "Expand"} ${label}`}
357
- data-tree-toggle
358
- className="flex shrink-0 items-center pr-1"
359
- >
360
- <Chevron open={open} />
361
- </button>
362
- <NavLink
363
- to={rowTarget(to, selId)}
364
- end
365
- data-sel={selId}
366
- data-nav-item
367
- data-tree-row
368
- data-tree-depth={depth}
369
- data-tree-open={open}
370
- onClick={onClick}
371
- className={`flex min-w-0 items-center gap-1.5 py-[3px] pr-2 ${
372
- end ? "shrink" : "flex-1"
373
- }`}
374
- >
375
- {children}
376
- {right && !end ? (
377
- <span className="ml-auto shrink-0">{right}</span>
378
- ) : null}
379
- </NavLink>
380
- {end}
381
- {right && end ? (
382
- <span className="flex shrink-0 items-center pr-2">{right}</span>
383
- ) : null}
384
- </div>
385
- {under}
386
- </>
387
- );
388
- }
389
-
390
- /**
391
- * A group header inside an aggregate - "value objects (3)". It names a kind
392
- * rather than a thing, so it has no page and does nothing but open and close.
393
- */
394
- function Group({
395
- kind,
396
- label,
397
- count,
398
- open,
399
- onToggle,
400
- depth,
401
- children,
402
- }: {
403
- kind: LeafKind;
404
- /** Overrides the kind's own plural — the stores group is "data", not "tables". */
405
- label?: string;
406
- count: number;
407
- open: boolean;
408
- onToggle: () => void;
409
- depth: number;
410
- children: React.ReactNode;
411
- }) {
412
- if (count === 0) return null;
413
- return (
414
- <>
415
- <button
416
- type="button"
417
- onClick={onToggle}
418
- aria-expanded={open}
419
- data-tree-row
420
- data-tree-depth={depth}
421
- data-tree-open={open}
422
- style={{ paddingLeft: indent(depth) }}
423
- className="tree-row group-row"
424
- >
425
- <Chevron open={open} />
426
- <KindIcon kind={kind} />
427
- <span>
428
- {label ?? KIND_GROUP_LABEL[kind]} ({count})
429
- </span>
430
- </button>
431
- <Unfold open={open}>{children}</Unfold>
432
- </>
433
- );
434
- }
435
-
436
- const KIND_GROUP_LABEL: Record<LeafKind, string> = {
437
- vo: "value objects",
438
- entity: "entities",
439
- enum: "enums",
440
- event: "events",
441
- command: "commands",
442
- query: "queries",
443
- endpoint: "api",
444
- table: "tables",
445
- view: "views",
446
- };
447
160
 
448
- // ---------------------------------------------------------------------------
449
- // Sections
450
- // ---------------------------------------------------------------------------
451
-
452
- /**
453
- * A top-level band of the tree. Its header folds it and always states its size:
454
- * a folded section that also hid its count would be indistinguishable from an
455
- * empty one, and the reader would have to open it to find out which.
456
- *
457
- * 12px above the header and nothing else between sections. The tree is one
458
- * continuous list; boxing each band would make a reader count boxes.
459
- */
460
- function Section({
461
- title,
462
- count,
463
- open,
464
- onToggle,
465
- first = false,
466
- children,
467
- }: {
468
- title: string;
469
- count: number;
470
- open: boolean;
471
- onToggle: () => void;
472
- /** The first section needs no air above it - the header is already there. */
473
- first?: boolean;
474
- children: React.ReactNode;
475
- }) {
476
- return (
477
- <div className={first ? "" : "mt-3"}>
478
- <button
479
- type="button"
480
- onClick={onToggle}
481
- aria-expanded={open}
482
- className="sb-section"
483
- >
484
- <Chevron open={open} />
485
- {title}
486
- <span className="sb-count">{count}</span>
487
- </button>
488
- <Unfold open={open}>{children}</Unfold>
489
- </div>
490
- );
491
- }
492
-
493
- /** A section of the tree with nothing under it, and why. */
494
161
  // ---------------------------------------------------------------------------
495
162
  // The rows as the arrow keys see them. Every leaf, branch link and group
496
163
  // header carries `data-tree-row`; a closed branch's children are not in the
@@ -529,390 +196,6 @@ function TreeNote({ children }: { children: React.ReactNode }) {
529
196
  // Flows
530
197
  // ---------------------------------------------------------------------------
531
198
 
532
- const HEALTH_COLOR: Record<FlowHealth, string> = {
533
- unresolved: "var(--status-unresolved)",
534
- declared: "var(--fg-faint)",
535
- verified: "var(--status-verified)",
536
- };
537
-
538
- /**
539
- * One flow. Its name is what the row is for; the two marks at the end answer
540
- * the two questions a reader has before opening it - how far does it travel,
541
- * and can I believe it.
542
- */
543
- function FlowRow({ entry }: { entry: FlowEntry }) {
544
- const { flow, health, reach } = entry;
545
- const { dots, more } = reachDots(reach);
546
- const crosses =
547
- reach.length === 0
548
- ? "stays inside its own context"
549
- : `crosses into ${reach.join(", ")}`;
550
- return (
551
- <Leaf
552
- to={paths.flow(flow.slug)}
553
- depth={1}
554
- title={`${flow.slug} — ${crosses}`}
555
- >
556
- <KindIcon kind="flow" />
557
- <span className="truncate">{flow.name}</span>
558
- <span className="ml-auto flex shrink-0 items-center gap-2 pl-2">
559
- <span className="flex items-center gap-0.5" aria-hidden>
560
- {dots.map((c) => (
561
- <span key={c} className="sb-reach" style={ctxStyle(c)} />
562
- ))}
563
- {more > 0 ? (
564
- <span className="mono" style={{ color: "var(--fg-faint)" }}>
565
- +{more}
566
- </span>
567
- ) : null}
568
- </span>
569
- <span
570
- className="sb-health"
571
- title={FLOW_HEALTH_NOTE[health]}
572
- style={{ background: HEALTH_COLOR[health] }}
573
- />
574
- </span>
575
- </Leaf>
576
- );
577
- }
578
-
579
- /**
580
- * The flows one context owns. Grouping them by owner rather than listing them
581
- * flat is what makes the section answer "whose flows are these" - and the
582
- * unowned group is a defect report: with a valid catalog it is never drawn.
583
- */
584
- function FlowGroupNode({
585
- group,
586
- open,
587
- onToggle,
588
- }: {
589
- group: FlowGroup;
590
- open: boolean;
591
- onToggle: () => void;
592
- }) {
593
- const { shown, hidden } = visibleEntries(group.entries);
594
- const owner = group.owner;
595
- const label = owner === null ? "unowned" : contextName(owner);
596
- return (
597
- <div>
598
- <button
599
- type="button"
600
- onClick={onToggle}
601
- aria-expanded={open}
602
- style={{ paddingLeft: indent(0) }}
603
- title={
604
- owner === null
605
- ? "these flows name no owner — a valid catalog has none of these"
606
- : `flows owned by ${owner}`
607
- }
608
- className="tree-row flex w-full items-center gap-1.5 py-[3px] pr-2 text-left t-micro transition-colors hover:bg-surface"
609
- >
610
- <Chevron open={open} />
611
- {owner === null ? (
612
- <TriangleAlert
613
- size={13}
614
- aria-hidden
615
- className="block shrink-0 text-unresolved"
616
- />
617
- ) : (
618
- <KindIcon kind="context" contextId={owner} />
619
- )}
620
- <span
621
- className="truncate"
622
- style={owner === null ? { color: "var(--status-unresolved)" } : {}}
623
- >
624
- {label}
625
- </span>
626
- <span className="sb-count">{group.entries.length}</span>
627
- </button>
628
- <Unfold open={open}>
629
- <>
630
- {shown.map((entry) => (
631
- <FlowRow key={entry.flow.slug} entry={entry} />
632
- ))}
633
- {hidden > 0 && owner !== null ? (
634
- <NavLink
635
- to={`${paths.flows()}?owner=${encodeURIComponent(owner)}`}
636
- data-nav-item
637
- style={{ paddingLeft: indent(1) }}
638
- className="tree-row mono flex items-center py-[3px] pr-2 text-accent hover:bg-surface"
639
- >
640
- view all {group.entries.length} →
641
- </NavLink>
642
- ) : null}
643
- </>
644
- </Unfold>
645
- </div>
646
- );
647
- }
648
-
649
- // ---------------------------------------------------------------------------
650
- // Pins
651
- // ---------------------------------------------------------------------------
652
-
653
- /**
654
- * The reader's own shortlist, above everything the catalog decided. It only
655
- * exists while there is something on it: an empty band with a header would
656
- * teach the feature by taking up room, which is the one thing the pane cannot
657
- * spare.
658
- */
659
- function PinnedSection({
660
- open,
661
- onToggle,
662
- }: {
663
- open: boolean;
664
- onToggle: () => void;
665
- }) {
666
- const pins = usePinsStore((s) => s.pins);
667
- const toggle = usePinsStore((s) => s.toggle);
668
- const reorder = usePinsStore((s) => s.reorder);
669
- // Which row is under the cursor is a ref, not state: a drop fires in the same
670
- // gesture that started the drag, and a state write scheduled by `dragstart`
671
- // is not guaranteed to have landed by the time `drop` reads it. The state
672
- // beside it only dims the row, so it is allowed to arrive late.
673
- const dragFrom = useRef<number | null>(null);
674
- const [dragging, setDragging] = useState<number | null>(null);
675
-
676
- // Resolved at render, so a pin taken against a build that no longer has the
677
- // event simply stops being drawn rather than pointing into nothing.
678
- const rows = useMemo(
679
- () =>
680
- pins
681
- .map((pin, at) => ({ at, resolved: resolvePin(pin) }))
682
- .filter(
683
- (r): r is { at: number; resolved: NonNullable<typeof r.resolved> } =>
684
- r.resolved !== null,
685
- ),
686
- [pins],
687
- );
688
- if (rows.length === 0) return null;
689
-
690
- return (
691
- <Section
692
- title="Pinned"
693
- count={rows.length}
694
- open={open}
695
- onToggle={onToggle}
696
- first
697
- >
698
- {rows.map(({ at, resolved }) => (
699
- <div
700
- key={`${resolved.pin.kind}:${resolved.pin.id}`}
701
- draggable
702
- onDragStart={(e) => {
703
- dragFrom.current = at;
704
- e.dataTransfer.effectAllowed = "move";
705
- setDragging(at);
706
- }}
707
- onDragEnd={() => {
708
- dragFrom.current = null;
709
- setDragging(null);
710
- }}
711
- onDragOver={(e) => {
712
- e.preventDefault();
713
- e.dataTransfer.dropEffect = "move";
714
- }}
715
- onDrop={(e) => {
716
- e.preventDefault();
717
- const from = dragFrom.current;
718
- if (from !== null) reorder(from, at);
719
- dragFrom.current = null;
720
- setDragging(null);
721
- }}
722
- className={`group relative flex items-stretch ${
723
- dragging === at ? "opacity-50" : ""
724
- }`}
725
- >
726
- <NavLink
727
- to={resolved.path}
728
- end
729
- draggable={false}
730
- data-nav-item
731
- title={resolved.title}
732
- style={({ isActive }) => ({
733
- paddingLeft: indent(0),
734
- background: isActive ? "var(--surface-2)" : undefined,
735
- borderLeftWidth: 2,
736
- borderLeftStyle: "solid",
737
- borderLeftColor: isActive ? "var(--accent)" : "transparent",
738
- })}
739
- 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"
740
- >
741
- <GripVertical
742
- size={12}
743
- aria-hidden
744
- className="block shrink-0 opacity-0 group-hover:opacity-100"
745
- style={{ color: "var(--fg-faint)" }}
746
- />
747
- <KindIcon
748
- kind={resolved.kind}
749
- {...(resolved.contextId ? { contextId: resolved.contextId } : {})}
750
- />
751
- <span className="truncate">{resolved.name}</span>
752
- </NavLink>
753
- <button
754
- type="button"
755
- onClick={() => toggle(resolved.pin)}
756
- aria-label={`Unpin ${resolved.name}`}
757
- title={`Unpin ${resolved.name}`}
758
- 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"
759
- >
760
- <PinOff size={13} aria-hidden />
761
- </button>
762
- </div>
763
- ))}
764
- </Section>
765
- );
766
- }
767
-
768
- // ---------------------------------------------------------------------------
769
- // The bottom group. A sibling of the scroller rather than a row inside it, so
770
- // "pinned to the bottom" is a fact about the layout and not about how far the
771
- // tree happens to have been scrolled.
772
- // ---------------------------------------------------------------------------
773
-
774
- function BottomGroup() {
775
- // Both halves of the Problems page, because that is what the row opens. The
776
- // badge is red while anything on it is an error and amber when only the
777
- // schema disagrees with itself - a page of warnings is not a clean estate,
778
- // and a green tick over it would be the one lie the row can tell.
779
- const found = useMemo(
780
- () => [
781
- ...problems(catalog),
782
- ...protoProblems(catalog, index),
783
- ...dataProblems(catalog, index),
784
- ],
785
- [],
786
- );
787
- const errors = found.filter((p) => p.severity === "error").length;
788
- const colour =
789
- errors > 0 ? "var(--status-unresolved)" : "var(--status-declared)";
790
-
791
- return (
792
- <div className="shrink-0 border-t bg-canvas border-line py-0.5">
793
- <NavLink
794
- to={paths.problems()}
795
- end
796
- data-nav-item
797
- aria-keyshortcuts="g p"
798
- title={
799
- found.length === 0
800
- ? "Problems — every edge in the catalog lands somewhere"
801
- : `Problems — ${found.length} to look at`
802
- }
803
- style={({ isActive }) => ({
804
- paddingLeft: indent(0),
805
- background: isActive ? "var(--surface-2)" : undefined,
806
- borderLeftWidth: 2,
807
- borderLeftStyle: "solid",
808
- borderLeftColor: isActive ? "var(--accent)" : "transparent",
809
- })}
810
- className="tree-row flex items-center gap-1.5 py-[3px] pr-2 t-micro transition-colors hover:bg-surface"
811
- >
812
- <TriangleAlert
813
- size={14}
814
- aria-hidden
815
- className="block shrink-0"
816
- style={{ color: found.length === 0 ? "var(--fg-muted)" : colour }}
817
- />
818
- <span className="truncate">Problems</span>
819
- {found.length === 0 ? (
820
- <Check
821
- size={13}
822
- aria-hidden
823
- className="ml-auto block shrink-0 text-muted"
824
- />
825
- ) : (
826
- <span
827
- className="mono tnum ml-auto shrink-0"
828
- style={{ color: colour }}
829
- >
830
- {found.length}
831
- </span>
832
- )}
833
- </NavLink>
834
- <NavLink
835
- to={paths.settings()}
836
- data-nav-item
837
- title="Settings — projects, plugins and appearance"
838
- style={({ isActive }) => ({
839
- paddingLeft: indent(0),
840
- background: isActive ? "var(--surface-2)" : undefined,
841
- borderLeftWidth: 2,
842
- borderLeftStyle: "solid",
843
- borderLeftColor: isActive ? "var(--accent)" : "transparent",
844
- })}
845
- 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"
846
- >
847
- <Settings2 size={14} aria-hidden className="block shrink-0" />
848
- <span className="truncate">Settings</span>
849
- </NavLink>
850
- </div>
851
- );
852
- }
853
-
854
- /**
855
- * What is left of the tree at 48px. Not a menu: every button here does the one
856
- * thing the rail can honestly offer, which is to give the tree its width back
857
- * and land the reader on the section they pointed at.
858
- */
859
- function IconRail({ onExpand }: { onExpand: () => void }) {
860
- const sections: { key: string; kind: Kind; label: string }[] = [
861
- { key: "flows", kind: "flow", label: "Flows" },
862
- { key: "domains", kind: "context", label: "Contexts" },
863
- // Conditional for the same reason the band is: at 48px a button that opens
864
- // an empty section is worse than no button.
865
- ...(allModules(catalog).length > 0
866
- ? [{ key: "registry", kind: "module" as Kind, label: "Registry" }]
867
- : []),
868
- { key: "adrs", kind: "adr", label: "Decisions" },
869
- ];
870
- return (
871
- <nav
872
- className="flex h-full flex-col items-center gap-1 border-r py-3 border-line bg-canvas"
873
- aria-label="Catalog (collapsed)"
874
- >
875
- {/* The mark survives the collapse; the wordmark does not fit and is not
876
- missed - at 48px the rose is the whole identity. */}
877
- <CompassRose size={18} className="mb-1 text-ink" />
878
- <button
879
- type="button"
880
- onClick={onExpand}
881
- title="Expand the catalog"
882
- aria-label="Expand the catalog"
883
- aria-expanded={false}
884
- className="flex size-8 items-center justify-center rounded-control text-muted t-micro transition-colors hover:bg-surface hover:text-ink"
885
- >
886
- <PanelLeftOpen size={16} aria-hidden />
887
- </button>
888
- <span aria-hidden className="my-1 h-px w-6 bg-line" />
889
- {sections.map(({ key, kind, label }) => (
890
- <button
891
- key={key}
892
- type="button"
893
- onClick={onExpand}
894
- title={`${label} — expand the catalog`}
895
- aria-label={`${label} — expand the catalog`}
896
- className="flex size-8 items-center justify-center rounded-control t-micro transition-colors hover:bg-surface"
897
- >
898
- <KindIcon kind={kind} size={16} />
899
- </button>
900
- ))}
901
- <NavLink
902
- to={paths.settings()}
903
- title="Settings"
904
- aria-label="Settings"
905
- className={({ isActive }) =>
906
- `mt-auto flex size-8 items-center justify-center rounded-control t-micro transition-colors hover:bg-surface ${
907
- isActive ? "bg-surface text-accent" : "text-muted hover:text-ink"
908
- }`
909
- }
910
- >
911
- <Settings2 size={16} aria-hidden />
912
- </NavLink>
913
- </nav>
914
- );
915
- }
916
199
 
917
200
  // ---------------------------------------------------------------------------
918
201
 
@@ -1082,7 +365,7 @@ export function Sidebar({
1082
365
  return () => clearTimeout(timer);
1083
366
  }, [selection, collapsed, sections, groups]);
1084
367
 
1085
- if (railed) return <IconRail onExpand={() => onExpand?.()} />;
368
+ if (railed) return <SidebarIconRail onExpand={() => onExpand?.()} />;
1086
369
 
1087
370
  // Namespaces the selection light's layoutId to this tree.
1088
371
  const treeId = useId();
@@ -1444,7 +727,7 @@ export function Sidebar({
1444
727
  ) : null}
1445
728
  </div>
1446
729
 
1447
- <BottomGroup />
730
+ <SidebarBottomGroup />
1448
731
  </LayoutGroup>
1449
732
  </nav>
1450
733
  );