@shortlink-org/portolan 0.2.0 → 0.2.2

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 (109) hide show
  1. package/cli/init.mjs +14 -6
  2. package/cli/init.test.mjs +8 -1
  3. package/cli/portolan.mjs +56 -7
  4. package/cli/portolan.test.mjs +47 -0
  5. package/package.json +7 -6
  6. package/plugins/extract-django/README.md +1 -1
  7. package/plugins/extract-django/extract_test.py +1 -1
  8. package/plugins/extract-django/openapi.py +4 -14
  9. package/plugins/extract-django/openapi_test.py +4 -4
  10. package/plugins/extract-go/options.schema.json +5 -0
  11. package/plugins/extract-project/options.schema.json +21 -0
  12. package/plugins/extract-ts/README.md +1 -1
  13. package/plugins/extract-ts/clients.ts +1 -1
  14. package/plugins/extract-ts/extract.test.ts +2 -2
  15. package/plugins/extract-ts/graphql.test.ts +2 -2
  16. package/plugins/extract-ts/openapi.test.ts +6 -7
  17. package/plugins/extract-ts/openapi.ts +3 -10
  18. package/plugins/portolan-go.wasm +0 -0
  19. package/public/404-cat-v1.webp +0 -0
  20. package/public/cat-about-v1.webp +0 -0
  21. package/public/cat-clear-skies-v1.webp +0 -0
  22. package/public/cat-no-changes-v1.webp +0 -0
  23. package/public/cat-onboarding-v1.webp +0 -0
  24. package/public/cat-trial-success-v1.webp +0 -0
  25. package/schema/portolan.schema.json +61 -0
  26. package/scripts/catalog-sources.mjs +12 -1
  27. package/scripts/catalog-sources.test.mjs +35 -0
  28. package/scripts/gen-likec4.mjs +242 -85
  29. package/scripts/gen-likec4.test.mjs +168 -14
  30. package/scripts/gen.mjs +5 -3
  31. package/scripts/host-plugins/fetch-git.mjs +22 -12
  32. package/scripts/host-plugins/fetch-git.test.mjs +25 -5
  33. package/scripts/local-api.mjs +368 -34
  34. package/scripts/local-api.test.mjs +197 -2
  35. package/scripts/package-smoke.mjs +19 -2
  36. package/scripts/plugin-host.mjs +14 -3
  37. package/scripts/plugin-host.test.mjs +8 -0
  38. package/scripts/schema.mjs +7 -0
  39. package/src/app/CatalogApp.tsx +19 -4
  40. package/src/app/CommandPalette.tsx +7 -2
  41. package/src/app/toast.tsx +11 -7
  42. package/src/catalog.test.ts +33 -0
  43. package/src/catalog.ts +190 -8
  44. package/src/components/CatIllustration.tsx +30 -0
  45. package/src/components/CommitLink.tsx +154 -0
  46. package/src/components/EditorLink.tsx +15 -8
  47. package/src/components/GrpcMethodReference.tsx +93 -0
  48. package/src/components/Markdown.tsx +17 -0
  49. package/src/components/MethodRows.tsx +8 -1
  50. package/src/components/PageHeader.test.ts +24 -0
  51. package/src/components/PageHeader.tsx +34 -0
  52. package/src/components/ShapeRows.tsx +12 -0
  53. package/src/components/SourceDoc.tsx +27 -12
  54. package/src/components/SourcePreview.tsx +124 -36
  55. package/src/enrich.test.ts +209 -2
  56. package/src/enrich.ts +284 -15
  57. package/src/er/RedisSchema.tsx +86 -5
  58. package/src/flow/FlowTable.tsx +19 -4
  59. package/src/flow/StepDetail.tsx +461 -101
  60. package/src/flow/StepRail.tsx +36 -13
  61. package/src/flow/answers-response.test.ts +100 -0
  62. package/src/flow/answers.ts +11 -8
  63. package/src/flow/chapters.ts +2 -1
  64. package/src/flow/labels.ts +10 -0
  65. package/src/flow/mermaid.test.ts +87 -3
  66. package/src/flow/mermaid.ts +26 -14
  67. package/src/index.css +132 -0
  68. package/src/landing/LandingPage.tsx +12 -8
  69. package/src/lib/api.test.ts +4 -6
  70. package/src/lib/build-info.test.ts +29 -0
  71. package/src/lib/build-info.ts +21 -0
  72. package/src/lib/data-model.ts +14 -0
  73. package/src/lib/flow-tree.ts +1 -1
  74. package/src/lib/github-catalog.test.ts +51 -0
  75. package/src/lib/github-catalog.ts +69 -0
  76. package/src/lib/grpc-reference.test.ts +72 -0
  77. package/src/lib/grpc-reference.ts +117 -0
  78. package/src/lib/local-api.ts +17 -4
  79. package/src/lib/product.ts +7 -0
  80. package/src/lib/queries.ts +16 -1
  81. package/src/lib/readme-assets.test.ts +19 -0
  82. package/src/lib/readme-assets.ts +34 -0
  83. package/src/lib/setup-info.test.ts +7 -0
  84. package/src/lib/setup-info.ts +5 -0
  85. package/src/lib/source-link.test.ts +106 -18
  86. package/src/lib/source-link.ts +22 -6
  87. package/src/merge.test.ts +55 -0
  88. package/src/merge.ts +30 -0
  89. package/src/pages/AdrDetail.tsx +3 -2
  90. package/src/pages/AdrIndex.tsx +10 -7
  91. package/src/pages/AggregatePage.tsx +34 -4
  92. package/src/pages/BlockPage.tsx +39 -32
  93. package/src/pages/Changes.tsx +7 -9
  94. package/src/pages/ContextMap.tsx +11 -2
  95. package/src/pages/ContextPage.tsx +11 -4
  96. package/src/pages/FlowIndex.tsx +16 -7
  97. package/src/pages/GraphPage.tsx +21 -7
  98. package/src/pages/Language.tsx +9 -9
  99. package/src/pages/NotFound.tsx +58 -15
  100. package/src/pages/Overview.tsx +6 -0
  101. package/src/pages/Problems.tsx +10 -8
  102. package/src/pages/RegistryIndex.tsx +10 -8
  103. package/src/pages/ServicePage.tsx +1 -1
  104. package/src/pages/Settings.tsx +382 -44
  105. package/src/routes.test.ts +2 -0
  106. package/src/routes.ts +2 -1
  107. package/src/selection/DetailPanel.tsx +213 -113
  108. package/src/testing/setup.ts +14 -0
  109. package/vite.config.ts +5 -1
