@shortlink-org/portolan 0.2.1 → 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 (107) 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 +363 -28
  34. package/scripts/local-api.test.mjs +195 -1
  35. package/scripts/plugin-host.mjs +14 -3
  36. package/scripts/plugin-host.test.mjs +8 -0
  37. package/scripts/schema.mjs +7 -0
  38. package/src/app/CatalogApp.tsx +19 -4
  39. package/src/app/CommandPalette.tsx +7 -2
  40. package/src/app/toast.tsx +11 -7
  41. package/src/catalog.test.ts +33 -0
  42. package/src/catalog.ts +190 -8
  43. package/src/components/CatIllustration.tsx +30 -0
  44. package/src/components/CommitLink.tsx +154 -0
  45. package/src/components/EditorLink.tsx +15 -8
  46. package/src/components/GrpcMethodReference.tsx +93 -0
  47. package/src/components/Markdown.tsx +17 -0
  48. package/src/components/MethodRows.tsx +8 -1
  49. package/src/components/PageHeader.test.ts +24 -0
  50. package/src/components/PageHeader.tsx +34 -0
  51. package/src/components/ShapeRows.tsx +12 -0
  52. package/src/components/SourceDoc.tsx +27 -12
  53. package/src/components/SourcePreview.tsx +124 -36
  54. package/src/enrich.test.ts +209 -2
  55. package/src/enrich.ts +284 -15
  56. package/src/er/RedisSchema.tsx +86 -5
  57. package/src/flow/FlowTable.tsx +19 -4
  58. package/src/flow/StepDetail.tsx +461 -101
  59. package/src/flow/StepRail.tsx +36 -13
  60. package/src/flow/answers-response.test.ts +100 -0
  61. package/src/flow/answers.ts +11 -8
  62. package/src/flow/chapters.ts +2 -1
  63. package/src/flow/labels.ts +10 -0
  64. package/src/flow/mermaid.test.ts +87 -3
  65. package/src/flow/mermaid.ts +26 -14
  66. package/src/index.css +132 -0
  67. package/src/landing/LandingPage.tsx +12 -8
  68. package/src/lib/api.test.ts +4 -6
  69. package/src/lib/build-info.test.ts +29 -0
  70. package/src/lib/build-info.ts +21 -0
  71. package/src/lib/data-model.ts +14 -0
  72. package/src/lib/flow-tree.ts +1 -1
  73. package/src/lib/github-catalog.test.ts +51 -0
  74. package/src/lib/github-catalog.ts +69 -0
  75. package/src/lib/grpc-reference.test.ts +72 -0
  76. package/src/lib/grpc-reference.ts +117 -0
  77. package/src/lib/local-api.ts +17 -4
  78. package/src/lib/product.ts +7 -0
  79. package/src/lib/queries.ts +16 -1
  80. package/src/lib/readme-assets.test.ts +19 -0
  81. package/src/lib/readme-assets.ts +34 -0
  82. package/src/lib/setup-info.test.ts +7 -0
  83. package/src/lib/setup-info.ts +5 -0
  84. package/src/lib/source-link.test.ts +106 -18
  85. package/src/lib/source-link.ts +22 -6
  86. package/src/merge.test.ts +55 -0
  87. package/src/merge.ts +30 -0
  88. package/src/pages/AdrDetail.tsx +3 -2
  89. package/src/pages/AdrIndex.tsx +10 -7
  90. package/src/pages/AggregatePage.tsx +34 -4
  91. package/src/pages/BlockPage.tsx +39 -32
  92. package/src/pages/Changes.tsx +7 -9
  93. package/src/pages/ContextMap.tsx +11 -2
  94. package/src/pages/ContextPage.tsx +11 -4
  95. package/src/pages/FlowIndex.tsx +16 -7
  96. package/src/pages/GraphPage.tsx +21 -7
  97. package/src/pages/Language.tsx +9 -9
  98. package/src/pages/NotFound.tsx +58 -15
  99. package/src/pages/Overview.tsx +6 -0
  100. package/src/pages/Problems.tsx +10 -8
  101. package/src/pages/RegistryIndex.tsx +10 -8
  102. package/src/pages/ServicePage.tsx +1 -1
  103. package/src/pages/Settings.tsx +382 -44
  104. package/src/routes.test.ts +2 -0
  105. package/src/routes.ts +2 -1
  106. package/src/selection/DetailPanel.tsx +213 -113
  107. package/src/testing/setup.ts +14 -0
