@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
@@ -9,14 +9,33 @@
9
9
  // queryset - which no interface in the catalog describes; an event is a
10
10
  // publication, and drawing a reply to it would be a lie about the bus.
11
11
 
12
- import type { CatalogIndex, Flow, RpcMethod, Step } from "../catalog";
12
+ import type {
13
+ CatalogIndex,
14
+ External,
15
+ Flow,
16
+ RpcMethod,
17
+ RpcService,
18
+ Service,
19
+ Step,
20
+ } from "../catalog";
13
21
  import { walkSteps } from "../catalog";
14
22
 
15
- /** The method a step reaches, when the catalog has it. */
16
- function methodOf(index: CatalogIndex, step: Step): RpcMethod | undefined {
23
+ export interface StepRpcContract {
24
+ id: string;
25
+ provider: Service | External;
26
+ provided: RpcService;
27
+ method: RpcMethod;
28
+ }
29
+
30
+ /** The interface and method an RPC step reaches, when the catalog has them. */
31
+ export function stepRpcContract(
32
+ index: CatalogIndex,
33
+ step: Step,
34
+ ): StepRpcContract | undefined {
17
35
  if (step.kind !== "rpc") return undefined;
18
36
 
19
- // Outgoing: the step names the call, and the call id is `<interface>/<method>`.
37
+ // A recorded ref is the full `<interface>/<method>` id, whether the flow
38
+ // enters this service or calls another one.
20
39
  if (step.ref) {
21
40
  const cut = step.ref.lastIndexOf("/");
22
41
  if (cut < 0) return undefined;
@@ -26,22 +45,32 @@ function methodOf(index: CatalogIndex, step: Step): RpcMethod | undefined {
26
45
  const provider =
27
46
  index.rpcProviderByMethod.get(step.ref) ?? index.externalProviderByMethod.get(step.ref);
28
47
  const provided = provider?.provides.find((p) => p.id === interfaceId);
29
- return provided?.methods.find((m) => m.name === name);
48
+ const method = provided?.methods.find((m) => m.name === name);
49
+ if (!provider || !provided || !method) return undefined;
50
+ return { id: step.ref, provider, provided, method };
30
51
  }
31
52
 
32
- // Incoming: somebody called this service, and the label is the operation.
53
+ // Older incoming flows recorded only the operation label. Keep resolving
54
+ // those catalogs while new extractors write the full ref above.
33
55
  const service = index.serviceById.get(step.to);
34
56
  if (!service || !step.label) return undefined;
35
57
  for (const provided of service.provides) {
36
58
  const found = provided.methods.find((m) => m.name === step.label);
37
- if (found) return found;
59
+ if (found) {
60
+ return {
61
+ id: `${provided.id}/${found.name}`,
62
+ provider: service,
63
+ provided,
64
+ method: found,
65
+ };
66
+ }
38
67
  }
39
68
  return undefined;
40
69
  }
41
70
 
42
71
  /** What the callee hands back, as the contract names it. */
43
72
  export function stepAnswer(index: CatalogIndex, step: Step): string | undefined {
44
- return methodOf(index, step)?.response || undefined;
73
+ return stepRpcContract(index, step)?.method.response || undefined;
45
74
  }
46
75
 
47
76
  /** Every request without an explicit response step that has an answer. */
package/src/index.css CHANGED
@@ -573,22 +573,6 @@
573
573
  background: color-mix(in srgb, var(--accent) 10%, transparent);
574
574
  }
575
575
 
576
- /* Sanctioned gradient 2 - the accent surface. Reserved for a primary button;
577
- white on --accent-lo clears 4.5:1 in both themes. */
578
- .btn-accent {
579
- @apply mono flex items-center gap-1.5 rounded-control px-3 py-1.5;
580
- color: #fff;
581
- background-image: linear-gradient(
582
- to bottom,
583
- var(--accent-hi),
584
- var(--accent-lo)
585
- );
586
- transition: filter var(--dur-micro) var(--ease-out);
587
- }
588
- .btn-accent:hover {
589
- filter: brightness(1.06);
590
- }
591
-
592
576
  /* The landing's primary action should feel like a control from the map,
593
577
  not a bright rectangle pasted over it. The depth is always present; the
594
578
  brief chart-line sweep only appears when a reader points at the action. */
@@ -697,7 +681,6 @@
697
681
  outline: 2px solid color-mix(in srgb, var(--accent) 55%, white);
698
682
  outline-offset: 3px;
699
683
  }
