@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
@@ -3,10 +3,9 @@
3
3
  // id across, so the two are paired by position.
4
4
  //
5
5
  // That is sound because it is the same walk twice: the generator emits steps in
6
- // the order walkSteps visits them a step, then each branch of a parallel,
7
- // then each branch of an alt, then the body of a loop and the view keeps that
8
- // order in its edge list. If the two lists ever differ in length the pairing is
9
- // abandoned rather than guessed at, and the diagram simply stops highlighting.
6
+ // catalog order and this module inserts the same contract-response edges in the
7
+ // same places. If the two lists ever differ in length the pairing is abandoned
8
+ // rather than guessed at, and the diagram simply stops highlighting.
10
9
 
11
10
  import type { Flow } from "../catalog";
12
11
  import { walkSteps } from "../catalog";
@@ -15,13 +14,16 @@ import { hiddenStepIds } from "../flow/cross-context";
15
14
  export interface EdgeStepPairing {
16
15
  /** LikeC4 edge id -> catalog step id */
17
16
  stepOf: Map<string, string>;
18
- /** catalog step id -> LikeC4 edge id */
17
+ /** catalog step id -> its primary LikeC4 edge id */
19
18
  edgeOf: Map<string, string>;
19
+ /** catalog step id -> every edge it draws, including a contract response */
20
+ edgesOf: Map<string, string[]>;
20
21
  }
21
22
 
22
23
  export const EMPTY_PAIRING: EdgeStepPairing = {
23
24
  stepOf: new Map(),
24
25
  edgeOf: new Map(),
26
+ edgesOf: new Map(),
25
27
  };
26
28
 