@@ -2,6 +2,7 @@ import { useEffect, useId, useRef } from "react";
2
2
  import { Link } from "react-router";
3
3
  import { LayoutGroup, m, transitions } from "../lib/motion";
4
4
  import {
5
+ AlertCircle,
5
6
  ArrowRight,
6
7
  ChevronDown,
7
8
  ChevronRight,
@@ -13,13 +14,11 @@ import { STATUSES } from "../catalog";
13
14
  import { contextName, ctxStyle } from "../lib/context-color";
14
15
  import { paths } from "../routes";
15
16
  import { statusVar } from "../components/primitives";
16
- import { railRows } from "./chapters";
17
+ import { railRows, stepTitle } from "./chapters";
17
18
  import type { Chapter, ChapterGroup } from "./chapters";
18
19
  import type { Continuation } from "./continues";
19
20
  import type { OutlineFrame, OutlineStep } from "./outline";
20
21
 
21
-
22
-
23
22
  /**
24
23
  * A chapter header. The one row on the rail that is a control rather than a
25
24
  * reading: it folds its own steps away and nothing else — the canvas keeps
@@ -68,7 +67,10 @@ function ChapterRow({
68
67
  {chapter.kind}
69
68
  </span>
70
69
  )}
71
- <span className="mono min-w-0 flex-1 truncate text-ink" title={chapter.title}>
70
+ <span
71
+ className="mono min-w-0 flex-1 truncate text-ink"
72
+ title={chapter.title}
73
+ >
72
74
  {chapter.title}
73
75
  </span>
74
76
  {/* The contexts this episode touches, as colour and nothing else: the
@@ -89,7 +91,11 @@ function ChapterRow({
89
91
  <span className="mono flex shrink-0 items-center gap-1 text-muted">
90
92
  {STATUSES.filter((status) => chapter.status[status] > 0).map(
91
93
  (status) => (
92
- <span key={status} className="flex items-center gap-0.5" title={status}>
94
+ <span
95
+ key={status}
96
+ className="flex items-center gap-0.5"
97
+ title={status}
98
+ >
93
99
  <span
94
100
  aria-hidden
95
101
  className="size-1.5 rounded-[1px]"
@@ -168,7 +174,9 @@ function CrossChip({ step, context }: { step: Step; context: string | null }) {
168
174
  return (
169
175
  <span
170
176
  className="chip shrink-0 ctx"
171
- style={context ? ctxStyle(context) : { borderColor: "var(--border-strong)" }}
177
+ style={
178
+ context ? ctxStyle(context) : { borderColor: "var(--border-strong)" }
179
+ }
172
180
  title={`crosses into ${label}`}
173
181
  >
174
182
  → {label}
@@ -202,6 +210,7 @@ function StepRow({
202
210
  const { step, number, depth, hidden, offPath, offStatus } = row;
203
211
  const self = step.from === step.to;
204
212
  const crosses = crossContext !== undefined;
213
+ const errorResponse = step.http?.outcome === "error";
205
214
  return (
206
215
  <div
207
216
  onMouseEnter={() => onHover(step.id)}
@@ -226,8 +235,11 @@ function StepRow({
226
235
  style={{
227
236
  borderLeftWidth: 2,
228
237
  borderLeftStyle: "solid",
229
- borderLeftColor: "transparent",
238
+ borderLeftColor: errorResponse
239
+ ? "var(--response-error)"
240
+ : "transparent",
230
241
  paddingLeft: 8 + depth * 10,
242
+ background: errorResponse ? "var(--response-error-bg)" : undefined,
231
243
  }}
232
244
  aria-current={active ? "true" : undefined}
233
245
  >
@@ -251,14 +263,17 @@ function StepRow({
251
263
  a character. Both sit on their content and the answer, weighted
252
264
  to give way ten times faster, is the one that loses. */}
253
265
  <span
