@shortlink-org/portolan 0.2.2 → 0.2.3

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 (47) hide show
  1. package/README.md +7 -2
  2. package/cli/portolan.mjs +36 -6
  3. package/package.json +4 -4
  4. package/plugins/portolan-go.wasm +0 -0
  5. package/scripts/builtin-plugins.mjs +3 -2
  6. package/scripts/catalog-sources.mjs +2 -1
  7. package/scripts/catalog-sources.test.mjs +11 -1
  8. package/scripts/delivery-presets.mjs +207 -24
  9. package/scripts/diff.mjs +5 -1
  10. package/scripts/local-api.mjs +26 -377
  11. package/scripts/local-api.test.mjs +50 -0
  12. package/scripts/local-discovery.mjs +378 -0
  13. package/scripts/manifest.mjs +42 -1
  14. package/scripts/manifest.test.mjs +24 -1
  15. package/scripts/run-builtin.mjs +4 -2
  16. package/scripts/schema.mjs +4 -0
  17. package/scripts/site-docs.mjs +2 -2
  18. package/src/app/CatalogApp.tsx +4 -4
  19. package/src/app/Sidebar.tsx +13 -730
  20. package/src/app/SidebarFlowSections.tsx +251 -0
  21. package/src/app/SidebarFooter.tsx +160 -0
  22. package/src/app/SidebarTree.tsx +322 -0
  23. package/src/catalog-index.ts +485 -0
  24. package/src/catalog-model.ts +1339 -0
  25. package/src/catalog-validation.ts +1570 -0
  26. package/src/catalog.test.ts +16 -0
  27. package/src/catalog.ts +6 -3306
  28. package/src/components/{PageHeader.test.ts → PageHeader.test.tsx} +9 -10
  29. package/src/components/ProblemRow.tsx +3 -0
  30. package/src/components/SourcePreview.tsx +1 -1
  31. package/src/index.css +0 -17
  32. package/src/landing/LandingPage.tsx +4 -4
  33. package/src/lib/all-problems.ts +1 -1
  34. package/src/lib/derive.ts +1 -0
  35. package/src/lib/local-api.ts +19 -7
  36. package/src/lib/motion.test.ts +4 -2
  37. package/src/lib/motion.tsx +5 -4
  38. package/src/lib/proto-problems.test.ts +170 -3
  39. package/src/lib/proto-problems.ts +176 -4
  40. package/src/merge.test.ts +46 -0
  41. package/src/merge.ts +35 -1
  42. package/src/pages/Settings.tsx +1 -126
  43. package/src/pages/settings/AboutSettings.tsx +129 -0
  44. package/src/pages/settings/DeliverySettings.tsx +71 -15
  45. package/src/selection/pages.test.ts +14 -1
  46. package/src/selection/pages.ts +9 -3
  47. package/vite.config.ts +3 -1
package/src/merge.test.ts CHANGED
@@ -181,6 +181,52 @@ describe("mergeCatalogs", () => {
181
181
  expect(service?.provides.map((p) => p.id)).toEqual(["auth.v1.Users"]);
182
182
  });
183
183
 