@@ -170,11 +170,13 @@ describe("routes", () => {
170
170
  expect(paths.settingsPipeline()).toBe("/settings/pipeline");
171
171
  expect(paths.settingsDelivery()).toBe("/settings/delivery");
172
172
  expect(paths.settingsPreferences()).toBe("/settings/preferences");
173
+ expect(paths.settingsAbout()).toBe("/settings/about");
173
174
  for (const path of [
174
175
  paths.settingsProjects(),
175
176
  paths.settingsPipeline(),
176
177
  paths.settingsDelivery(),
177
178
  paths.settingsPreferences(),
179
+ paths.settingsAbout(),
178
180
  ]) expect(isRoutable(path)).toBe(true);
179
181
  expect(isRoutable("/settings/nope")).toBe(false);
180
182
  expect(allCatalogPaths(catalog)).toContain(paths.settings());
package/src/routes.ts CHANGED
@@ -46,6 +46,7 @@ export const paths = {
46
46
  settingsPipeline: () => "/settings/pipeline",
47
47
  settingsDelivery: () => "/settings/delivery",
48
48
  settingsPreferences: () => "/settings/preferences",
49
+ settingsAbout: () => "/settings/about",
49
50
  external: (slug: string) => `/externals/${slug}`,
50
51
  registry: () => "/registry",
51
52
  /**
@@ -374,7 +375,7 @@ const ROUTES: RegExp[] = [
374
375
  /^\/language$/,
375
376
  /^\/problems$/,
376
377
  /^\/changes$/,
377
- /^\/settings(?:\/(?:projects|pipeline|delivery|preferences))?$/,
378
+ /^\/settings(?:\/(?:projects|pipeline|delivery|preferences|about))?$/,
378
379
  /^\/externals\/[^/]+$/,
379
380
  /^\/map$/,
380
381
  /^\/adrs\/[^/]+$/,
@@ -19,6 +19,7 @@ import { useNarrow } from "../app/responsive";
19
19
  import { useUiStore } from "../app/ui-store";
20
20
  import { catalog, index } from "../data";
21
21
  import {
22
+ allRepos,
22
23
  blockFields,
23
24
  columnId,
24
25
  columnNameOfId,
@@ -29,9 +30,11 @@ import {
29
30
  viewReads,
30
31
  } from "../catalog";
31
32
  import { flowsForService, usesOfDef } from "../lib/derive";
33
+ import { stepLabel } from "../flow/labels";
32
34
  import { KIND_LABEL } from "../lib/kinds";
33
35
  import { eventScope, resolveShape } from "../lib/shape";
34
36
  import { stepsInto } from "../lib/backlinks";
37
+ import { treeHref } from "../lib/source-link";
35
38
  import { walkSteps } from "../catalog";
36
39
  import { methodCount } from "../lib/api";
37
40
  import {
@@ -78,6 +81,26 @@ function Row({ children }: { children: ReactNode }) {
78
81
  return <div className="flex items-center gap-2 py-0.5">{children}</div>;
79
82
  }
80
83
 
84
+ function PanelSection({
85
+ title,
86
+ meta,
87
+ children,
88
+ }: {
89
+ title: string;
90
+ meta?: ReactNode;
91
+ children: ReactNode;
92
+ }) {
93
+ return (
94
+ <section className="border-b px-3 py-3 last:border-b-0 border-line">
95
+ <div className="mb-2 flex items-baseline justify-between gap-3">
96
+ <h3 className="label">{title}</h3>
97
+ {meta ? <span className="mono text-muted">{meta}</span> : null}
98
+ </div>
99
+ {children}
100
+ </section>
101
+ );
102
+ }
103
+
81
104
  /** The panel's own way of moving the selection, without leaving the page. */
82
105
  function SelectLink({ id, children }: { id: string; children: ReactNode }) {
83
106
  const select = useSelectionStore((s) => s.select);
@@ -699,6 +722,8 @@ function titleOf(resolved: Resolved | null, selection: Selection): string {
699
722
  switch (resolved.kind) {
700
723
  case "event":
701
724
  return resolved.event.name;
725
+ case "service":
726
+ return resolved.service.name;
702
727
  case "table":
703
728
  return resolved.table.name;
704
729
  case "view":
@@ -706,7 +731,7 @@ function titleOf(resolved: Resolved | null, selection: Selection): string {
706
731
  case "column":
707
732
  return `${resolved.view?.name ?? resolved.table?.name ?? resolved.store.slug}.${resolved.column.name}`;
708
733
  case "flow-step":
709
- return resolved.step.label ?? resolved.step.ref ?? resolved.step.kind;
734
+ return stepLabel(resolved.step);
710
735
  case "bundle":
711
736
  return `${resolved.bundle.from} → ${resolved.bundle.to}`;
712
737
  // `acme/shop`, not the registry host as well: the host is the same for
@@ -842,99 +867,155 @@ function ServiceBody({
842
867
  const methods = methodCount(service);
843
868
  const events = service.aggregates.flatMap((a) => a.events);
844
869
  const unresolved = service.consumes.filter((c) => c.status === "unresolved");
870
+ const tree = treeHref(service.path, service, allRepos(catalog));
845
871
  // Each flow this service takes part in, opened on the first step it is
846
872
  // on either end of - where it enters the story, not the top of the rail.
847
873
  const appearances = flowsForService(catalog, service.id).map((flow) => {
848
874
  const steps = walkSteps(flow.steps);
849
- const at = steps.findIndex((s) => s.from === service.id || s.to === service.id);
875
+ const at = steps.findIndex(
876
+ (s) => s.from === service.id || s.to === service.id,
877
+ );
850
878
  return { flow, step: at >= 0 ? steps[at] : undefined, number: at + 1 };
851
879
  });
852
880
 
853
881
  return (
854
- <>
855
- <Ident
856
- block
857
- value={`${service.repo}/${service.path}`}
858
- className="mt-1.5 text-muted"
859
- />
860
-
861
- <Label>Provides</Label>
862
- {/* The count opens the tab that lists the methods it counted. */}
863
- <Link
864
- to={`${paths.service(context.id, service.slug)}?tab=provides`}
865
- className="mono rounded-control text-muted hover:text-ink"
866
- >
867
- <span className="tnum">{methods}</span> method
868
- {methods === 1 ? "" : "s"} over{" "}
869
- <span className="tnum">{service.provides.length}</span> service
870
- {service.provides.length === 1 ? "" : "s"}
871
- </Link>
872
- {service.provides.map((p) => (
873
- <Ident block key={p.id} value={p.id} className="text-muted" />
874
- ))}
882
+ <section
883
+ aria-label="Service details"
884
+ className="overflow-hidden rounded-card border shadow-xs border-line"
885
+ >
886
+ <PanelSection title="Source">
887
+ <dl className="grid grid-cols-[6.5rem_minmax(0,1fr)] gap-x-4 gap-y-2">
888
+ <dt className="mono text-muted">Repository</dt>
889
+ <dd className="min-w-0">
890
+ <Ident block value={service.repo} className="text-ink" />
891
+ </dd>
892
+ <dt className="mono text-muted">Path</dt>
893
+ <dd className="min-w-0">
894
+ <Ident block value={service.path} className="text-ink" />
895
+ </dd>
896
+ </dl>
897
+ {tree ? (
898
+ <a
899
+ href={tree}
900
+ target="_blank"
901
+ rel="noreferrer"
902
+ className="mono mt-2 inline-flex items-center rounded-control border px-2 py-1 border-line bg-canvas text-accent hover:bg-raised"
903
+ title="Open the service directory on the forge, at the built commit"
904
+ >
905
+ forge ↗
906
+ </a>
907
+ ) : null}
908
+ </PanelSection>
875
909
 
876
- <Label>Consumes</Label>
877
- {service.consumes.length === 0 ? (
878
- <div className="mono text-muted">this service calls nobody</div>
879
- ) : null}
880
- {service.consumes.map((call) => (
881
- <Row key={call.id}>
882
- <Ident value={call.id} />
883
- <span className="ml-auto shrink-0">
884
- <StatusChip status={call.status} title={call.note} />
885
- </span>
886
- </Row>
887
- ))}
888
- {unresolved.length > 0 ? (
910
+ <PanelSection
911
+ title="Provides"
912
+ meta={`${methods} method${methods === 1 ? "" : "s"}`}
913
+ >
914
+ {/* The count opens the tab that lists the interfaces it counted. */}
889
915
  <Link
890
- to={paths.problems()}
891
- className="mono mt-1 block rounded-control text-unresolved hover:underline"
916
+ to={`${paths.service(context.id, service.slug)}?tab=provides`}
917
+ className="mono rounded-control text-muted hover:text-ink hover:underline"
892
918
  >
893
- <span className="tnum">{unresolved.length}</span> call
894
- {unresolved.length === 1 ? "" : "s"} resolve to nothing in the catalog
895
-
919
+ {service.provides.length} interface
920
+ {service.provides.length === 1 ? "" : "s"}
896
921
  </Link>
897
- ) : null}
922
+ {service.provides.length > 0 ? (
923
+ <div className="mt-2 flex flex-col gap-1">
924
+ {service.provides.map((provided) => (
925
+ <Ident
926
+ block
927
+ key={provided.id}
928
+ value={provided.id}
929
+ className="text-ink"
930
+ />
931
+ ))}
932
+ </div>
933
+ ) : null}
934
+ </PanelSection>
898
935
 
899
- <Label>Publishes</Label>
900
- {events.length === 0 ? (
901
- <div className="mono text-muted">no events</div>
902
- ) : null}
903
- {events.map((e) => (
904
- <Row key={e.id}>
905
- <SelectLink id={e.id}>{e.name}</SelectLink>
936
+ <PanelSection
937
+ title="Consumes"
938
+ meta={`${service.consumes.length} call${service.consumes.length === 1 ? "" : "s"}`}
939
+ >
940
+ {service.consumes.length === 0 ? (
941
+ <div className="mono text-muted">this service calls nobody</div>
942
+ ) : (
943
+ <div className="flex flex-col gap-1.5">
944
+ {service.consumes.map((call) => (
945
+ <div key={call.id} className="flex flex-wrap items-center gap-2">
946
+ <Ident value={call.id} />
947
+ <StatusChip status={call.status} title={call.note} />
948
+ </div>
949
+ ))}
950
+ </div>
951
+ )}
952
+ {unresolved.length > 0 ? (
906
953
  <Link
907
- to={`${eventPathOf(e.id) ?? paths.service(context.id, service.slug)}#${EVENT_ANCHOR.consumers}`}
908
- title={`${e.consumers.length} consumers of ${e.name}`}
909
- className="mono tnum ml-auto shrink-0 rounded-control text-muted hover:text-ink"
954
+ to={paths.problems()}
955
+ className="mono mt-2 block rounded-control text-unresolved hover:underline"
910
956
  >
911
- {e.consumers.length}
957
+ <span className="tnum">{unresolved.length}</span> call
958
+ {unresolved.length === 1 ? "" : "s"} resolve to nothing in the
959
+ catalog →
912
960
  </Link>
913
- </Row>
914
- ))}
961
+ ) : null}
962
+ </PanelSection>
915
963
 
916
- <Label>Appears in flows</Label>
917
- {appearances.length === 0 ? (
918
- <div className="mono text-muted">appears in no flow</div>
919
- ) : (
920
- <div className="flex flex-col gap-1">
921
- {appearances.map(({ flow, step, number }) => (
922
- <Link
923
- key={flow.slug}
924
- to={step ? paths.flowStep(flow.slug, step.id) : paths.flow(flow.slug)}
925
- className="mono text-accent"
926
- title={step ? `${flow.name}, from step ${number}` : flow.name}
927
- >
928
- {flow.slug}
929
- {step ? ` · step ${number}` : ""}
930
- </Link>
931
- ))}
932
- </div>
933
- )}
964
+ <PanelSection
965
+ title="Publishes"
966
+ meta={`${events.length} event${events.length === 1 ? "" : "s"}`}
967
+ >
968
+ {events.length === 0 ? (
969
+ <div className="mono text-muted">no events</div>
970
+ ) : (
971
+ <div className="flex flex-col gap-1.5">
972
+ {events.map((event) => (
973
+ <div key={event.id} className="flex flex-wrap items-center gap-2">
974
+ <SelectLink id={event.id}>{event.name}</SelectLink>
975
+ <Link
976
+ to={`${eventPathOf(event.id) ?? paths.service(context.id, service.slug)}#${EVENT_ANCHOR.consumers}`}
977
+ title={`${event.consumers.length} consumers of ${event.name}`}
978
+ className="mono tnum shrink-0 rounded-control text-muted hover:text-ink"
979
+ >
980
+ {event.consumers.length} consumers
981
+ </Link>
982
+ </div>
983
+ ))}
984
+ </div>
985
+ )}
986
+ </PanelSection>
934
987
 
935
- <Label>Context</Label>
936
- <SelectLink id={context.id}>{context.id}</SelectLink>
937
- </>
988
+ <PanelSection title="Appears in flows" meta={appearances.length}>
989
+ {appearances.length === 0 ? (
990
+ <div className="mono text-muted">appears in no flow</div>
991
+ ) : (
992
+ <div className="flex flex-col gap-1.5">
993
+ {appearances.map(({ flow, step, number }) => (
994
+ <Link
995
+ key={flow.slug}
996
+ to={
997
+ step
998
+ ? paths.flowStep(flow.slug, step.id)
999
+ : paths.flow(flow.slug)
1000
+ }
1001
+ className="mono flex min-w-0 flex-wrap items-baseline gap-x-2 text-accent"
1002
+ title={step ? `${flow.name}, from step ${number}` : flow.name}
1003
+ >
1004
+ <span className="min-w-0 truncate">{flow.slug}</span>
1005
+ {step ? (
1006
+ <span className="shrink-0 text-muted">step {number}</span>
1007
+ ) : null}
1008
+ <span aria-hidden>→</span>
1009
+ </Link>
1010
+ ))}
1011
+ </div>
1012
+ )}
1013
+ </PanelSection>
1014
+
1015
+ <PanelSection title="Context">
1016
+ <SelectLink id={context.id}>{context.id}</SelectLink>
1017
+ </PanelSection>
1018
+ </section>
938
1019
  );
939
1020
  }
940
1021
 
@@ -1066,16 +1147,7 @@ function FlowStepBody({
1066
1147
  }: {
1067
1148
  resolved: Extract<Resolved, { kind: "flow-step" }>;
1068
1149
  }) {
1069
- return (
1070
- <>
1071
- <div className="mono mt-1 mb-2 flex items-center gap-2 text-muted">
1072
- <Link to={paths.flow(resolved.flow.slug)} className="text-accent">
1073
- {resolved.flow.slug}
1074
- </Link>
1075
- </div>
1076
- <StepDetailBody step={resolved.step} flow={resolved.flow} />
1077
- </>
1078
- );
1150
+ return <StepDetailBody step={resolved.step} flow={resolved.flow} />;
1079
1151
  }
1080
1152
 
1081
1153
  /**
@@ -1208,6 +1280,42 @@ function pinFor(
1208
1280
  }
1209
1281
  }
1210
1282
 
1283
+ function DetailNavigation({
1284
+ resolved,
1285
+ page,
1286
+ }: {
1287
+ resolved: Resolved | null;
1288
+ page: string | null;
1289
+ }) {
1290
+ if (!page) return null;
1291
+
1292
+ const flowStep = resolved?.kind === "flow-step" ? resolved : null;
1293
+ const destination =
1294
+ flowStep?.flow.slug ??
1295
+ (resolved?.kind === "service" ? "service page" : "catalog page");
1296
+
1297
+ return (
1298
+ <nav
1299
+ aria-label="Selection navigation"
1300
+ className="my-3 flex min-w-0 items-center gap-2 rounded-control border px-2.5 py-2 border-line bg-surface"
1301
+ >
1302
+ <span className="label shrink-0">{flowStep ? "Flow" : "Page"}</span>
1303
+ <span className="mono trunc text-muted" title={destination}>
1304
+ {destination}
1305
+ </span>
1306
+ <Link
1307
+ to={page}
1308
+ className="mono ml-auto inline-flex shrink-0 items-center rounded-control border px-2 py-1 border-line-strong bg-canvas text-accent t-micro transition-colors hover:bg-raised"
1309
+ >
1310
+ {flowStep ? "open flow" : "open"}
1311
+ <span aria-hidden className="ml-1">
1312
+
1313
+ </span>
1314
+ </Link>
1315
+ </nav>
1316
+ );
1317
+ }
1318
+
1211
1319
  export function DetailPanel() {
1212
1320
  const selection = useSelectionStore((s) => s.selection);
1213
1321
  const clear = useSelectionStore((s) => s.clear);
@@ -1225,34 +1333,33 @@ export function DetailPanel() {
1225
1333
  className="panel-in pane flex h-full w-full flex-col border-l border-line bg-canvas"
1226
1334
  aria-label="Selection detail"
1227
1335
  >
1228
- <div className="sticky-bar flex shrink-0 items-center gap-2 border-b px-4 py-2.5 border-line">
1336
+ <div className="sticky-bar sticky top-0 z-10 flex shrink-0 items-center gap-2 border-b px-4 py-2 border-line">
1229
1337
  <span className="label">{kindLabel(selection, resolved)}</span>
1230
1338
  {resolved?.kind === "flow-step" ? (
1231
1339
  <StatusChip status={resolved.step.status} />
1232
1340
  ) : null}
1233
- {/* Right-aligned beside the close: both are about the panel rather
1234
- than about what is in it. */}
1235
- {pin ? (
1236
- <span className="ml-auto flex items-center">
1237
- <PinButton kind={pin.kind} id={pin.id} size={14} />
1238
- </span>
1239
- ) : null}
1240
- <button
1241
- type="button"
1242
- onClick={() => clear("panel")}
1243
- aria-label="Close selection detail (Esc)"
1244
- title="Esc"
1245
- className="rounded-control p-1 text-muted t-micro transition-colors hover:bg-surface hover:text-ink"
1246
- >
1247
- <X size={16} aria-hidden />
1248
- </button>
1341
+ {/* Panel controls own the far edge even when this selection cannot be
1342
+ pinned. The close button used to sit immediately after the status
1343
+ in that case, which made it look attached to the status itself. */}
1344
+ <div className="ml-auto flex shrink-0 items-center gap-1">
1345
+ {pin ? <PinButton kind={pin.kind} id={pin.id} size={14} /> : null}
1346
+ <button
1347
+ type="button"
1348
+ onClick={() => clear("panel")}
1349
+ aria-label="Close selection detail (Esc)"
1350
+ title="Close · Esc"
1351
+ className="inline-flex size-7 items-center justify-center rounded-control border border-transparent text-muted t-micro transition-colors hover:border-line hover:bg-surface hover:text-ink"
1352
+ >
1353
+ <X size={16} aria-hidden />
1354
+ </button>
1355
+ </div>
1249
1356
  </div>
1250
1357
 
1251
1358
  <div className="flex-1 overflow-y-auto p-4">
1252
1359
  <div className="flex flex-wrap items-baseline gap-x-2">
1253
- <span className="mono break-all text-sm text-ink">
1360
+ <h2 className="mono break-all text-sm font-medium text-ink">
1254
1361
  {titleOf(resolved, selection)}
1255
- </span>
1362
+ </h2>
1256
1363
  {resolved?.kind === "event" ||
1257
1364
  resolved?.kind === "service" ||
1258
1365
  resolved?.kind === "aggregate" ? (
@@ -1274,14 +1381,7 @@ export function DetailPanel() {
1274
1381
  <Ident block value={selection.id} className="mt-0.5 text-muted" />
1275
1382
  ) : null}
1276
1383
 
1277
- {page ? (
1278
- <Link
1279
- to={page}
1280
- className="mono mt-3 inline-block rounded-control text-accent hover:underline"
1281
- >
1282
- open page →
1283
- </Link>
1284
- ) : null}
1384
+ <DetailNavigation resolved={resolved} page={page} />
1285
1385
 
1286
1386
  {resolved === null ? (
1287
1387
  <UnknownBody selection={selection} />
@@ -0,0 +1,14 @@
1
+ import { vi } from "vitest";
2
+
3
+ // Browser navigation selects a catalog through `?catalog=`. Node has no URL,
4
+ // so tests that exercise the shipped example estate make that same choice by
5
+ // setting the fixture manifest's default profile explicitly.
6
+ vi.mock("../../portolan.json", async (importOriginal) => {
7
+ const module = await importOriginal<{ default: Record<string, unknown> }>();
8
+ return {
9
+ default: {
10
+ ...module.default,
11
+ defaultCatalog: "example",
12
+ },
13
+ };
14
+ });
package/vite.config.ts CHANGED
@@ -152,7 +152,11 @@ export default defineConfig({
152
152
  __PROJECT_PREVIEW__: JSON.stringify(env.PORTOLAN_PROJECT_PREVIEW === "1"),
153
153
  },
154
154
  plugins: [
155
- localApiPlugin(workspace),
155
+ // Inject the TypeScript projection from the staged Vite config rather than
156
+ // importing it from local-api.mjs. The latter is also loaded directly by
157
+ // `portolan init` from the published package, and Node does not strip
158
+ // TypeScript below node_modules.
159
+ localApiPlugin(workspace, publicSetupFrom),
156
160
  siteDocsPlugin(workspace),
157
161
  react(),
158
162
  tailwindcss(),