700
- .btn-accent:disabled,
701
684
  .product-primary:disabled,
702
685
  .tbtn:disabled {
703
686
  cursor: not-allowed;
@@ -97,7 +97,7 @@ function Header() {
97
97
  >
98
98
  <GitBranch size={15} /> GitHub
99
99
  </a>
100
- <Link to={exampleTo} className="btn-accent landing-primary">
100
+ <Link to={exampleTo} className="landing-primary">
101
101
  Explore example <ArrowRight size={14} />
102
102
  </Link>
103
103
  </div>
@@ -319,7 +319,7 @@ export function LandingPage() {
319
319
  >
320
320
  <Link
321
321
  to={exampleTo}
322
- className="btn-accent landing-primary px-4 py-2.5"
322
+ className="landing-primary px-4 py-2.5"
323
323
  >
324
324
  Explore example catalog <ArrowRight size={15} />
325
325
  </Link>
@@ -647,7 +647,7 @@ export function LandingPage() {
647
647
  <div className="mt-4 flex flex-wrap gap-3">
648
648
  <Link
649
649
  to={exampleTo}
650
- className="btn-accent landing-primary px-4 py-2.5"
650
+ className="landing-primary px-4 py-2.5"
651
651
  >
652
652
  Open the example <ArrowRight size={15} />
653
653
  </Link>
@@ -719,7 +719,7 @@ export function LandingPage() {
719
719
  <div className="mt-8 flex flex-wrap justify-center gap-3">
720
720
  <Link
721
721
  to={exampleTo}
722
- className="btn-accent landing-primary px-4 py-2.5"
722
+ className="landing-primary px-4 py-2.5"
723
723
  >
724
724
  Explore example catalog <ArrowRight size={15} />
725
725
  </Link>
@@ -1,7 +1,7 @@
1
1
  // Every reading the catalog makes of itself, in one list.
2
2
  //
3
3
  // Four readers, each answering a different question: edges that resolve to
4
- // nothing, schemas that name a method the module lacks, tables and columns
4
+ // nothing, producer and consumer schemas that disagree, tables and columns
5
5
  // that disagree with the model, and channels that documents and events do not
6
6
  // agree on. The problems page, the overview and the landing all want the
7
7
  // union, errors first - a boundary leak is not the same kind of news as a
@@ -1,6 +1,11 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { catalog, index } from "../testing/estate";
3
- import { backlinkCount, backlinksFor, stepsInto } from "./backlinks";
3
+ import {
4
+ backlinkCount,
5
+ backlinksFor,
6
+ distinctBacklinks,
7
+ stepsInto,
8
+ } from "./backlinks";
4
9
  import type { BacklinkGroup, BacklinkTarget } from "./backlinks";
5
10
  import type { Kind } from "./kinds";
6
11
 
@@ -290,6 +295,16 @@ describe("grouping", () => {
290
295
  const g = groups({ kind: "service", id: "shop.oms" });
291
296
  expect(backlinkCount(g)).toBe(g.reduce((n, x) => n + x.links.length, 0));
292
297
  });
298
+
299
+ it("collapses repeated entities for compact backlink lines", () => {
300
+ const links = distinctBacklinks(
301
+ groups({ kind: "store", id: "shop.oms.pg" }),
302
+ );
303
+ const ids = links.map((link) => `${link.kind}:${link.id}`);
304
+
305
+ expect(ids).toEqual([...new Set(ids)]);
306
+ expect(ids).toContain("aggregate:shop.oms.order");
307
+ });
293
308
  });
294
309
 
295
310
  describe("the word that names it", () => {
@@ -100,6 +100,26 @@ export function backlinkCount(groups: readonly BacklinkGroup[]): number {
100
100
  return groups.reduce((n, g) => n + g.links.length, 0);
101
101
  }
102
102
 
103
+ /**
104
+ * One link per entity for compact surfaces that cannot show each link's
105
+ * reason. The full section deliberately keeps separate edges (for example an
106
+ * aggregate persisted by two tables), but two identical chips would only look
107
+ * like an accidental duplicate when `via` is not visible.
108
+ */
109
+ export function distinctBacklinks(
110
+ groups: readonly BacklinkGroup[],
111
+ ): Backlink[] {
112
+ const seen = new Set<string>();
113
+ return groups.flatMap((group) =>
114
+ group.links.filter((link) => {
115
+ const key = `${link.kind}:${link.id}`;
116
+ if (seen.has(key)) return false;
117
+ seen.add(key);
118
+ return true;
119
+ }),
120
+ );
121
+ }
122
+
103
123
  // ---------------------------------------------------------------------------
104
124
  // Row builders. One per kind of thing that can do the pointing.
105
125
  // ---------------------------------------------------------------------------
@@ -57,6 +57,24 @@ describe("diffCatalogs", () => {
57
57
  });
58
58
 
59
59
  describe("diffCatalogs: what a reviewer is looking for", () => {
60
+ it("calls a change between known message encodings breaking", () => {
61
+ const before = JSON.parse(JSON.stringify(catalog)) as Catalog;
62
+ const service = before.contexts[0]!.services[0]!;
63
+ service.channels = [{
64
+ address: "shop.cart.basket",
65
+ messages: [{ name: "cart.BasketCreated", direction: "send", encoding: "json" }],
66
+ }];
67
+ const after = JSON.parse(JSON.stringify(before)) as Catalog;
68
+ after.contexts[0]!.services[0]!.channels![0]!.messages[0]!.encoding = "msgpack";
69
+
70
+ expect(diffCatalogs(before, after)).toEqual([{
71
+ kind: "message.encoding",
72
+ severity: "breaking",
73
+ where: service.id,
74
+ summary: "send message cart.BasketCreated on shop.cart.basket uses msgpack, was json",
75
+ }]);
76
+ });
77
+
60
78
  // The finding the whole report exists for.
61
79
  it("names a new event nothing consumes", () => {
62
80
  const changes = edited((c) => {
@@ -331,7 +331,7 @@ function diffCalls(before: Service, after: Service, add: Add): void {
331
331
  function diffChannels(before: Service, after: Service, add: Add): void {
332
332
  const was = byId(before.channels ?? [], (c) => c.address);
333
333
  const now = byId(after.channels ?? [], (c) => c.address);
334
- const { added, removed } = partition(was, now);
334
+ const { added, removed, kept } = partition(was, now);
335
335
 
336
336
  for (const address of added) {
337
337
  add("channel.added", "addition", before.id, `"${before.id}" declares channel ${address}`);
@@ -339,6 +339,24 @@ function diffChannels(before: Service, after: Service, add: Add): void {
339
339
  for (const address of removed) {
340
340
  add("channel.removed", "breaking", before.id, `"${before.id}" no longer declares channel ${address}`);
341
341
  }
342
+ for (const address of kept) {
343
+ const previous = byId(was.get(address)!.messages, (message) => `${message.direction} ${message.name}`);
344
+ const current = byId(now.get(address)!.messages, (message) => `${message.direction} ${message.name}`);
345
+ const messages = partition(previous, current).kept;
346
+ for (const id of messages) {
347
+ const a = previous.get(id)!;
348
+ const b = current.get(id)!;
349
+ const from = a.encoding || a.contentType || "";
350
+ const to = b.encoding || b.contentType || "";
351
+ if (from === to) continue;
352
+ add(
353
+ "message.encoding",
354
+ from && to ? "breaking" : "change",
355
+ before.id,
356
+ `${a.direction} message ${a.name} on ${address} uses ${to || "an unspecified encoding"}, was ${from || "unspecified"}`,
357
+ );
358
+ }
359
+ }
342
360
  }
343
361
 
344
362
  function diffServiceStores(before: Service, after: Service, add: Add): void {
package/src/lib/derive.ts CHANGED
@@ -420,9 +420,11 @@ export type ProblemKind =
420
420
  | "column-type"
421
421
  | "outbox-payload"
422
422
  | "proto-missing"
423
+ | "proto-drift"
423
424
  | "shared-channel"
424
425
  | "channel-undeclared"
425
426
  | "channel-unpublished"
427
+ | "message-encoding"
426
428
  | "subscription-unresolved";
427
429
 
428
430
  /**
@@ -0,0 +1,87 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { Catalog, Channel } from "../catalog";
3
+ import {
4
+ isKafkaChannel,
5
+ kafkaHandoffChannels,
6
+ kafkaUiTopicUrl,
7
+ normalizeKafkaUiUrl,
8
+ } from "./kafka-ui";
9
+
10
+ describe("Kafka UI integration", () => {
11
+ it("accepts web URLs and rejects values that cannot be opened safely", () => {
12
+ expect(normalizeKafkaUiUrl(" https://kafka.example/ ")).toBe(
13
+ "https://kafka.example",
14
+ );
15
+ expect(normalizeKafkaUiUrl("javascript:alert(1)")).toBeNull();
16
+ expect(normalizeKafkaUiUrl("not a URL")).toBeNull();
17
+ expect(normalizeKafkaUiUrl(" ")).toBe("");
18
+ });
19
+
20
+ it("opens the exact topic from a Kafbat cluster URL behind a path prefix", () => {
21
+ expect(
22
+ kafkaUiTopicUrl(
23
+ "https://ops.example/kafka/ui/clusters/prod/all-topics/old",
24
+ "orders/new",
25
+ ),
26
+ ).toBe(
27
+ "https://ops.example/kafka/ui/clusters/prod/all-topics/orders%2Fnew",
28
+ );
29
+ });
30
+
31
+ it("keeps a plain installation URL as the useful fallback", () => {
32
+ expect(kafkaUiTopicUrl("https://kafka.example", "orders.created")).toBe(
33
+ "https://kafka.example",
34
+ );
35
+ });
36
+
37
+ it("recognizes both Kafka-labelled cards and explicit Kafka handoffs", () => {
38
+ const labelled: Channel = {
39
+ address: "orders.created",
40
+ title: "Kafka · orders.created",
41
+ messages: [],
42
+ };
43
+ const flowOnly: Channel = {
44
+ address: "payments.accepted",
45
+ title: "message stream",
46
+ messages: [],
47
+ };
48
+ const nats: Channel = {
49
+ address: "shop.cart",
50
+ title: "JetStream subject",
51
+ messages: [],
52
+ };
53
+ const catalog = {
54
+ flows: [
55
+ {
56
+ id: "payment-kafka",
57
+ slug: "payment-kafka",
58
+ name: "Payment Kafka",
59
+ summary: "",
60
+ owner: "payments",
61
+ participants: [],
62
+ steps: [
63
+ {
64
+ type: "step",
65
+ id: "publish",
66
+ from: "payments",
67
+ to: "broker",
68
+ kind: "event",
69
+ status: "verified",
70
+ handoff: {
71
+ kind: "message",
72
+ transport: "kafka",
73
+ channel: "payments.accepted",
74
+ direction: "send",
75
+ },
76
+ },
77
+ ],
78
+ },
79
+ ],
80
+ } as Pick<Catalog, "flows">;
81
+ const handoffs = kafkaHandoffChannels(catalog);
82
+
83
+ expect(isKafkaChannel(labelled, handoffs)).toBe(true);
84
+ expect(isKafkaChannel(flowOnly, handoffs)).toBe(true);
85
+ expect(isKafkaChannel(nats, handoffs)).toBe(false);
86
+ });
87
+ });
@@ -0,0 +1,105 @@
1
+ // The optional hand-off from a catalogued Kafka topic to the operational UI.
2
+ //
3
+ // The configured value belongs to the reader, not to the catalog: two readers
4
+ // can use different Kafka UI installations for the same generated estate. It
5
+ // therefore lives in localStorage, like the editor and display preferences.
6
+ // A cluster URL gives us enough information to open the exact topic; a plain
7
+ // installation URL remains useful and opens Kafka UI without inventing a
8
+ // cluster name.
9
+
10
+ import { create } from "zustand";
11
+ import type { Catalog, Channel } from "../catalog";
12
+ import { walkSteps } from "../catalog";
13
+
14
+ export const KAFKA_UI_KEY = "portolan.integrations.kafka-ui";
15
+
16
+ export function normalizeKafkaUiUrl(value: string): string | null {
17
+ const clean = value.trim();
18
+ if (!clean) return "";
19
+ try {
20
+ const url = new URL(clean);
21
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
22
+ url.hash = "";
23
+ return url.toString().replace(/\/$/, "");
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ /** Build Kafbat's topic route when the configured URL names a cluster. */
30
+ export function kafkaUiTopicUrl(
31
+ configured: string,
32
+ topic: string,
33
+ ): string | null {
34
+ const normalized = normalizeKafkaUiUrl(configured);
35
+ if (!normalized) return null;
36
+ const url = new URL(normalized);
37
+ const match = url.pathname.match(
38
+ /^(.*\/ui\/clusters\/[^/]+)(?:\/.*)?$/,
39
+ );
40
+ if (!match?.[1]) return normalized;
41
+ url.pathname = `${match[1]}/all-topics/${encodeURIComponent(topic)}`;
42
+ url.search = "";
43
+ url.hash = "";
44
+ return url.toString();
45
+ }
46
+
47
+ /** Topic addresses whose source-backed flow explicitly says Kafka. */
48
+ export function kafkaHandoffChannels(catalog: Pick<Catalog, "flows">): Set<string> {
49
+ const channels = new Set<string>();
50
+ for (const flow of catalog.flows) {
51
+ for (const step of walkSteps(flow.steps)) {
52
+ if (step.handoff?.transport.toLowerCase() === "kafka") {
53
+ channels.add(step.handoff.channel);
54
+ }
55
+ }
56
+ }
57
+ return channels;
58
+ }
59
+
60
+ /**
61
+ * A channel is Kafka only when an extractor said so in its presentation facts
62
+ * or a matching flow carries explicit Kafka transport evidence. Channel itself
63
+ * predates the transport field, so address alone is deliberately insufficient.
64
+ */
65
+ export function isKafkaChannel(
66
+ channel: Pick<Channel, "address" | "title" | "doc">,
67
+ handoffChannels: ReadonlySet<string>,
68
+ ): boolean {
69
+ const description = `${channel.title ?? ""} ${channel.doc ?? ""}`;
70
+ return /(^|[^a-z0-9])kafka([^a-z0-9]|$)/i.test(description)
71
+ || handoffChannels.has(channel.address);
72
+ }
73
+
74
+ function read(): string {
75
+ try {
76
+ const value = localStorage.getItem(KAFKA_UI_KEY) ?? "";
77
+ return normalizeKafkaUiUrl(value) ?? "";
78
+ } catch {
79
+ return "";
80
+ }
81
+ }
82
+
83
+ function write(value: string): void {
84
+ try {
85
+ if (value) localStorage.setItem(KAFKA_UI_KEY, value);
86
+ else localStorage.removeItem(KAFKA_UI_KEY);
87
+ } catch {
88
+ /* private mode: keep the value for this session */
89
+ }
90
+ }
91
+
92
+ interface KafkaUiState {
93
+ url: string;
94
+ setUrl: (url: string) => void;
95
+ }
96
+
97
+ export const useKafkaUi = create<KafkaUiState>()((set) => ({
98
+ url: read(),
99
+ setUrl: (url) => {
100
+ const normalized = normalizeKafkaUiUrl(url);
101
+ if (normalized === null) return;
102
+ write(normalized);
103
+ set({ url: normalized });
104
+ },
105
+ }));
@@ -70,10 +70,20 @@ export interface GeneratedFileDiff {
70
70
  }
71
71
 
72
72
  export type DeliveryProvider = "github" | "gitlab";
73
+ export type DeliveryFeatureId = "check" | "diff" | "sarif" | "pages";
74
+
75
+ export interface DeliveryFeature {
76
+ id: DeliveryFeatureId;
77
+ label: string;
78
+ description: string;
79
+ selected: boolean;
80
+ available: boolean;
81
+ requires: DeliveryFeatureId[];
82
+ }
73
83
 
74
84
  export interface DeliveryPresetFile {
75
85
  path: string;
76
- status: "added" | "changed" | "unchanged" | "conflict";
86
+ status: "added" | "changed" | "removed" | "unchanged" | "conflict";
77
87
  diff: string;
78
88
  message?: string;
79
89
  }
@@ -86,7 +96,7 @@ export interface DeliveryPreset {
86
96
  defaultBranch: string;
87
97
  status: "available" | "installed" | "update" | "conflict";
88
98
  revision: string;
89
- features: string[];
99
+ features: DeliveryFeature[];
90
100
  files: DeliveryPresetFile[];
91
101
  }
92
102
 
@@ -168,16 +178,18 @@ export async function localStatus(): Promise<{ local: true; workspace: string; s
168
178
  return json("/status");
169
179
  }
170
180
 
171
- export async function previewDeliveryPreset(provider?: DeliveryProvider): Promise<DeliveryPreset> {
172
- const query = provider ? `?provider=${encodeURIComponent(provider)}` : "";
173
- return json(`/delivery-presets${query}`);
181
+ export async function previewDeliveryPreset(provider?: DeliveryProvider, features?: DeliveryFeatureId[]): Promise<DeliveryPreset> {
182
+ const query = new URLSearchParams();
183
+ if (provider) query.set("provider", provider);
184
+ if (features) query.set("features", features.join(","));
185
+ return json(`/delivery-presets${query.size ? `?${query}` : ""}`);
174
186
  }
175
187
 
176
- export async function installDeliveryPreset(provider: DeliveryProvider, revision: string): Promise<DeliveryPreset & { written: string[] }> {
188
+ export async function installDeliveryPreset(provider: DeliveryProvider, revision: string, features: DeliveryFeatureId[]): Promise<DeliveryPreset & { written: string[] }> {
177
189
  return json("/delivery-presets/install", {
178
190
  method: "POST",
179
191
  headers: LOCAL_HEADER,
180
- body: JSON.stringify({ provider, revision }),
192
+ body: JSON.stringify({ provider, revision, features }),
181
193
  });
182
194
  }
183
195
 
@@ -102,10 +102,12 @@ describe("presence", () => {
102
102
  expect(rise.initial.y).toBe(8);
103
103
  });
104
104
 
105
- it("a page arrives on the page duration and leaves faster, opacity only", () => {
105
+ it("a page rises in and lifts out faster", () => {
106
106
  expect(page.animate.transition).toBe(transitions.page);
107
107
  expect(page.exit.transition).toBe(transitions.micro);
108
- expect(Object.keys(page.exit)).toEqual(["opacity", "transition"]);
108
+ expect(page.initial.y).toBe(8);
109
+ expect(page.animate.y).toBe(0);
110
+ expect(page.exit.y).toBe(-4);
109
111
  });
110
112
 
111
113
  it("scaleIn grows the two percent palette-in grows", () => {
@@ -91,14 +91,15 @@ export const scaleIn: Presence = {
91
91
  };
92
92
 
93
93
  /**
94
- * A route: rises the eight pixels on the page duration, and leaves on the
95
- * micro duration, opacity only. The reader has already asked for the next
96
- * page; the old one should be out of the way, not making an exit.
94
+ * A route: rises the eight pixels on the page duration while the old page
95
+ * lifts four pixels and fades on the micro duration. Route presence uses
96
+ * `popLayout`, so those two short movements overlap instead of inserting a
97
+ * blank beat between pages.
97
98
  */
98
99
  export const page: Presence = {
99
100
  initial: { opacity: 0, y: 8 },
100
101
  animate: { opacity: 1, y: 0, transition: transitions.page },
101
- exit: { opacity: 0, transition: transitions.micro },
102
+ exit: { opacity: 0, y: -4, transition: transitions.micro },
102
103
  };
103
104
 
104
105
  /** A row, a toast: rises the same eight pixels a page rises. */