184
+ it("retains and unions vendored proto descriptors across service fragments", () => {
185
+ const first = context("shop", ["shop.oms"]);
186
+ first.services[0]!.copies = [
187
+ {
188
+ id: "pricing.v1.Pricing",
189
+ source: "pricing.proto",
190
+ methods: [{ name: "GetQuote" }],
191
+ enums: [
192
+ {
193
+ name: "Status",
194
+ values: [{ name: "STATUS_UNSPECIFIED", number: 0 }],
195
+ },
196
+ ],
197
+ },
198
+ ];
199
+ const second = context("shop", ["shop.oms"]);
200
+ second.services[0]!.copies = [
201
+ {
202
+ id: "pricing.v1.Pricing",
203
+ source: "pricing.proto",
204
+ methods: [{ name: "WatchPrices" }],
205
+ enums: [
206
+ {
207
+ name: "Status",
208
+ values: [{ name: "STATUS_READY", number: 1 }],
209
+ },
210
+ ],
211
+ },
212
+ ];
213
+
214
+ const merged = mergeCatalogs([
215
+ source("a.json", { contexts: [first] }),
216
+ source("b.json", { contexts: [second] }),
217
+ ]);
218
+ const copy = merged.catalog.contexts[0]?.services[0]?.copies?.[0];
219
+
220
+ expect(copy?.methods.map((method) => method.name)).toEqual([
221
+ "GetQuote",
222
+ "WatchPrices",
223
+ ]);
224
+ expect(copy?.enums?.[0]?.values.map((value) => value.name)).toEqual([
225
+ "STATUS_UNSPECIFIED",
226
+ "STATUS_READY",
227
+ ]);
228
+ });
229
+
184
230
  it("unions a service's commands by the line to type", () => {
185
231
  const domain = context("shop", ["shop.cart"]);
186
232
  domain.services = domain.services.map((s) => ({
package/src/merge.ts CHANGED
@@ -473,8 +473,11 @@ function mergeService(
473
473
  if (!existing) {
474
474
  services.set(incoming.id, {
475
475
  ...incoming,
476
- provides: [...incoming.provides],
476
+ provides: incoming.provides.map(copyInterface),
477
477
  consumes: [...incoming.consumes],
478
+ ...(incoming.copies
479
+ ? { copies: incoming.copies.map(copyInterface) }
480
+ : {}),
478
481
  aggregates: incoming.aggregates.map(copyAggregate),
479
482
  ...(incoming.stores ? { stores: [...incoming.stores] } : {}),
480
483
  ...(incoming.modules ? { modules: [...incoming.modules] } : {}),
@@ -534,6 +537,11 @@ function mergeService(
534
537
 
535
538
  mergeInterfaces(existing.provides, incoming.provides);
536
539
  appendNew(existing.consumes, incoming.consumes, (c) => c.id, raise);
540
+ if (incoming.copies?.length) {
541
+ const copies = existing.copies ?? [];
542
+ mergeInterfaces(copies, incoming.copies);
543
+ existing.copies = copies;
544
+ }
537
545
  mergeAggregates(existing.aggregates, incoming.aggregates);
538
546
 
539
547
  if (incoming.stores?.length) {
@@ -713,6 +721,24 @@ function mergeInterfaces(target: RpcService[], incoming: RpcService[]): void {
713
721
  }
714
722
  }
715
723
  if (messages.length > 0) mine.messages = messages;
724
+
725
+ const enums = mine.enums ?? [];
726
+ for (const item of theirs.enums ?? []) {
727
+ const existing = enums.find((candidate) => candidate.name === item.name);
728
+ if (!existing) {
729
+ enums.push({
730
+ ...item,
731
+ values: item.values.map((value) => ({ ...value })),
732
+ });
733
+ continue;
734
+ }
735
+ appendNew(
736
+ existing.values,
737
+ item.values.map((value) => ({ ...value })),
738
+ (value) => value.name,
739
+ );
740
+ }
741
+ if (enums.length > 0) mine.enums = enums;
716
742
  }
717
743
  }
718
744
 
@@ -736,6 +762,14 @@ function copyInterface(provided: RpcService): RpcService {
736
762
  })),
737
763
  }
738
764
  : {}),
765
+ ...(provided.enums
766
+ ? {
767
+ enums: provided.enums.map((item) => ({
768
+ ...item,
769
+ values: item.values.map((value) => ({ ...value })),
770
+ })),
771
+ }
772
+ : {}),
739
773
  };
740
774
  }
741
775
 
@@ -3,13 +3,10 @@ import { createContext, useCallback, useContext, useEffect, useState } from "rea
3
3
  import { Link, Navigate, NavLink, Route, Routes, useLocation } from "react-router";
4
4
  import {
5
5
  ArrowLeft,
6
- ArrowRight,
7
- BookOpen,
8
6
  Box,
9
7
  Check,
10
8
  ChevronDown,
11
9
  CircleAlert,
12
- CircleDot,
13
10
  FolderGit2,
14
11
  GitBranch,
15
12
  KeyRound,
@@ -21,7 +18,6 @@ import {
21
18
  Trash2,
22
19
  X,
23
20
  } from "lucide-react";
24
- import type { Variants } from "motion/react";
25
21
  import { catalog, catalogSources } from "../data";
26
22
  import { useToastStore } from "../app/toast";
27
23
  import { absoluteTime, plural, relativeTime } from "../lib/format";
@@ -59,15 +55,9 @@ import { Modal } from "../components/Overlay";
59
55
  import { MachineDocs } from "../components/MachineDocs";
60
56
  import { DeliverySettings } from "./settings/DeliverySettings";
61
57
  import { PreferencesSettings } from "./settings/PreferencesSettings";
58
+ import { AboutSettings } from "./settings/AboutSettings";
62
59
  import { CatEmptyState, CatIllustration } from "../components/CatIllustration";
63
60
  import { CommitLink } from "../components/CommitLink";
64
- import { m, transitions } from "../lib/motion";
65
- import {
66
- PRODUCT_ISSUES,
67
- PRODUCT_LICENSE,
68
- PRODUCT_README,
69
- PRODUCT_REPOSITORY,
70
- } from "../lib/product";
71
61
 
72
62
  type Health = "healthy" | "changed" | "failed" | "unchecked";
73
63
  type ProjectSource = ProjectDraft["source"];
@@ -1117,121 +1107,6 @@ function PipelineSettings() {
1117
1107
  );
1118
1108
  }
1119
1109
 
1120
- const ABOUT_CARD_MOTION: Variants = {
1121
- hidden: { opacity: 0, y: 8 },
1122
- shown: {
1123
- opacity: 1,
1124
- y: 0,
1125
- transition: { ...transitions.page, staggerChildren: 0.05 },
1126
- },
1127
- hover: {},
1128
- };
1129
-
1130
- const ABOUT_COPY_MOTION: Variants = {
1131
- hidden: { opacity: 0, y: 6 },
1132
- shown: { opacity: 1, y: 0, transition: transitions.panel },
1133
- };
1134
-
1135
- const ABOUT_CAT_MOTION: Variants = {
1136
- hidden: { opacity: 0, x: 18, rotate: 3 },
1137
- shown: {
1138
- opacity: 1,
1139
- x: 0,
1140
- rotate: 0,
1141
- transition: transitions.narrative,
1142
- },
1143
- hover: {
1144
- y: -7,
1145
- rotate: -2,
1146
- scale: 1.035,
1147
- transition: transitions.settle,
1148
- },
1149
- };
1150
-
1151
- function AboutSettings() {
1152
- return (
1153
- <section>
1154
- <div className="grid gap-grid lg:grid-cols-[minmax(0,1.25fr)_minmax(17rem,0.75fr)]">
1155
- <m.div
1156
- data-about-card
1157
- className="card grid items-center gap-4 sm:grid-cols-[minmax(0,1fr)_180px]"
1158
- variants={ABOUT_CARD_MOTION}
1159
- initial="hidden"
1160
- animate="shown"
1161
- whileHover="hover"
1162
- >
1163
- <m.div
1164
- className="flex flex-col items-start"
1165
- variants={ABOUT_COPY_MOTION}
1166
- >
1167
- <p className="max-w-prose text-muted">
1168
- Architecture from code and evidence. Product source,
1169
- documentation, releases and issue tracking live in the public
1170
- GitHub repository.
1171
- </p>
1172
- <div className="mt-5 flex flex-wrap gap-2">
1173
- <Link to={paths.landing()} className="product-primary">
1174
- Product page <ArrowRight size={15} aria-hidden />
1175
- </Link>
1176
- <a
1177
- href={PRODUCT_REPOSITORY}
1178
- target="_blank"
1179
- rel="noreferrer"
1180
- className="tbtn text-ink"
1181
- >
1182
- <GitBranch size={15} aria-hidden /> View product on GitHub
1183
- </a>
1184
- </div>
1185
- </m.div>
1186
- <m.div
1187
- data-about-cat
1188
- className="h-44 w-full justify-self-center sm:h-52"
1189
- variants={ABOUT_CAT_MOTION}
1190
- >
1191
- <CatIllustration scene="about" className="h-full w-full" />
1192
- </m.div>
1193
- </m.div>
1194
- <m.div
1195
- className="card"
1196
- initial={{ opacity: 0, y: 8 }}
1197
- animate={{ opacity: 1, y: 0 }}
1198
- transition={{ ...transitions.page, delay: 0.06 }}
1199
- >
1200
- <div className="label mb-2">resources</div>
1201
- <div className="divide-y divide-line">
1202
- <a
1203
- href={PRODUCT_README}
1204
- target="_blank"
1205
- rel="noreferrer"
1206
- className="flex items-center gap-2 py-2.5 text-muted transition-colors hover:text-ink"
1207
- >
1208
- <BookOpen size={15} aria-hidden className="shrink-0" />
1209
- Documentation
1210
- </a>
1211
- <a
1212
- href={PRODUCT_ISSUES}
1213
- target="_blank"
1214
- rel="noreferrer"
1215
- className="flex items-center gap-2 py-2.5 text-muted transition-colors hover:text-ink"
1216
- >
1217
- <CircleDot size={15} aria-hidden className="shrink-0" />
1218
- Issues and feedback
1219
- </a>
1220
- <a
1221
- href={PRODUCT_LICENSE}
1222
- target="_blank"
1223
- rel="noreferrer"
1224
- className="flex items-center gap-2 py-2.5 text-muted transition-colors hover:text-ink"
1225
- >
1226
- <ShieldCheck size={15} aria-hidden className="shrink-0" />
1227
- MIT License
1228
- </a>
1229
- </div>
1230
- </m.div>
1231
- </div>
1232
- </section>
1233
- );
1234
- }
1235
1110
 
1236
1111
  function SettingsContent({ local, onAdd, onRemove, onGenerate }: { local: boolean; onAdd: (source: ProjectSource) => void; onRemove: (project: SetupProject) => void; onGenerate: () => void }) {
1237
1112
  const pathname = useLocation().pathname.replace(/\/$/, "");
@@ -0,0 +1,129 @@
1
+ import { ArrowRight, BookOpen, CircleDot, GitBranch, ShieldCheck } from "lucide-react";
2
+ import type { Variants } from "motion/react";
3
+ import { Link } from "react-router";
4
+
5
+ import { CatIllustration } from "../../components/CatIllustration";
6
+ import { m, transitions } from "../../lib/motion";
7
+ import {
8
+ PRODUCT_ISSUES,
9
+ PRODUCT_LICENSE,
10
+ PRODUCT_README,
11
+ PRODUCT_REPOSITORY,
12
+ } from "../../lib/product";
13
+ import { paths } from "../../routes";
14
+
15
+ const ABOUT_CARD_MOTION: Variants = {
16
+ hidden: { opacity: 0, y: 8 },
17
+ shown: {
18
+ opacity: 1,
19
+ y: 0,
20
+ transition: { ...transitions.page, staggerChildren: 0.05 },
21
+ },
22
+ hover: {},
23
+ };
24
+
25
+ const ABOUT_COPY_MOTION: Variants = {
26
+ hidden: { opacity: 0, y: 6 },
27
+ shown: { opacity: 1, y: 0, transition: transitions.panel },
28
+ };
29
+
30
+ const ABOUT_CAT_MOTION: Variants = {
31
+ hidden: { opacity: 0, x: 18, rotate: 3 },
32
+ shown: {
33
+ opacity: 1,
34
+ x: 0,
35
+ rotate: 0,
36
+ transition: transitions.narrative,
37
+ },
38
+ hover: {
39
+ y: -7,
40
+ rotate: -2,
41
+ scale: 1.035,
42
+ transition: transitions.settle,
43
+ },
44
+ };
45
+
46
+ export function AboutSettings() {
47
+ return (
48
+ <section>
49
+ <div className="grid gap-grid lg:grid-cols-[minmax(0,1.25fr)_minmax(17rem,0.75fr)]">
50
+ <m.div
51
+ data-about-card
52
+ className="card grid items-center gap-4 sm:grid-cols-[minmax(0,1fr)_180px]"
53
+ variants={ABOUT_CARD_MOTION}
54
+ initial="hidden"
55
+ animate="shown"
56
+ whileHover="hover"
57
+ >
58
+ <m.div
59
+ className="flex flex-col items-start"
60
+ variants={ABOUT_COPY_MOTION}
61
+ >
62
+ <p className="max-w-prose text-muted">
63
+ Architecture from code and evidence. Product source,
64
+ documentation, releases and issue tracking live in the public
65
+ GitHub repository.
66
+ </p>
67
+ <div className="mt-5 flex flex-wrap gap-2">
68
+ <Link to={paths.landing()} className="product-primary">
69
+ Product page <ArrowRight size={15} aria-hidden />
70
+ </Link>
71
+ <a
72
+ href={PRODUCT_REPOSITORY}
73
+ target="_blank"
74
+ rel="noreferrer"
75
+ className="tbtn text-ink"
76
+ >
77
+ <GitBranch size={15} aria-hidden /> View product on GitHub
78
+ </a>
79
+ </div>
80
+ </m.div>
81
+ <m.div
82
+ data-about-cat
83
+ className="h-44 w-full justify-self-center sm:h-52"
84
+ variants={ABOUT_CAT_MOTION}
85
+ >
86
+ <CatIllustration scene="about" className="h-full w-full" />
87
+ </m.div>
88
+ </m.div>
89
+ <m.div
90
+ className="card"
91
+ initial={{ opacity: 0, y: 8 }}
92
+ animate={{ opacity: 1, y: 0 }}
93
+ transition={{ ...transitions.page, delay: 0.06 }}
94
+ >
95
+ <div className="label mb-2">resources</div>
96
+ <div className="divide-y divide-line">
97
+ <a
98
+ href={PRODUCT_README}
99
+ target="_blank"
100
+ rel="noreferrer"
101
+ className="flex items-center gap-2 py-2.5 text-muted transition-colors hover:text-ink"
102
+ >
103
+ <BookOpen size={15} aria-hidden className="shrink-0" />
104
+ Documentation
105
+ </a>
106
+ <a
107
+ href={PRODUCT_ISSUES}
108
+ target="_blank"
109
+ rel="noreferrer"
110
+ className="flex items-center gap-2 py-2.5 text-muted transition-colors hover:text-ink"
111
+ >
112
+ <CircleDot size={15} aria-hidden className="shrink-0" />
113
+ Issues and feedback
114
+ </a>
115
+ <a
116
+ href={PRODUCT_LICENSE}
117
+ target="_blank"
118
+ rel="noreferrer"
119
+ className="flex items-center gap-2 py-2.5 text-muted transition-colors hover:text-ink"
120
+ >
121
+ <ShieldCheck size={15} aria-hidden className="shrink-0" />
122
+ MIT License
123
+ </a>
124
+ </div>
125
+ </m.div>
126
+ </div>
127
+ </section>
128
+ );
129
+ }
@@ -1,6 +1,6 @@
1
1
  import { useCallback, useEffect, useState } from "react";
2
+ import { Switch } from "@headlessui/react";
2
3
  import {
3
- Check,
4
4
  ChevronDown,
5
5
  CircleAlert,
6
6
  FolderGit2,
@@ -14,6 +14,7 @@ import {
14
14
  previewDeliveryPreset,
15
15
  } from "../../lib/local-api";
16
16
  import type {
17
+ DeliveryFeatureId,
17
18
  DeliveryPreset,
18
19
  DeliveryProvider,
19
20
  } from "../../lib/local-api";
@@ -24,6 +25,7 @@ const STATUS_CLASS: Record<
24
25
  > = {
25
26
  added: "status-verified",
26
27
  changed: "status-declared",
28
+ removed: "status-unresolved",
27
29
  unchanged: "status-verified",
28
30
  conflict: "status-unresolved",
29
31
  };
@@ -35,12 +37,12 @@ export function DeliverySettings({ local }: { local: boolean }) {
35
37
  const [error, setError] = useState("");
36
38
  const say = useToastStore((state) => state.say);
37
39
 
38
- const load = useCallback(async (provider?: DeliveryProvider) => {
40
+ const load = useCallback(async (provider?: DeliveryProvider, features?: DeliveryFeatureId[]) => {
39
41
  if (!local) return;
40
42
  setLoading(true);
41
43
  setError("");
42
44
  try {
43
- setPlan(await previewDeliveryPreset(provider));
45
+ setPlan(await previewDeliveryPreset(provider, features));
44
46
  } catch (cause) {
45
47
  setError(cause instanceof Error ? cause.message : String(cause));
46
48
  } finally {
@@ -60,6 +62,7 @@ export function DeliverySettings({ local }: { local: boolean }) {
60
62
  const installed = await installDeliveryPreset(
61
63
  plan.provider,
62
64
  plan.revision,
65
+ plan.features.filter((feature) => feature.selected).map((feature) => feature.id),
63
66
  );
64
67
  setPlan(installed);
65
68
  say(
@@ -76,6 +79,23 @@ export function DeliverySettings({ local }: { local: boolean }) {
76
79
  }
77
80
  }
78
81
 
82
+ function selectFeature(id: DeliveryFeatureId) {
83
+ if (!plan) return;
84
+ const feature = plan.features.find((item) => item.id === id);
85
+ if (!feature?.available) return;
86
+ const selected = new Set(
87
+ plan.features.filter((item) => item.selected).map((item) => item.id),
88
+ );
89
+ if (selected.has(id)) {
90
+ selected.delete(id);
91
+ if (id === "diff") selected.delete("sarif");
92
+ } else {
93
+ selected.add(id);
94
+ for (const required of feature.requires) selected.add(required);
95
+ }
96
+ void load(plan.provider, [...selected]);
97
+ }
98
+
79
99
  if (!local) {
80
100
  return (
81
101
  <div className="empty">
@@ -96,8 +116,8 @@ export function DeliverySettings({ local }: { local: boolean }) {
96
116
  <FolderGit2 size={16} className="text-accent" /> Delivery automation
97
117
  </div>
98
118
  <p className="mt-1 max-w-prose text-muted">
99
- Install review checks and static catalog publishing directly into
100
- this repository.
119
+ Choose the automation this repository needs, preview the generated
120
+ jobs, then install them together.
101
121
  </p>
102
122
  </div>
103
123
  <div
@@ -111,7 +131,12 @@ export function DeliverySettings({ local }: { local: boolean }) {
111
131
  type="button"
112
132
  disabled={loading || installing}
113
133
  aria-pressed={plan?.provider === provider}
114
- onClick={() => void load(provider)}
134
+ onClick={() => void load(
135
+ provider,
136
+ plan?.features
137
+ .filter((feature) => feature.selected && (feature.id !== "sarif" || provider === "github"))
138
+ .map((feature) => feature.id),
139
+ )}
115
140
  className={`capitalize ${plan?.provider === provider ? "is-on" : ""}`}
116
141
  >
117
142
  {provider === "github" ? "GitHub" : "GitLab"}
@@ -157,15 +182,46 @@ export function DeliverySettings({ local }: { local: boolean }) {
157
182
  )}
158
183
  </div>
159
184
 
160
- <ul className="mt-4 grid gap-2 text-muted sm:grid-cols-2">
185
+ <div className="mt-4 grid gap-2 sm:grid-cols-2">
161
186
  {plan.features.map((feature) => (
162
- <li key={feature} className="flex items-center gap-2">
163
- <Check size={14} className="shrink-0 text-verified" /> {feature}
164
- </li>
187
+ <div
188
+ key={feature.id}
189
+ className={`flex items-start gap-3 rounded-control border px-3 py-3 transition-colors ${
190
+ feature.available
191
+ ? feature.selected
192
+ ? "border-accent bg-surface"
193
+ : "border-line hover:bg-surface"
194
+ : "border-line opacity-60"
195
+ }`}
196
+ >
197
+ <span className="min-w-0 flex-1">
198
+ <span className="block font-medium text-ink">{feature.label}</span>
199
+ <span className="mt-0.5 block text-muted">{feature.description}</span>
200
+ {!feature.available ? (
201
+ <span className="mt-1 block text-faint">Available for GitHub only.</span>
202
+ ) : feature.requires.length > 0 ? (
203
+ <span className="mt-1 block text-faint">
204
+ Enables {feature.requires.map((required) => plan.features.find((item) => item.id === required)?.label).join(", ")}.
205
+ </span>
206
+ ) : null}
207
+ </span>
208
+ <Switch
209
+ checked={feature.selected}
210
+ disabled={!feature.available || loading || installing}
211
+ onChange={() => selectFeature(feature.id)}
212
+ aria-label={feature.label}
213
+ className="group mt-0.5 inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-line-strong bg-surface transition-colors outline-none data-checked:border-accent data-checked:bg-accent focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed"
214
+ >
215
+ <span
216
+ aria-hidden
217
+ className="size-3.5 translate-x-0.5 rounded-full bg-muted shadow-xs transition-transform group-data-checked:translate-x-[18px] group-data-checked:bg-canvas"
218
+ />
219
+ </Switch>
220
+ </div>
165
221
  ))}
166
- </ul>
222
+ </div>
167
223
 
168
- <div className="mt-4 divide-y divide-line overflow-hidden rounded-control border border-line">
224
+ {plan.files.length > 0 ? <div className="mt-4 divide-y divide-line overflow-hidden rounded-control border border-line">
169
225
  {plan.files.map((file) => (
170
226
  <details
171
227
  key={file.path}
@@ -194,7 +250,7 @@ export function DeliverySettings({ local }: { local: boolean }) {
194
250
  </div>
195
251
  </details>
196
252
  ))}
197
- </div>
253
+ </div> : <div className="empty mt-4">No delivery jobs selected.</div>}
198
254
 
199
255
  {error ? <p className="mt-3 text-unresolved">{error}</p> : null}
200
256
  <div className="mt-4 flex flex-wrap items-center justify-between gap-3">
@@ -204,12 +260,12 @@ export function DeliverySettings({ local }: { local: boolean }) {
204
260
  </p>
205
261
  {plan.status === "installed" ? (
206
262
  <span className="flex items-center gap-1.5 text-verified">
207
- <ShieldCheck size={15} /> Preset installed
263
+ <ShieldCheck size={15} /> Selection installed
208
264
  </span>
209
265
  ) : (
210
266
  <button
211
267
  type="button"
212
- className="btn-accent"
268
+ className="product-primary"
213
269
  disabled={installing || conflicts.length > 0}
214
270
  onClick={() => void install()}
215
271
  >
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { pageContains, selectionPath } from "./pages";
2
+ import { pageContains, selectionPath, selectsInPlace } from "./pages";
3
3
  import { selectionFor } from "./model";
4
4
 
5
5
  const dispatched = selectionFor("delivery.core.shipment.ShipmentDispatched");
@@ -44,6 +44,19 @@ describe("selectionPath", () => {
44
44
  });
45
45
  });
46
46
 
47
+ describe("selectsInPlace", () => {
48
+ const flow = "/flows/cart-checkout";
49
+ const shop = selectionFor("shop");
50
+
51
+ it("selects a flow context on the flow the first time", () => {
52
+ expect(selectsInPlace(flow, shop, null)).toBe(true);
53
+ });
54
+
55
+ it("lets a repeated selection follow its link to the context page", () => {
56
+ expect(selectsInPlace(flow, shop, shop)).toBe(false);
57
+ });
58
+ });
59
+
47
60
  // Navigation keeps a selection only when the page it lands on can point at it.
48
61
  // These are the cases that decide whether the palette moves the reader.
49
62
  describe("pageContains across pages", () => {
@@ -9,7 +9,7 @@ import { walkSteps } from "../catalog";
9
9
  import { catalog, index } from "../data";
10
10
  import { paths } from "../routes";
11
11
  import type { Selection } from "./model";
12
- import { parseFlowStepId, resolveSelection } from "./model";
12
+ import { parseFlowStepId, resolveSelection, sameSelection } from "./model";
13
13
 
14
14
  /** The page that owns a selection, or null when it has no page of its own. */
15
15
  export function selectionPath(selection: Selection): string | null {
@@ -257,12 +257,18 @@ export function pageContains(pathname: string, selection: Selection): boolean {
257
257
  * and only for something that flow actually draws. That is the one place where
258
258
  * leaving is the wrong answer — picking an event there is a question about
259
259
  * *this* sequence ("where does it appear?"), and the flow page answers it by
260
- * lighting the matching steps.
260
+ * lighting the matching steps. Clicking the highlighted selection again is an
261
+ * explicit request to follow the tree link to its own page.
261
262
  */
262
263
  export function selectsInPlace(
263
264
  pathname: string,
264
265
  selection: Selection,
266
+ current: Selection | null,
265
267
  ): boolean {
266
268
  const route = parseRoute(pathname);
267
- return route.kind === "flow" && pageContains(pathname, selection);
269
+ return (
270
+ !sameSelection(current, selection) &&
271
+ route.kind === "flow" &&
272
+ pageContains(pathname, selection)
273
+ );
268
274
  }
package/vite.config.ts CHANGED
@@ -7,6 +7,8 @@ import react from "@vitejs/plugin-react";
7
7
  import tailwindcss from "@tailwindcss/vite";
8
8
  import { nodePolyfills } from "vite-plugin-node-polyfills";
9
9
  import { publicSetupFrom } from "./src/lib/setup-info.ts";
10
+ // @ts-expect-error plain JavaScript module intentionally has no browser types
11
+ import { readManifest } from "./scripts/manifest.mjs";
10
12
  // The local control plane is a Node-only Vite plugin kept as plain ESM so its
11
13
  // pure discovery functions can also be tested directly.
12
14
  // @ts-expect-error plain JavaScript module intentionally has no browser types
@@ -121,7 +123,7 @@ try {
121
123
  // A clean checkout has no run to report yet. Settings says so explicitly.
122
124
  }
123
125
  const setupInfo = publicSetupFrom(
124
- JSON.parse(manifestText) as unknown,
126
+ readManifest(resolve(workspace, "portolan.json")) as unknown,
125
127
  buildReport,
126
128
  createHash("sha256").update(manifestText).digest("hex"),
127
129
  );