27
29
  export function pairEdgesToSteps(
@@ -31,13 +33,17 @@ export function pairEdgesToSteps(
31
33
  if (edgeIds.length !== stepIds.length) return EMPTY_PAIRING;
32
34
  const stepOf = new Map<string, string>();
33
35
  const edgeOf = new Map<string, string>();
36
+ const edgesOf = new Map<string, string[]>();
34
37
  edgeIds.forEach((edgeId, i) => {
35
38
  const stepId = stepIds[i];
36
39
  if (stepId === undefined) return;
37
40
  stepOf.set(edgeId, stepId);
38
- edgeOf.set(stepId, edgeId);
41
+ if (!edgeOf.has(stepId)) edgeOf.set(stepId, edgeId);
42
+ const edges = edgesOf.get(stepId) ?? [];
43
+ edges.push(edgeId);
44
+ edgesOf.set(stepId, edges);
39
45
  });
40
- return { stepOf, edgeOf };
46
+ return { stepOf, edgeOf, edgesOf };
41
47
  }
42
48
 
43
49
  /**
@@ -87,3 +93,33 @@ export function drawnStepIds(flow: Flow, crossOnly: boolean): string[] {
87
93
  const hidden = hiddenStepIds(flow);
88
94
  return steps.filter((s) => !hidden.has(s.id)).map((s) => s.id);
89
95
  }
96
+
97
+ /**
98
+ * One step id per generated edge, including contract responses synthesized by
99
+ * the LikeC4 generator. Nested RPCs return immediately; the actor request that
100
+ * opened the flow returns after its final step.
101
+ */
102
+ export function drawnEdgeStepIds(
103
+ flow: Flow,
104
+ crossOnly: boolean,
105
+ responseStepIds: ReadonlySet<string>,
106
+ ): string[] {
107
+ const steps = walkSteps(flow.steps);
108
+ const actorIds = new Set(
109
+ flow.participants.filter((p) => p.kind === "actor").map((p) => p.id),
110
+ );
111
+ const deferred = steps.find(
112
+ (step) =>
113
+ step.kind === "rpc" &&
114
+ actorIds.has(step.from) &&
115
+ responseStepIds.has(step.id),
116
+ )?.id;
117
+ const drawn = drawnStepIds(flow, crossOnly);
118
+ const out: string[] = [];
119
+ for (const stepId of drawn) {
120
+ out.push(stepId);
121
+ if (stepId !== deferred && responseStepIds.has(stepId)) out.push(stepId);
122
+ }
123
+ if (deferred && drawn.includes(deferred)) out.push(deferred);
124
+ return out;
125
+ }
@@ -8,7 +8,9 @@
8
8
  import { pickViewBounds } from "likec4/react";
9
9
  import { likec4model } from "./generated";
10
10
  import type { Flow } from "../catalog";
11
- import { drawnStepIds, pairEdgesToSteps } from "./flow-edges";
11
+ import { index } from "../data";
12
+ import { flowAnswers } from "../flow/answers";
13
+ import { drawnEdgeStepIds, pairEdgesToSteps } from "./flow-edges";
12
14
  import type { EdgeStepPairing } from "./flow-edges";
13
15
  import { EMPTY_PAIRING } from "./flow-edges";
14
16
  import { flowCrossViewId, flowViewId } from "./ids";
@@ -70,8 +72,12 @@ export function flowPairing(flow: Flow, crossOnly: boolean): EdgeStepPairing {
70
72
  const viewId = crossOnly ? flowCrossViewId(flow) : flowViewId(flow);
71
73
  const cached = pairings.get(viewId);
72
74
  if (cached) return cached;
75
+ const responseStepIds = new Set(flowAnswers(index, flow).keys());
73
76
  const pairing = shapeOf(viewId).edgeIds.length
74
- ? pairEdgesToSteps(shapeOf(viewId).edgeIds, drawnStepIds(flow, crossOnly))
77
+ ? pairEdgesToSteps(
78
+ shapeOf(viewId).edgeIds,
79
+ drawnEdgeStepIds(flow, crossOnly, responseStepIds),
80
+ )
75
81
  : EMPTY_PAIRING;
76
82
  pairings.set(viewId, pairing);
77
83
  return pairing;
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) => ({
@@ -711,6 +757,29 @@ describe("mergeCatalogs: schema modules", () => {
711
757
  // came from.
712
758
  expect(channels?.[0]?.source).toBe("bus/asyncapi.yaml");
713
759
  });
760
+
761
+ it("keeps a message encoding and reports sources that disagree about it", () => {
762
+ const first = context("shop", ["shop.cart"]);
763
+ first.services[0]!.channels = [{
764
+ address: "shop.cart.basket",
765
+ messages: [{ name: "cart.BasketCreated", direction: "send", encoding: "msgpack", contentType: "application/msgpack" }],
766
+ }];
767
+ const second = context("shop", ["shop.cart"]);
768
+ second.services[0]!.channels = [{
769
+ address: "shop.cart.basket",
770
+ messages: [{ name: "cart.BasketCreated", direction: "send", encoding: "json", contentType: "application/json" }],
771
+ }];
772
+
773
+ const merged = mergeCatalogs([
774
+ source("a-msgpack.json", { contexts: [first] }),
775
+ source("b-json.json", { contexts: [second] }),
776
+ ]);
777
+ const message = merged.catalog.contexts[0]!.services[0]!.channels![0]!.messages[0]!;
778
+ expect(message.encoding).toBe("msgpack");
779
+ expect(message.contentType).toBe("application/msgpack");
780
+ expect(merged.conflicts.map((conflict) => conflict.message).join(" ")).toMatch(/encoding json.*msgpack/);
781
+ expect(merged.conflicts.map((conflict) => conflict.message).join(" ")).toMatch(/contentType application\/json.*application\/msgpack/);
782
+ });
714
783
  });
715
784
 
716
785
  describe("a second source that has seen the flow run", () => {
package/src/merge.ts CHANGED
@@ -442,6 +442,7 @@ function foldMaps(catalog: Catalog, stores: Store[]): Store[] {
442
442
  if (!aggregate) return table;
443
443
  return {
444
444
  ...table,
445
+ ...(table.accesses ? { accesses: table.accesses.map((access) => ({ ...access })) } : {}),
445
446
  columns: table.columns.map((column) => {
446
447
  if (!column.maps) return column;
447
448
  const maps = fold(aggregate, column.maps);
@@ -473,8 +474,11 @@ function mergeService(
473
474
  if (!existing) {
474
475
  services.set(incoming.id, {
475
476
  ...incoming,
476
- provides: [...incoming.provides],
477
+ provides: incoming.provides.map(copyInterface),
477
478
  consumes: [...incoming.consumes],
479
+ ...(incoming.copies
480
+ ? { copies: incoming.copies.map(copyInterface) }
481
+ : {}),
478
482
  aggregates: incoming.aggregates.map(copyAggregate),
479
483
  ...(incoming.stores ? { stores: [...incoming.stores] } : {}),
480
484
  ...(incoming.modules ? { modules: [...incoming.modules] } : {}),
@@ -534,6 +538,11 @@ function mergeService(
534
538
 
535
539
  mergeInterfaces(existing.provides, incoming.provides);
536
540
  appendNew(existing.consumes, incoming.consumes, (c) => c.id, raise);
541
+ if (incoming.copies?.length) {
542
+ const copies = existing.copies ?? [];
543
+ mergeInterfaces(copies, incoming.copies);
544
+ existing.copies = copies;
545
+ }
537
546
  mergeAggregates(existing.aggregates, incoming.aggregates);
538
547
 
539
548
  if (incoming.stores?.length) {
@@ -600,6 +609,21 @@ function mergeService(
600
609
  mine.messages,
601
610
  theirs.messages,
602
611
  (m) => `${m.direction} ${m.name}`,
612
+ (held, offered) => {
613
+ for (const field of ["encoding", "contentType"] as const) {
614
+ if (!held[field] && offered[field]) {
615
+ held[field] = offered[field];
616
+ } else if (held[field] && offered[field] && held[field] !== offered[field]) {
617
+ conflicts.push({
618
+ path,
619
+ where: incoming.id,
620
+ message: `message "${held.name}" on channel "${mine.address}" of service "${incoming.id}" has ${field} ${offered[field]} here and ${held[field]} in ${owner}; the first one is used`,
621
+ });
622
+ }
623
+ }
624
+
625
+ return undefined;
626
+ },
603
627
  );
604
628
 
605
629
  // Mutated in place: the channel that was here keeps its prose and its
@@ -612,7 +636,7 @@ function mergeService(
612
636
  }
613
637
 
614
638
  function copyChannel(channel: Channel): Channel {
615
- return { ...channel, messages: [...channel.messages] };
639
+ return { ...channel, messages: channel.messages.map((message) => ({ ...message })) };
616
640
  }
617
641
 
618
642
  /**
@@ -713,6 +737,24 @@ function mergeInterfaces(target: RpcService[], incoming: RpcService[]): void {
713
737
  }
714
738
  }
715
739
  if (messages.length > 0) mine.messages = messages;
740
+
741
+ const enums = mine.enums ?? [];
742
+ for (const item of theirs.enums ?? []) {
743
+ const existing = enums.find((candidate) => candidate.name === item.name);
744
+ if (!existing) {
745
+ enums.push({
746
+ ...item,
747
+ values: item.values.map((value) => ({ ...value })),
748
+ });
749
+ continue;
750
+ }
751
+ appendNew(
752
+ existing.values,
753
+ item.values.map((value) => ({ ...value })),
754
+ (value) => value.name,
755
+ );
756
+ }
757
+ if (enums.length > 0) mine.enums = enums;
716
758
  }
717
759
  }
718
760
 
@@ -736,6 +778,14 @@ function copyInterface(provided: RpcService): RpcService {
736
778
  })),
737
779
  }
738
780
  : {}),
781
+ ...(provided.enums
782
+ ? {
783
+ enums: provided.enums.map((item) => ({
784
+ ...item,
785
+ values: item.values.map((value) => ({ ...value })),
786
+ })),
787
+ }
788
+ : {}),
739
789
  };
740
790
  }
741
791
 
@@ -26,8 +26,8 @@ const HINTS: { test: RegExp; hint: string }[] = [
26
26
  hint: "A step names a lane the flow never declared. Add the service to the flow's `participants`, or point the step at one that is already there.",
27
27
  },
28
28
  {
29
- test: /resolves to neither an Event nor an RpcCall/,
30
- hint: "Either the ref is stale — the event was renamed or removed — or the step really does point at something outside the catalog, in which case its status belongs as `unresolved`.",
29
+ test: /resolves to neither an Event, an RpcCall nor a method/,
30
+ hint: "Either the ref is stale — the event or RPC method was renamed or removed — or the step really does point at something outside the catalog, in which case its status belongs as `unresolved`.",
31
31
  },
32
32
  {
33
33
  test: /must have id/,
@@ -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,10 @@ 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";
59
+ import { IntegrationsSettings } from "./settings/IntegrationsSettings";
62
60
  import { CatEmptyState, CatIllustration } from "../components/CatIllustration";
63
61
  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
62
 
72
63
  type Health = "healthy" | "changed" | "failed" | "unchecked";
73
64
  type ProjectSource = ProjectDraft["source"];
@@ -964,6 +955,7 @@ const SETTINGS_LINKS = [
964
955
  ["Projects", paths.settingsProjects()],
965
956
  ["Pipeline", paths.settingsPipeline()],
966
957
  ["Delivery", paths.settingsDelivery()],
958
+ ["Integrations", paths.settingsIntegrations()],
967
959
  ["Preferences", paths.settingsPreferences()],
968
960
  ["About", paths.settingsAbout()],
969
961
  ] as const;
@@ -1056,6 +1048,10 @@ function OverviewSettings({ local, onGenerate }: { local: boolean; onGenerate: (
1056
1048
  <div className="font-semibold text-ink">Delivery</div>
1057
1049
  <p className="mt-1 text-muted">Install review checks and static catalog publishing for GitHub or GitLab.</p>
1058
1050
  </Link>
1051
+ <Link to={paths.settingsIntegrations()} className="card">
1052
+ <div className="font-semibold text-ink">Integrations</div>
1053
+ <p className="mt-1 text-muted">Connect operational tools such as Kafka UI to the catalog.</p>
1054
+ </Link>
1059
1055
  <Link to={paths.settingsPreferences()} className="card">
1060
1056
  <div className="font-semibold text-ink">Preferences</div>
1061
1057
  <p className="mt-1 text-muted">Theme, row density, source editor and Ask the catalog.</p>
@@ -1117,135 +1113,21 @@ function PipelineSettings() {
1117
1113
  );
1118
1114
  }
1119
1115
 
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
1116
 
1236
1117
  function SettingsContent({ local, onAdd, onRemove, onGenerate }: { local: boolean; onAdd: (source: ProjectSource) => void; onRemove: (project: SetupProject) => void; onGenerate: () => void }) {
1237
1118
  const pathname = useLocation().pathname.replace(/\/$/, "");
1238
1119
  const overview = pathname === paths.settings();
1239
1120
  const about = pathname === paths.settingsAbout();
1121
+ const browserIntegration = pathname === paths.settingsIntegrations();
1240
1122
  return (
1241
1123
  <div className="h-full overflow-y-auto p-gutter">
1242
1124
  <div className="max-w-table">
1243
1125
  <div className="flex flex-wrap items-start justify-between gap-3">
1244
1126
  <div>
1245
1127
  <div className="flex items-center gap-2"><h1 className="text-lg font-semibold">Settings</h1>{local ? <span className="chip status-verified">local mode</span> : null}</div>
1246
- <p className="mt-1 max-w-prose text-muted">Configure projects, extraction, delivery automation and local preferences. {local ? "This local session can write reviewed changes." : "Build configuration is read-only here."}</p>
1128
+ <p className="mt-1 max-w-prose text-muted">Configure projects, extraction, delivery automation, integrations and local preferences. {local ? "This local session can write reviewed changes." : "Build configuration is read-only here."}</p>
1247
1129
  </div>
1248
- {local && !overview && !about ? <button type="button" className="product-primary" onClick={onGenerate}><Play size={15} /> Preview generated diff</button> : null}
1130
+ {local && !overview && !about && !browserIntegration ? <button type="button" className="product-primary" onClick={onGenerate}><Play size={15} /> Preview generated diff</button> : null}
1249
1131
  </div>
1250
1132
  <SettingsNav />
1251
1133
  <div className="mt-section">
@@ -1254,12 +1136,13 @@ function SettingsContent({ local, onAdd, onRemove, onGenerate }: { local: boolea
1254
1136
  <Route path="projects" element={<ProjectsSettings local={local} onAdd={onAdd} onRemove={onRemove} />} />
1255
1137
  <Route path="pipeline" element={<PipelineSettings />} />
1256
1138
  <Route path="delivery" element={<section><SectionTitle right={local ? "preview before writing" : "local mode required"}>Delivery presets</SectionTitle><DeliverySettings local={local} /></section>} />
1139
+ <Route path="integrations" element={<IntegrationsSettings />} />
1257
1140
  <Route path="preferences" element={<PreferencesSettings />} />
1258
1141
  <Route path="about" element={<AboutSettings />} />
1259
1142
  <Route path="*" element={<Navigate to={paths.settings()} replace />} />
1260
1143
  </Routes>
1261
1144
  </div>
1262
- {!about ? <div className="mono mt-section flex items-center gap-2 pb-section text-muted"><Box size={14} aria-hidden />{local ? "Changes are written only after preview; generated files remain reviewable in git." : "Configuration is embedded at build time; changing it requires a new catalog build."}</div> : null}
1145
+ {!about ? <div className="mono mt-section flex items-center gap-2 pb-section text-muted"><Box size={14} aria-hidden />{browserIntegration ? "Integration settings stay in this browser and do not change the generated catalog." : local ? "Changes are written only after preview; generated files remain reviewable in git." : "Configuration is embedded at build time; changing it requires a new catalog build."}</div> : null}
1263
1146
  </div>
1264
1147
  </div>
1265
1148
  );
@@ -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
+ }