254
- className="min-w-0 truncate text-ink"
255
- title={step.label ?? step.ref ?? step.kind}
266
+ className="min-w-0 truncate"
267
+ style={{
268
+ color: errorResponse ? "var(--response-error)" : "var(--fg)",
269
+ }}
270
+ title={stepTitle(step)}
256
271
  >
257
- {step.label ?? step.ref ?? step.kind}
272
+ {stepTitle(step)}
258
273
  </span>
259
- {/* The reply is not a step of its own - it is the far end of this
260
- one - so it is read on the same line, and only where a contract
261
- says what comes back. */}
274
+ {/* A standalone call keeps its contract answer on this line. Once
275
+ composition proves the nested return, the answer becomes its
276
+ own response step and is omitted from this map. */}
262
277
  {/* Only where there is room for it: two truncated halves read
263
278
  worse than one whole label, so on a rail dragged narrow the
264
279
  answer steps aside and the step's own panel still says it. */}
@@ -291,6 +306,14 @@ function StepRow({
291
306
  {full ? (
292
307
  <span className="mono shrink-0 text-muted">{step.kind}</span>
293
308
  ) : null}
309
+ {errorResponse ? (
310
+ <AlertCircle
311
+ size={12}
312
+ aria-label="error response"
313
+ className="mt-0.5 shrink-0"
314
+ style={{ color: "var(--response-error)" }}
315
+ />
316
+ ) : null}
294
317
  {crosses ? <CrossChip step={step} context={crossContext} /> : null}
295
318
  {hidden ? (
296
319
  <span
@@ -0,0 +1,100 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { buildIndex } from "../catalog";
4
+ import type { Catalog, Flow } from "../catalog";
5
+ import { flowAnswers, stepAnswer } from "./answers";
6
+
7
+ describe("explicit flow responses", () => {
8
+ it("moves the contract answer off a request that has a response step", () => {
9
+ const flow: Flow = {
10
+ id: "flow.get-book",
11
+ slug: "get-book",
12
+ name: "Get book",
13
+ summary: "",
14
+ owner: "demo",
15
+ participants: [
16
+ { id: "demo.api", kind: "service", context: "demo" },
17
+ { id: "demo.book", kind: "service", context: "demo" },
18
+ ],
19
+ steps: [
20
+ {
21
+ type: "step",
22
+ id: "request",
23
+ from: "demo.api",
24
+ to: "demo.book",
25
+ kind: "rpc",
26
+ ref: "book.v1.Book/Get",
27
+ label: "Get",
28
+ status: "declared",
29
+ },
30
+ {
31
+ type: "step",
32
+ id: "response",
33
+ from: "demo.book",
34
+ to: "demo.api",
35
+ kind: "response",
36
+ label: "GetResponse",
37
+ status: "declared",
38
+ replyTo: "request",
39
+ },
40
+ ],
41
+ };
42
+ const catalog: Catalog = {
43
+ generatedAt: "2026-09-09T00:00:00Z",
44
+ commit: "0000000",
45
+ contexts: [
46
+ {
47
+ id: "demo",
48
+ slug: "demo",
49
+ name: "Demo",
50
+ summary: "",
51
+ services: [
52
+ {
53
+ id: "demo.api",
54
+ slug: "api",
55
+ name: "API",
56
+ repo: "",
57
+ path: "",
58
+ readme: "",
59
+ provides: [],
60
+ consumes: [],
61
+ aggregates: [],
62
+ },
63
+ {
64
+ id: "demo.book",
65
+ slug: "book",
66
+ name: "Book",
67
+ repo: "",
68
+ path: "",
69
+ readme: "",
70
+ provides: [
71
+ {
72
+ id: "book.v1.Book",
73
+ source: "book.proto",
74
+ methods: [
75
+ {
76
+ name: "Get",
77
+ request: "GetRequest",
78
+ response: "GetResponse",
79
+ },
80
+ ],
81
+ },
82
+ ],
83
+ consumes: [],
84
+ aggregates: [],
85
+ },
86
+ ],
87
+ },
88
+ ],
89
+ defs: {},
90
+ flows: [flow],
91
+ adrs: [],
92
+ };
93
+ const index = buildIndex(catalog);
94
+ const request = flow.steps[0];
95
+ if (request?.type !== "step") throw new Error("missing request");
96
+
97
+ expect(stepAnswer(index, request)).toBe("GetResponse");
98
+ expect(flowAnswers(index, flow).has(request.id)).toBe(false);
99
+ });
100
+ });
@@ -1,12 +1,9 @@
1
1
  // What comes back from a call, when a contract says.
2
2
  //
3
- // The catalog records hops - a call was made, an event was published - and
4
- // never a reply: a reply is not a second thing that happened, it is the far end
5
- // of the same one. But a reader of a sequence still wants to see what the far
6
- // end hands over, and for an rpc the answer is already written down twice over:
7
- // in the proto that declares the method, and in the OpenAPI document that
8
- // declares the operation. So it is looked up rather than recorded, and a step
9
- // whose method nobody in the catalog provides simply has no answer to draw.
3
+ // The catalog's source facts are hops - a call was made, an event was
4
+ // published. Composition may synthesize a response when a proven synchronous
5
+ // continuation returns, but standalone calls still need their answer read from
6
+ // the contract and shown on the request itself.
10
7
  //
11
8
  // Only an rpc has one. A `call` lands inside a service - a repository, a
12
9
  // queryset - which no interface in the catalog describes; an event is a
@@ -47,10 +44,16 @@ export function stepAnswer(index: CatalogIndex, step: Step): string | undefined
47
44
  return methodOf(index, step)?.response || undefined;
48
45
  }
49
46
 
50
- /** Every step of a flow that has one, by step id. */
47
+ /** Every request without an explicit response step that has an answer. */
51
48
  export function flowAnswers(index: CatalogIndex, flow: Flow): Map<string, string> {
52
49
  const out = new Map<string, string>();
50
+ const explicit = new Set(
51
+ walkSteps(flow.steps)
52
+ .filter((step) => step.kind === "response" && step.replyTo)
53
+ .map((step) => step.replyTo as string),
54
+ );
53
55
  for (const step of walkSteps(flow.steps)) {
56
+ if (explicit.has(step.id)) continue;
54
57
  const answer = stepAnswer(index, step);
55
58
  if (answer) out.set(step.id, answer);
56
59
  }
@@ -21,6 +21,7 @@
21
21
  import type { Flow, FlowNode, Status, Step } from "../catalog";
22
22
  import { walkSteps } from "../catalog";
23
23
  import { contextResolver } from "./cross-context";
24
+ import { stepLabel } from "./labels";
24
25
  import type { OutlineRow } from "./outline";
25
26
 
26
27
  export type ChapterKind = "alt" | "par" | "loop" | "steps";
@@ -55,7 +56,7 @@ export interface ChapterGroup {
55
56
 
56
57
  /** What a step is called when it has to name something. */
57
58
  export function stepTitle(step: Step): string {
58
- return step.label ?? step.ref ?? step.kind;
59
+ return stepLabel(step);
59
60
  }
60
61
 
61
62
  const NO_STATUS: Record<Status, number> = {
@@ -0,0 +1,10 @@
1
+ import type { Step } from "../catalog";
2
+
3
+ /** The message a reader sees everywhere a flow step is listed or drawn. */
4
+ export function stepLabel(step: Step): string {
5
+ const access = step.storeAccess;
6
+ if (access?.operation && access.keyspace) {
7
+ return `${access.operation.toUpperCase()} ${access.keyspace}`;
8
+ }
9
+ return step.label ?? step.ref ?? step.kind;
10
+ }
@@ -1,5 +1,6 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { describe, expect, it } from "vitest";
3
+ import type { Flow } from "../catalog";
3
4
  import { catalog, index } from "../data";
4
5
  import { flowAnswers } from "./answers";
5
6
  import { flowMermaid } from "./mermaid";
@@ -15,8 +16,91 @@ function generated(slug: string): string {
15
16
  describe("flowMermaid", () => {
16
17
  // The Go generator and this port draw the same diagram, or one of them is
17
18
  // wrong; the generated pages are the record of what the Go one drew.
18
- it.each(catalog.flows.map((f) => f.slug))("draws %s as gen-markdown does", (slug) => {
19
- const flow = catalog.flows.find((f) => f.slug === slug)!;
20
- expect(flowMermaid(flow, flowAnswers(index, flow))).toBe(generated(slug));
19
+ it.each(catalog.flows.map((f) => f.slug))(
20
+ "draws %s as gen-markdown does",
21
+ (slug) => {
22
+ const flow = catalog.flows.find((f) => f.slug === slug)!;
23
+ expect(flowMermaid(flow, flowAnswers(index, flow))).toBe(generated(slug));
24
+ },
25
+ );
26
+
27
+ it("draws a synthesized response as a dashed reverse message", () => {
28
+ const flow: Flow = {
29
+ id: "flow.reply",
30
+ slug: "reply",
31
+ name: "Reply",
32
+ summary: "",
33
+ owner: "shop",
34
+ participants: [
35
+ { id: "client", kind: "actor", context: null },
36
+ { id: "shop.api", kind: "service", context: "shop" },
37
+ ],
38
+ steps: [
39
+ {
40
+ type: "step",
41
+ id: "request",
42
+ from: "client",
43
+ to: "shop.api",
44
+ kind: "rpc",
45
+ label: "GET /book",
46
+ status: "declared",
47
+ },
48
+ {
49
+ type: "step",
50
+ id: "response",
51
+ from: "shop.api",
52
+ to: "client",
53
+ kind: "response",
54
+ label: "HTTP response",
55
+ status: "declared",
56
+ replyTo: "request",
57
+ },
58
+ ],
59
+ };
60
+
61
+ expect(flowMermaid(flow)).toContain(
62
+ "p0->>p1: GET /book\n p1-->>p0: HTTP response",
63
+ );
64
+
65
+ const response = flow.steps[1];
66
+ if (response?.type !== "step") throw new Error("response fixture missing");
67
+ response.http = { status: 500, outcome: "error" };
68
+ response.label = "500 · Error";
69
+ expect(flowMermaid(flow)).toContain(
70
+ "rect rgba(183, 100, 107, 0.12)\n p1-->>p0: 500 · Error\n end",
71
+ );
72
+ });
73
+
74
+ it("labels a resolved Redis call by its concrete operation and key", () => {
75
+ const flow: Flow = {
76
+ id: "flow.redis",
77
+ slug: "redis",
78
+ name: "Redis",
79
+ summary: "",
80
+ owner: "shop",
81
+ participants: [
82
+ { id: "shop.api", kind: "service", context: "shop" },
83
+ { id: "shop.redis", kind: "store", context: "shop" },
84
+ ],
85
+ steps: [
86
+ {
87
+ type: "step",
88
+ id: "read",
89
+ from: "shop.api",
90
+ to: "shop.redis",
91
+ kind: "call",
92
+ label: "Get",
93
+ status: "verified",
94
+ storeAccess: {
95
+ store: "shop.redis",
96
+ method: "Store.Get",
97
+ operation: "read",
98
+ keyspace: "{id}",
99
+ },
100
+ },
101
+ ],
102
+ };
103
+
104
+ expect(flowMermaid(flow)).toContain("p0->>p1: READ {id}");
21
105
  });
22
106
  });
@@ -7,6 +7,7 @@
7
7
  // the catalog has a dotted service id.
8
8
 
9
9
  import type { Flow, FlowNode, Step } from "../catalog";
10
+ import { stepLabel } from "./labels";
10
11
 
11
12
  /**
12
13
  * `answers` is what each step's callee hands back, by step id - see
@@ -42,17 +43,28 @@ function emit(
42
43
  for (const node of nodes) {
43
44
  switch (node.type) {
44
45
  case "step": {
45
- // An event is drawn with the async arrow. A reader who cannot tell a
46
- // call from a publication is reading a different flow.
47
- const arrow = node.kind === "event" ? "-)" : "->>";
48
- // The reply rides on the hop's own label rather than coming back as a
49
- // second message: `autonumber` counts messages, and the numbers here
50
- // are the numbers of the step list beside the diagram.
51
- out.push(`${indent}${of(node.from)}${arrow}${of(node.to)}: ${text(labelWithAnswer(node, answers))}`);
46
+ // Events use the async arrow; synthesized responses use the standard
47
+ // dashed return. A reader can now distinguish all three directions.
48
+ const arrow =
49
+ node.kind === "response"
50
+ ? "-->>"
51
+ : node.kind === "event"
52
+ ? "-)"
53
+ : "->>";
54
+ const message = `${of(node.from)}${arrow}${of(node.to)}: ${text(labelWithAnswer(node, answers))}`;
55
+ if (node.http?.outcome === "error") {
56
+ out.push(`${indent}rect rgba(183, 100, 107, 0.12)`);
57
+ out.push(`${indent} ${message}`);
58
+ out.push(`${indent}end`);
59
+ } else {
60
+ out.push(`${indent}${message}`);
61
+ }
52
62
  break;
53
63
  }
54
64
  case "parallel": {
55
- out.push(`${indent}par ${text(node.title?.trim() ? node.title : "in parallel")}`);
65
+ out.push(
66
+ `${indent}par ${text(node.title?.trim() ? node.title : "in parallel")}`,
67
+ );
56
68
  node.branches.forEach((branch, i) => {
57
69
  if (i > 0) out.push(`${indent}and`);
58
70
  emit(branch, of, depth + 1, out, answers);
@@ -62,14 +74,18 @@ function emit(
62
74
  }
63
75
  case "alt": {
64
76
  node.branches.forEach((branch, i) => {
65
- out.push(`${indent}${i === 0 ? "alt " : "else "}${text(branch.title)}`);
77
+ out.push(
78
+ `${indent}${i === 0 ? "alt " : "else "}${text(branch.title)}`,
79
+ );
66
80
  emit(branch.steps, of, depth + 1, out, answers);
67
81
  // A branch that ends the flow has to say so inside the diagram, or
68
82
  // the steps drawn after the alt read as if they follow it too.
69
83
  if (branch.terminal) {
70
84
  const last = lastParticipant(branch.steps);
71
85
  if (last)
72
- out.push(`${" ".repeat(depth + 1)}Note over ${of(last)}: flow ends here`);
86
+ out.push(
87
+ `${" ".repeat(depth + 1)}Note over ${of(last)}: flow ends here`,
88
+ );
73
89
  }
74
90
  });
75
91
  out.push(`${indent}end`);
@@ -85,10 +101,6 @@ function emit(
85
101
  }
86
102
  }
87
103
 
88
- function stepLabel(step: Step): string {
89
- return step.label || step.ref || step.kind;
90
- }
91
-
92
104
  function lastParticipant(nodes: FlowNode[]): string {
93
105
  for (let i = nodes.length - 1; i >= 0; i--) {
94
106
  const node = nodes[i]!;
package/src/index.css CHANGED
@@ -54,6 +54,10 @@
54
54
  /* Domain events are the loudest leaf in the tree. Amber, but a step darker
55
55
  than amber-600 so 12px mono still clears contrast on white. */
56
56
  --kind-event: #b45309;
57
+ /* Error paths should be unmistakable without turning the diagram into an
58
+ alarm panel: dusty rose on a very pale wash. */
59
+ --response-error: #b7646b;
60
+ --response-error-bg: #faedef;
57
61
 
58
62
  /* GitHub paints `[!IMPORTANT]` purple. Purple already exists in the context
59
63
  ramp, where it means "this context"; the alert gets a name of its own so
@@ -170,6 +174,8 @@
170
174
  --status-unresolved: #ff6b63;
171
175
 
172
176
  --kind-event: #e8a33d;
177
+ --response-error: #d98288;
178
+ --response-error-bg: #2a1c20;
173
179
 
174
180
  --alert-important: #b98cf0;
175
181
 
@@ -616,6 +622,9 @@
616
622
  border-color var(--dur-micro) var(--ease-out),
617
623
  box-shadow var(--dur-panel) var(--ease-out);
618
624
  }
625
+ .product-primary {
626
+ cursor: pointer;
627
+ }
619
628
  :root:not(.dark) :is(.landing-primary, .product-primary) {
620
629
  color: var(--product-action-ink);
621
630
  border-color: color-mix(in srgb, var(--product-action) 52%, var(--border));
@@ -1015,6 +1024,129 @@
1015
1024
  font-size: var(--text-meta);
1016
1025
  }
1017
1026
 
1027
+ /* Cats mark rare, positive empty states and milestones rather than routine gaps. */
1028
+ .cat-illustration {
1029
+ display: block;
1030
+ object-fit: contain;
1031
+ pointer-events: none;
1032
+ user-select: none;
1033
+ filter: drop-shadow(0 12px 18px rgb(10 18 26 / 10%));
1034
+ }
1035
+
1036
+ .cat-empty-state {
1037
+ @apply glow grid items-center gap-5 rounded-card border border-dashed border-line-strong px-5 py-4;
1038
+ grid-template-columns: 180px minmax(0, 1fr);
1039
+ }
1040
+
1041
+ .cat-empty-illustration {
1042
+ width: 180px;
1043
+ height: 126px;
1044
+ justify-self: center;
1045
+ }
1046
+
1047
+ .cat-onboarding-illustration {
1048
+ width: 104px;
1049
+ height: 82px;
1050
+ margin-block: -10px;
1051
+ }
1052
+
1053
+ .cat-search-empty {
1054
+ @apply glow flex items-center justify-center gap-4 px-4 py-4;
1055
+ min-height: 104px;
1056
+ }
1057
+
1058
+ .cat-search-illustration {
1059
+ width: 92px;
1060
+ height: 68px;
1061
+ }
1062
+
1063
+ .cat-trial-illustration {
1064
+ width: 120px;
1065
+ height: 88px;
1066
+ margin-block: -8px;
1067
+ justify-self: center;
1068
+ }
1069
+
1070
+ @media (max-width: 520px) {
1071
+ .cat-empty-state {
1072
+ grid-template-columns: 104px minmax(0, 1fr);
1073
+ gap: 16px;
1074
+ padding: 16px;
1075
+ }
1076
+
1077
+ .cat-empty-illustration {
1078
+ width: 104px;
1079
+ height: 92px;
1080
+ }
1081
+
1082
+ .cat-onboarding-illustration {
1083
+ width: 76px;
1084
+ height: 66px;
1085
+ }
1086
+
1087
+ .cat-trial-illustration {
1088
+ width: 90px;
1089
+ height: 72px;
1090
+ }
1091
+ }
1092
+
1093
+ /* The 404 is a small editorial moment inside the catalog rather than a
1094
+ centred error dialog. Copy keeps a readable left edge; the transparent
1095
+ explorer sits quietly beside it. */
1096
+ .not-found-page {
1097
+ @apply glow flex h-full min-h-0 items-center justify-center overflow-y-auto p-gutter;
1098
+ }
1099
+ .not-found-state {
1100
+ display: grid;
1101
+ grid-template-columns: minmax(260px, 0.9fr) minmax(360px, 1fr);
1102
+ align-items: center;
1103
+ gap: clamp(32px, 6vw, 72px);
1104
+ width: min(100%, 980px);
1105
+ margin: auto;
1106
+ }
1107
+ .not-found-copy {
1108
+ max-width: 480px;
1109
+ }
1110
+ .not-found-art {
1111
+ position: relative;
1112
+ aspect-ratio: 4 / 3;
1113
+ }
1114
+ .not-found-cat {
1115
+ width: 100%;
1116
+ height: 100%;
1117
+ object-fit: contain;
1118
+ user-select: none;
1119
+ filter: drop-shadow(0 18px 22px rgb(10 18 26 / 0.12));
1120
+ }
1121
+ .not-found-request {
1122
+ @apply grid grid-cols-[auto_minmax(0,1fr)] items-center gap-3 rounded-control border border-line bg-surface px-3 py-2;
1123
+ }
1124
+ .not-found-key {
1125
+ margin-left: 2px;
1126
+ padding: 0 4px;
1127
+ border: 1px solid color-mix(in srgb, var(--fg) 22%, transparent);
1128
+ border-radius: 4px;
1129
+ color: inherit;
1130
+ }
1131
+
1132
+ @media (max-width: 760px) {
1133
+ .not-found-page {
1134
+ align-items: flex-start;
1135
+ }
1136
+ .not-found-state {
1137
+ grid-template-columns: 1fr;
1138
+ gap: 8px;
1139
+ width: min(100%, 520px);
1140
+ }
1141
+ .not-found-art {
1142
+ width: min(88%, 380px);
1143
+ margin-inline: auto;
1144
+ }
1145
+ .not-found-copy {
1146
+ max-width: none;
1147
+ }
1148
+ }
1149
+
1018
1150
  /* ---------------------------------------------------------------------
1019
1151
  Identifiers. Every id, path and type name in the app is an <Ident>, and
1020
1152
  every <Ident> is this button: it looks like text until the pointer is on
@@ -30,8 +30,12 @@ import { DraggableReveal } from "./DraggableReveal";
30
30
  import { heroColumn, heroLine, Reveal } from "./motion";
31
31
  import { ProductFrame } from "./ProductFrame";
32
32
  import { ProductTour } from "./ProductTour";
33
+ import {
34
+ PRODUCT_LICENSE,
35
+ PRODUCT_README,
36
+ PRODUCT_REPOSITORY,
37
+ } from "../lib/product";
33
38
 
34
- const REPOSITORY = "https://github.com/shortlink-org/portolan";
35
39
  const EXAMPLE_DOCS = "https://shortlink-org.github.io/portolan/docs/example";
36
40
  const exampleTo = catalogTo(paths.overview());
37
41
 
@@ -86,7 +90,7 @@ function Header() {
86
90
  {theme === "dark" ? <Sun size={15} /> : <Moon size={15} />}
87
91
  </button>
88
92
  <a
89
- href={REPOSITORY}
93
+ href={PRODUCT_REPOSITORY}
90
94
  className="tbtn hidden sm:flex"
91
95
  target="_blank"
92
96
  rel="noreferrer"
@@ -362,7 +366,7 @@ export function LandingPage() {
362
366
  <div className="shrink-0">
363
367
  <div className="mono text-faint">BUILT-IN INPUTS</div>
364
368
  <a
365
- href={`${REPOSITORY}#plugins`}
369
+ href={`${PRODUCT_REPOSITORY}#plugins`}
366
370
  target="_blank"
367
371
  rel="noreferrer"
368
372
  className="mt-1 inline-flex items-center gap-1 text-sm text-accent hover:underline"
@@ -648,7 +652,7 @@ export function LandingPage() {
648
652
  Open the example <ArrowRight size={15} />
649
653
  </Link>
650
654
  <a
651
- href={`${REPOSITORY}#use-it-in-your-project`}
655
+ href={`${PRODUCT_REPOSITORY}#use-it-in-your-project`}
652
656
  className="tbtn px-4 py-2.5 text-ink"
653
657
  target="_blank"
654
658
  rel="noreferrer"
@@ -720,7 +724,7 @@ export function LandingPage() {
720
724
  Explore example catalog <ArrowRight size={15} />
721
725
  </Link>
722
726
  <a
723
- href={REPOSITORY}
727
+ href={PRODUCT_REPOSITORY}
724
728
  target="_blank"
725
729
  rel="noreferrer"
726
730
  className="tbtn px-4 py-2.5 text-ink"
@@ -737,16 +741,16 @@ export function LandingPage() {
737
741
  <Wordmark />
738
742
  <span className="sm:ml-3">Architecture from code and evidence.</span>
739
743
  <div className="flex gap-5 sm:ml-auto">
740
- <a className="hover:text-ink" href={`${REPOSITORY}#readme`}>
744
+ <a className="hover:text-ink" href={PRODUCT_README}>
741
745
  Docs
742
746
  </a>
743
747
  <a
744
748
  className="hover:text-ink"
745
- href={`${REPOSITORY}/blob/main/LICENSE`}
749
+ href={PRODUCT_LICENSE}
746
750
  >
747
751
  MIT License
748
752
  </a>
749
- <a className="hover:text-ink" href={REPOSITORY}>
753
+ <a className="hover:text-ink" href={PRODUCT_REPOSITORY}>
750
754
  GitHub
751
755
  </a>
752
756
  </div>