@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
@@ -1,13 +1,17 @@
1
1
  import { useDocumentTitle } from "../app/title";
2
2
  import { createContext, useCallback, useContext, useEffect, useState } from "react";
3
- import { Link, Navigate, NavLink, Route, Routes } from "react-router";
3
+ import { Link, Navigate, NavLink, Route, Routes, useLocation } from "react-router";
4
4
  import {
5
5
  ArrowLeft,
6
+ ArrowRight,
7
+ BookOpen,
6
8
  Box,
7
9
  Check,
8
10
  ChevronDown,
9
11
  CircleAlert,
12
+ CircleDot,
10
13
  FolderGit2,
14
+ GitBranch,
11
15
  KeyRound,
12
16
  LoaderCircle,
13
17
  Play,
@@ -17,6 +21,7 @@ import {
17
21
  Trash2,
18
22
  X,
19
23
  } from "lucide-react";
24
+ import type { Variants } from "motion/react";
20
25
  import { catalog, catalogSources } from "../data";
21
26
  import { useToastStore } from "../app/toast";
22
27
  import { absoluteTime, plural, relativeTime } from "../lib/format";
@@ -37,23 +42,35 @@ import {
37
42
  forgetRepositoryCredential,
38
43
  inspectRepository,
39
44
  LocalApiError,
45
+ removeProject as removeLocalProject,
40
46
  saveRepositoryCredential,
41
47
  startGeneration,
42
48
  startProjectTrial,
43
49
  subscribeToRun,
50
+ undoProjectRemoval,
44
51
  } from "../lib/local-api";
45
52
  import type { Discovery, ProjectDraft, ProjectPlan, RunEvent } from "../lib/local-api";
46
53
  import { useQuery, useQueryClient } from "@tanstack/react-query";
47
54
  import { localKeys, localStatusQuery } from "../lib/queries";
48
55
  import { sourceHref, treeHref } from "../lib/source-link";
49
56
  import { paths } from "../routes";
50
- import { Empty, SectionTitle } from "../components/PageHeader";
57
+ import { CapabilityEmpty, Empty, SectionTitle } from "../components/PageHeader";
51
58
  import { Modal } from "../components/Overlay";
52
59
  import { MachineDocs } from "../components/MachineDocs";
53
60
  import { DeliverySettings } from "./settings/DeliverySettings";
54
61
  import { PreferencesSettings } from "./settings/PreferencesSettings";
62
+ import { CatEmptyState, CatIllustration } from "../components/CatIllustration";
63
+ 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";
55
71
 
56
72
  type Health = "healthy" | "changed" | "failed" | "unchecked";
73
+ type ProjectSource = ProjectDraft["source"];
57
74
 
58
75
  const SetupContext = createContext<SetupInfo>(staticSetupInfo);
59
76
  const useSetup = () => useContext(SetupContext);
@@ -205,6 +222,7 @@ function BuildHealth() {
205
222
 
206
223
  function projectHref(project: SetupProject): string | null {
207
224
  const group = project.group ?? project.context;
225
+ if (project.components?.length) return group && catalog.contexts.some((item) => item.id === group) ? paths.context(group) : null;
208
226
  const component = project.component ?? project.service;
209
227
  const context = catalog.contexts.find((item) => item.id === group);
210
228
  if (!context) return null;
@@ -217,6 +235,13 @@ function projectHref(project: SetupProject): string | null {
217
235
  return service ? paths.service(context.id, service.slug) : paths.context(context.id);
218
236
  }
219
237
 
238
+ function starterProject(setupInfo: SetupInfo): SetupProject | undefined {
239
+ if (setupInfo.projects.length !== 1) return undefined;
240
+ const project = setupInfo.projects[0];
241
+ const steps = setupInfo.steps.filter((step) => step.projectId === project?.id);
242
+ return project?.root === "." && !project.groupKind && !project.componentKind && steps.length === 1 && steps[0]?.plugin === "project" ? project : undefined;
243
+ }
244
+
220
245
  function forge(project: SetupProject): { href: string; title: string } | null {
221
246
  if (project.repository) {
222
247
  return {
@@ -286,7 +311,7 @@ function StepWarnings({ warnings }: { warnings: string[] }) {
286
311
  );
287
312
  }
288
313
 
289
- function ProjectCard({ project }: { project: SetupProject }) {
314
+ function ProjectCard({ project, onRemove }: { project: SetupProject; onRemove?: (project: SetupProject) => void }) {
290
315
  const setupInfo = useSetup();
291
316
  const declared = setupInfo.steps.filter((step) => step.projectId === project.id);
292
317
  const runSteps = setupInfo.run?.steps.filter((step) => step.projectId === project.id) ?? [];
@@ -319,13 +344,14 @@ function ProjectCard({ project }: { project: SetupProject }) {
319
344
  ) : null}
320
345
  </div>
321
346
  </div>
347
+ {onRemove ? <button type="button" className="tbtn shrink-0 p-1.5 text-unresolved" onClick={() => onRemove(project)} aria-label={`Remove ${project.name}`} title={`Remove ${project.name}`}><Trash2 size={15} aria-hidden /></button> : null}
322
348
  </div>
323
349
 
324
350
  <dl className="mono mt-4 grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-1 text-muted">
325
- <dt>scope</dt><dd className="truncate text-ink">{[project.group ?? project.context, project.component ?? project.service].filter(Boolean).join(" · ") || "estate"}</dd>
351
+ <dt>scope</dt><dd className="truncate text-ink">{project.components?.length ? `${project.group ?? project.context} · ${project.components.length} components` : [project.group ?? project.context, project.component ?? project.service].filter(Boolean).join(" · ") || "estate"}</dd>
326
352
  <dt>pipeline</dt><dd className="text-ink">{declared.length} {plural(declared.length, "step")}</dd>
327
353
  <dt>fragments</dt><dd className="text-ink">{sources.length}</dd>
328
- <dt>commit</dt><dd className="truncate text-ink" title={commits.join(", ")}>{commits.length === 0 ? "not stamped" : commits.length === 1 ? commits[0] : `${commits.length} source commits`}</dd>
354
+ <dt>commit</dt><dd className="truncate text-ink" title={commits.join(", ")}>{commits.length === 0 ? "not stamped" : commits.length === 1 ? <CommitLink commit={commits[0]!} repository={project.repository} length={12} /> : `${commits.length} source commits`}</dd>
329
355
  </dl>
330
356
 
331
357
  <div className="mt-4 flex flex-wrap gap-1.5" aria-label="Active plugins">
@@ -476,6 +502,21 @@ function PluginsList() {
476
502
  }
477
503
 
478
504
  const FIELD = "mono w-full rounded-control border border-line bg-canvas px-3 py-2 text-ink outline-none focus:border-accent";
505
+ const PROJECT_RESUME_KEY = "portolan.onboarding.project-source.v1";
506
+ const PROJECT_PRESETS: Array<{ label: string; kind: NonNullable<ProjectDraft["componentKind"]>; summary: string }> = [
507
+ { label: "Service", kind: "service", summary: "A deployable business capability." },
508
+ { label: "Web app", kind: "webapp", summary: "A browser-facing application." },
509
+ { label: "Worker", kind: "worker", summary: "A long-running background consumer." },
510
+ { label: "CLI", kind: "cli", summary: "A command-line application." },
511
+ { label: "Library", kind: "library", summary: "Reusable code, not deployed alone." },
512
+ ];
513
+
514
+ function readProjectResume(): { source: ProjectSource; path: string; repository: string; ref: string; sourcePath: string } | null {
515
+ try {
516
+ const value = JSON.parse(localStorage.getItem(PROJECT_RESUME_KEY) ?? "null");
517
+ return value && (value.source === "local" || value.source === "external") ? value : null;
518
+ } catch { return null; }
519
+ }
479
520
 
480
521
  function Field({ label, value, onChange, placeholder, required = false }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; required?: boolean }) {
481
522
  return (
@@ -486,15 +527,59 @@ function Field({ label, value, onChange, placeholder, required = false }: { labe
486
527
  );
487
528
  }
488
529
 
489
- function AddProjectCard({ onClick }: { onClick: () => void }) {
530
+ function AddProjectCard({ onAdd }: { onAdd: (source: ProjectSource) => void }) {
490
531
  return (
491
- <button type="button" onClick={onClick} className="group flex min-h-52 items-center justify-center rounded-card border border-dashed border-line-strong bg-canvas p-card text-left shadow-xs transition-colors hover:border-accent hover:bg-surface">
492
- <span className="flex max-w-72 flex-col items-center text-center">
493
- <span className="flex size-9 items-center justify-center rounded-full border border-line-strong text-muted group-hover:border-accent group-hover:text-accent"><Plus size={18} aria-hidden /></span>
494
- <span className="mt-3 font-semibold text-ink">Add a project</span>
495
- <span className="mt-1 text-muted">Point Portolan at a local component or repository and it will suggest the extractors to use.</span>
496
- </span>
497
- </button>
532
+ <article className="rounded-card border border-line bg-canvas p-card shadow-xs xl:col-span-2">
533
+ <div className="flex flex-col gap-4 sm:flex-row sm:items-center">
534
+ <span className="flex size-10 shrink-0 items-center justify-center rounded-control border border-line-strong bg-surface text-accent">
535
+ <Plus size={19} aria-hidden />
536
+ </span>
537
+ <div className="min-w-0 flex-1">
538
+ <div className="font-semibold text-ink">Add a project</div>
539
+ <p className="mt-1 text-muted">Connect a local folder or Git repository. Portolan will detect the right extractors.</p>
540
+ </div>
541
+ <div className="flex flex-wrap gap-2 sm:justify-end">
542
+ <button type="button" className="tbtn px-3 py-1.5" onClick={() => onAdd("local")}>
543
+ <FolderGit2 size={15} aria-hidden /> Local folder
544
+ </button>
545
+ <button type="button" className="product-primary" onClick={() => onAdd("external")}>
546
+ <GitBranch size={15} aria-hidden /> Git repository
547
+ </button>
548
+ </div>
549
+ </div>
550
+ </article>
551
+ );
552
+ }
553
+
554
+ function OnboardingCard({ starter, onAdd, onRemove }: { starter?: SetupProject; onAdd: (source: ProjectSource) => void; onRemove: (project: SetupProject) => void }) {
555
+ return (
556
+ <article className="mb-grid overflow-hidden rounded-card border border-accent bg-canvas shadow-xs">
557
+ <div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4 border-b border-line bg-surface px-card py-3">
558
+ <div>
559
+ <div className="font-semibold text-ink">Start with your architecture</div>
560
+ <p className="mt-1 text-muted">Replace the starter catalog with the project you actually want to describe.</p>
561
+ </div>
562
+ <CatIllustration scene="onboarding" className="cat-onboarding-illustration" />
563
+ </div>
564
+ <div className="grid gap-0 md:grid-cols-2">
565
+ <div className="flex items-start gap-3 p-card md:border-r md:border-line">
566
+ <span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-accent text-canvas"><Plus size={15} aria-hidden /></span>
567
+ <div className="min-w-0 flex-1">
568
+ <div className="flex flex-wrap items-center gap-2"><span className="font-medium text-ink">Add your first project</span><span className="chip status-verified">recommended</span></div>
569
+ <p className="mt-1 text-muted">A successful trial replaces the starter and adds your project in one reviewed change.</p>
570
+ <div className="mt-3 flex flex-wrap gap-2"><button type="button" className="product-primary" onClick={() => onAdd("local")}><FolderGit2 size={14} aria-hidden /> Local folder</button><button type="button" className="tbtn" onClick={() => onAdd("external")}><GitBranch size={14} aria-hidden /> Git repository</button></div>
571
+ </div>
572
+ </div>
573
+ <div className="flex items-start gap-3 p-card">
574
+ <span className={`flex size-7 shrink-0 items-center justify-center rounded-full ${starter ? "bg-surface text-muted" : "bg-verified text-canvas"}`}>{starter ? <Trash2 size={14} aria-hidden /> : <Check size={15} aria-hidden />}</span>
575
+ <div className="min-w-0 flex-1">
576
+ <div className="font-medium text-ink">Or start with an empty catalog</div>
577
+ <p className="mt-1 text-muted">Remove the starter now if you want to connect projects later. Application source code is never touched.</p>
578
+ {starter ? <button type="button" className="tbtn mt-3 text-unresolved" onClick={() => onRemove(starter)}><Trash2 size={14} aria-hidden /> Remove starter only</button> : <div className="mono mt-2 text-verified">starter removed</div>}
579
+ </div>
580
+ </div>
581
+ </div>
582
+ </article>
498
583
  );
499
584
  }
500
585
 
@@ -546,8 +631,11 @@ function RepositoryFailure({ failure, message, token, onTokenChange, onForget, b
546
631
  );
547
632
  }
548
633
 
549
- function Wizard({ open, onClose, onAdded, onRunStarted }: { open: boolean; onClose: () => void; onAdded: (setup: SetupInfo) => void; onRunStarted: (runId: string) => void }) {
550
- const [source, setSource] = useState<"local" | "external">("local");
634
+ function Wizard({ open, initialSource, onClose, onAdded, onRunStarted }: { open: boolean; initialSource: ProjectSource; onClose: () => void; onAdded: (setup: SetupInfo) => void; onRunStarted: (runId: string) => void }) {
635
+ const setupInfo = useSetup();
636
+ const starter = starterProject(setupInfo);
637
+ const say = useToastStore((state) => state.say);
638
+ const [source, setSource] = useState<ProjectSource>(initialSource);
551
639
  const [stage, setStage] = useState<"source" | "scope" | "configure" | "trial">("source");
552
640
  const [path, setPath] = useState("");
553
641
  const [repository, setRepository] = useState("");
@@ -568,12 +656,26 @@ function Wizard({ open, onClose, onAdded, onRunStarted }: { open: boolean; onClo
568
656
  const [repositoryError, setRepositoryError] = useState<LocalApiError | null>(null);
569
657
  const [retryComponent, setRetryComponent] = useState<{ path: string; batch: boolean } | null>(null);
570
658
  const [credentialToken, setCredentialToken] = useState("");
659
+ const [resumed, setResumed] = useState(false);
571
660
 
572
661
  useEffect(() => {
662
+ if (open) {
663
+ setSource(initialSource);
664
+ const saved = readProjectResume();
665
+ if (saved?.source === initialSource) {
666
+ setPath(saved.path); setRepository(saved.repository); setRef(saved.ref); setSourcePath(saved.sourcePath); setResumed(true);
667
+ }
668
+ return;
669
+ }
573
670
  if (!open) {
574
- setStage("source"); setSource("local"); setPath(""); setRepository(""); setRef("main"); setSourcePath(""); setDiscovery(null); setScopeDiscovery(null); setInspectionCommit(""); setSelectedComponents([]); setPendingComponents([]); setBatchPosition(null); setDraft(null); setPlan(null); setTrialRunId(null); setTrialEvents([]); setError(""); setRepositoryError(null); setRetryComponent(null); setCredentialToken(""); setBusy(false);
671
+ setStage("source"); setSource("local"); setPath(""); setRepository(""); setRef("main"); setSourcePath(""); setDiscovery(null); setScopeDiscovery(null); setInspectionCommit(""); setSelectedComponents([]); setPendingComponents([]); setBatchPosition(null); setDraft(null); setPlan(null); setTrialRunId(null); setTrialEvents([]); setError(""); setRepositoryError(null); setRetryComponent(null); setCredentialToken(""); setResumed(false); setBusy(false);
575
672
  }
576
- }, [open]);
673
+ }, [initialSource, open]);
674
+
675
+ useEffect(() => {
676
+ if (!open || (!path.trim() && !repository.trim())) return;
677
+ try { localStorage.setItem(PROJECT_RESUME_KEY, JSON.stringify({ source, path, repository, ref, sourcePath })); } catch {}
678
+ }, [open, path, ref, repository, source, sourcePath]);
577
679
 
578
680
  useEffect(() => {
579
681
  if (!trialRunId) return;
@@ -593,6 +695,11 @@ function Wizard({ open, onClose, onAdded, onRunStarted }: { open: boolean; onClo
593
695
  clearRepositoryFailure();
594
696
  }
595
697
 
698
+ function startOver() {
699
+ try { localStorage.removeItem(PROJECT_RESUME_KEY); } catch {}
700
+ setSource(initialSource); setPath(""); setRepository(""); setRef("main"); setSourcePath(""); setResumed(false); clearRepositoryFailure();
701
+ }
702
+
596
703
  async function detect() {
597
704
  setBusy(true); setError("");
598
705
  setRepositoryError(null); setRetryComponent(null);
@@ -613,7 +720,9 @@ function Wizard({ open, onClose, onAdded, onRunStarted }: { open: boolean; onClo
613
720
 
614
721
  function configure(found: Discovery, commit: string, selectedSourcePath: string, projectId?: string) {
615
722
  setDiscovery(found);
616
- setDraft({ source, root: found.root, repository, ref, commit, sourcePath: selectedSourcePath, ...found.defaults, ...(projectId ? { id: projectId } : {}), plugins: found.detections.filter((item) => item.selected).map((item) => item.plugin) });
723
+ const plugins = found.detections.filter((item) => item.selected).map((item) => item.plugin);
724
+ const domainModel = plugins.some((plugin) => ["go-domain", "ts-domain", "rust-domain", "java-domain", "django-domain"].includes(plugin));
725
+ setDraft({ source, root: found.root, repository, ref, commit, sourcePath: selectedSourcePath, ...found.defaults, ...(projectId ? { id: projectId } : {}), contextName: found.defaults.group ? found.defaults.group.replace(/(^|-)([a-z])/g, (_, gap, letter) => `${gap ? " " : ""}${letter.toUpperCase()}`) : "", contextSummary: "", classification: "supporting", groupKind: domainModel ? "bounded-context" : "system", componentKind: domainModel ? "service" : "application", replaceStarter: Boolean(starter), plugins });
617
726
  setStage("configure");
618
727
  }
619
728
 
@@ -703,8 +812,19 @@ function Wizard({ open, onClose, onAdded, onRunStarted }: { open: boolean; onClo
703
812
  setTrialRunId(null); setTrialEvents([]); setPlan(null);
704
813
  await chooseComponent(next, true);
705
814
  } else {
815
+ try { localStorage.removeItem(PROJECT_RESUME_KEY); } catch {}
706
816
  onClose();
707
817
  if (result.run) onRunStarted(result.run.runId);
818
+ else say(`${result.project.name} added to portolan.json.`, {
819
+ label: "Undo",
820
+ run: () => { void (async () => {
821
+ try {
822
+ const restored = await undoProjectRemoval(result.undoToken);
823
+ onAdded(restored.setup);
824
+ say(`${result.project.name} addition undone.`);
825
+ } catch (cause) { say(cause instanceof Error ? cause.message : String(cause)); }
826
+ })(); },
827
+ });
708
828
  }
709
829
  } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); setBusy(false); }
710
830
  }
@@ -716,9 +836,17 @@ function Wizard({ open, onClose, onAdded, onRunStarted }: { open: boolean; onClo
716
836
  const trial = trialEvents.find((event): event is Extract<RunEvent, { type: "project-trial-ready" }> => event.type === "project-trial-ready");
717
837
  const logs = trialEvents.filter((event): event is Extract<RunEvent, { type: "log" }> => event.type === "log");
718
838
  const trialRunning = stage === "trial" && !finished;
839
+ const confirmedDeployables = discovery?.deployables.filter((candidate) => candidate.confidence === "high") ?? [];
840
+ const splitsDeployables = confirmedDeployables.length > 1 && draft?.component === discovery?.defaults.component;
841
+ const quality = trial && draft ? [
842
+ { label: "Stable project identity", done: Boolean(draft.id.trim() && draft.name.trim()) },
843
+ { label: "Architecture placement", done: Boolean(draft.group.trim() && (splitsDeployables || draft.component.trim())) },
844
+ { label: "Catalog facts extracted", done: trial.facts.some((fact) => fact.count > 0) },
845
+ { label: "Extraction without warnings", done: trial.warnings.length === 0 },
846
+ ] : [];
719
847
  const totalSteps = pipeline?.stepCount ?? plan?.steps.length ?? 0;
720
848
  const percent = totalSteps ? Math.round((completedSteps.length / totalSteps) * 100) : 0;
721
- const heading = stage === "source" ? "Add a project" : stage === "scope" ? "Choose a component" : stage === "configure" ? "Review discovery" : "Trial extraction";
849
+ const heading = stage === "source" ? "Add a project" : stage === "scope" ? "Choose a component" : stage === "configure" ? "Place the project" : "Trial extraction";
722
850
  function back() {
723
851
  if (stage === "trial") { if (trialRunId) void disposeProjectTrial(trialRunId); setStage("configure"); setTrialRunId(null); setTrialEvents([]); setPlan(null); }
724
852
  else if (stage === "configure" && scopeDiscovery) { setStage("scope"); setPendingComponents([]); setBatchPosition(null); }
@@ -729,14 +857,16 @@ function Wizard({ open, onClose, onAdded, onRunStarted }: { open: boolean; onClo
729
857
  <Modal open={open} onClose={busy || trialRunning ? () => {} : onClose} label={heading} width="min(800px,94vw)">
730
858
  <div className="flex items-center gap-3 border-b border-line px-5 py-4">
731
859
  {stage !== "source" ? <button type="button" className="tbtn p-1.5" onClick={back} disabled={trialRunning || busy} aria-label="Back"><ArrowLeft size={16} /></button> : null}
732
- <div className="min-w-0 flex-1"><div className="font-semibold text-ink">{heading}</div><div className="mono mt-0.5 text-muted">{stage === "source" ? "1 / 4 · source" : stage === "scope" ? "2 / 4 · scope" : stage === "configure" ? `3 / 4 · capabilities${batchPosition ? ` · ${batchPosition.current} / ${batchPosition.total}` : ""}` : `4 / 4 · proof${batchPosition ? ` · ${batchPosition.current} / ${batchPosition.total}` : ""}`}</div></div>
860
+ <div className="min-w-0 flex-1"><div className="font-semibold text-ink">{heading}</div><div className="mono mt-0.5 text-muted">{stage === "source" ? "1 / 4 · source" : stage === "scope" ? "2 / 4 · scope" : stage === "configure" ? `3 / 4 · identity & scope${batchPosition ? ` · ${batchPosition.current} / ${batchPosition.total}` : ""}` : `4 / 4 · proof${batchPosition ? ` · ${batchPosition.current} / ${batchPosition.total}` : ""}`}</div></div>
733
861
  <button type="button" className="tbtn p-1.5" onClick={onClose} disabled={busy || trialRunning} aria-label="Close"><X size={16} /></button>
734
862
  </div>
735
863
  <div className="min-h-0 flex-1 overflow-y-auto p-5">
736
864
  {stage === "source" ? (
737
865
  repositoryError ? <div className="flex flex-wrap items-center justify-between gap-3 rounded-control border border-line bg-surface px-3 py-2"><div className="min-w-0"><div className="mono truncate text-ink">{repository}</div><div className="mono mt-0.5 text-muted">{ref || "HEAD"}{sourcePath ? ` · ${sourcePath}` : ""}</div></div><button type="button" className="tbtn" onClick={clearRepositoryFailure}>Edit source</button></div> : <div className="space-y-5">
866
+ {resumed ? <div className="flex flex-wrap items-center justify-between gap-2 rounded-control border border-accent bg-surface px-3 py-2"><span className="text-muted"><span className="font-medium text-ink">Draft restored.</span> Continue with the source from your last session.</span><button type="button" className="tbtn" onClick={startOver}>Start over</button></div> : null}
738
867
  <div className="seg inline-flex" role="group" aria-label="Project source"><button type="button" className={source === "local" ? "is-on" : ""} aria-pressed={source === "local"} onClick={() => selectSource("local")}>local directory</button><button type="button" className={source === "external" ? "is-on" : ""} aria-pressed={source === "external"} onClick={() => selectSource("external")}>external repository</button></div>
739
868
  <p className="text-muted">Detection reads project files but does not execute project code. External repositories are pinned and vendored through the built-in git fetcher.</p>
869
+ <div className="rounded-control border border-line bg-surface px-3 py-2 text-muted"><span className="font-medium text-ink">Tip:</span> local paths are relative to this workspace. To connect a separate repository such as <span className="mono text-ink">aviaadmin</span>, choose external repository.</div>
740
870
  {source === "local" ? <><Field label="local path" value={path} onChange={setPath} placeholder="services/billing" required /><Field label="repository URL · optional" value={repository} onChange={(value) => { setRepository(value); clearRepositoryFailure(); }} placeholder="https://github.com/acme/billing" /></> : <><Field label="repository URL" value={repository} onChange={(value) => { setRepository(value); clearRepositoryFailure(); }} placeholder="https://github.com/acme/platform" required /><div className="grid gap-4 sm:grid-cols-2"><Field label="branch, tag or commit" value={ref} onChange={(value) => { setRef(value); clearRepositoryFailure(); }} placeholder="main" /><Field label="component path · optional" value={sourcePath} onChange={(value) => { setSourcePath(value); clearRepositoryFailure(); }} placeholder="services/billing" /></div></>}
741
871
  </div>
742
872
  ) : stage === "scope" && scopeDiscovery ? (
@@ -748,12 +878,10 @@ function Wizard({ open, onClose, onAdded, onRunStarted }: { open: boolean; onClo
748
878
  ) : stage === "configure" && discovery && draft ? (
749
879
  <div className="space-y-5">
750
880
  <div className="rounded-control border border-line bg-surface px-3 py-2 text-muted"><span className="mono text-ink">{draft.source === "external" ? `${draft.repository}@${draft.commit.slice(0, 7)}${draft.sourcePath ? `/${draft.sourcePath}` : ""}` : discovery.root}</span> · scanned {discovery.filesScanned} files{discovery.truncated ? " (limit reached)" : ""}</div>
751
- <div className="grid gap-4 sm:grid-cols-2">
752
- <Field label="project name" value={draft.name} onChange={(name) => setDraft({ ...draft, name })} required />
753
- <Field label="project id" value={draft.id} onChange={(id) => setDraft({ ...draft, id })} required />
754
- <Field label="group" value={draft.group} onChange={(group) => setDraft({ ...draft, group })} />
755
- <Field label="component slug" value={draft.component} onChange={(component) => setDraft({ ...draft, component })} />
756
- </div>
881
+ {starter ? <label className="flex cursor-pointer items-start gap-3 rounded-control border border-accent bg-surface px-3 py-3"><input className="mt-1" type="checkbox" checked={draft.replaceStarter ?? false} onChange={(event) => setDraft({ ...draft, replaceStarter: event.target.checked })} /><span><span className="font-medium text-ink">Replace {starter.name} starter after the trial succeeds</span><span className="mt-0.5 block text-muted">The new project and starter removal are written together, so a failed trial leaves the workspace unchanged.</span></span></label> : null}
882
+ {splitsDeployables ? <section><div className="label mb-2">detected deployables</div><div className="grid gap-2 sm:grid-cols-2">{confirmedDeployables.map((component) => <div key={component.slug} className="rounded-control border border-accent bg-surface px-3 py-2"><div className="flex items-center justify-between gap-2"><span className="font-medium text-ink">{component.name}</span><span className="chip status-verified">service</span></div><span className="mono mt-1 block text-muted">{component.path}</span><span className="mono mt-0.5 block truncate text-faint" title={component.evidence.join(", ")}>confirmed by {component.evidence.length} source/build files</span></div>)}</div><p className="mt-2 text-muted">These entrypoints share one repository root but will be catalogued as separate runtime components.</p></section> : <section><div className="label mb-2">component preset</div><div className="grid grid-cols-2 gap-2 md:grid-cols-3">{PROJECT_PRESETS.map((preset) => <button key={preset.kind} type="button" className={`rounded-control border px-3 py-2 text-left transition-colors ${draft.componentKind === preset.kind ? "border-accent bg-surface" : "border-line hover:bg-surface"}`} aria-pressed={draft.componentKind === preset.kind} onClick={() => setDraft({ ...draft, componentKind: preset.kind, ...(preset.kind === "service" ? { groupKind: "bounded-context" as const } : {}) })}><span className="font-medium text-ink">{preset.label}</span><span className="mt-0.5 block text-muted">{preset.summary}</span></button>)}</div></section>}
883
+ <section><div className="label mb-2">project identity</div><div className="grid gap-4 rounded-control border border-line p-4 sm:grid-cols-2"><Field label="display name" value={draft.name} onChange={(name) => setDraft({ ...draft, name })} required /><Field label="stable project id" value={draft.id} onChange={(id) => setDraft({ ...draft, id })} required /><p className="text-muted sm:col-span-2">The id connects this project to its extraction steps. Keep it stable even if the display name changes.</p></div></section>
884
+ <section><div className="mb-2 flex flex-wrap items-center justify-between gap-2"><div className="label">architecture placement</div><span className="chip status-declared">{draft.group || "group"} → {splitsDeployables ? `${confirmedDeployables.length} components` : draft.component || "component"}</span></div><div className="grid gap-4 rounded-control border border-line p-4 sm:grid-cols-2">{catalog.contexts.length ? <label className="block sm:col-span-2"><span className="label mb-1.5 block">start from</span><select className={FIELD} value={catalog.contexts.some((context) => context.id === draft.group) ? draft.group : "__new__"} onChange={(event) => { const existing = catalog.contexts.find((context) => context.id === event.target.value); setDraft(existing ? { ...draft, group: existing.id, contextName: existing.name, contextSummary: existing.summary, classification: existing.classification || "supporting", groupKind: existing.kind || "bounded-context" } : { ...draft, group: draft.id, contextName: draft.name, contextSummary: "", classification: "supporting", groupKind: "bounded-context" }); }}><option value="__new__">Create a new context or group</option>{catalog.contexts.map((context) => <option key={context.id} value={context.id}>Use {context.name} ({context.id})</option>)}</select></label> : null}<Field label="bounded context / group" value={draft.group} onChange={(group) => setDraft({ ...draft, group })} placeholder="avia" /><Field label="context name" value={draft.contextName ?? ""} onChange={(contextName) => setDraft({ ...draft, contextName })} placeholder="Aviation" /><label className="block sm:col-span-2"><span className="label mb-1.5 block">responsibility · optional</span><textarea className={`${FIELD} min-h-20 resize-y`} value={draft.contextSummary ?? ""} onChange={(event) => setDraft({ ...draft, contextSummary: event.target.value })} placeholder="What business decisions and language belong inside this boundary?" /></label>{splitsDeployables ? null : <><Field label="component slug" value={draft.component} onChange={(component) => setDraft({ ...draft, component })} placeholder="aviaadmin" /><label className="block"><span className="label mb-1.5 block">component kind</span><select className={FIELD} value={draft.componentKind ?? "application"} onChange={(event) => setDraft({ ...draft, componentKind: event.target.value as ProjectDraft["componentKind"] })}><option value="service">service</option><option value="application">application</option><option value="webapp">web app</option><option value="worker">worker</option><option value="job">job</option><option value="function">function</option><option value="cli">CLI</option><option value="library">library</option><option value="data-pipeline">data pipeline</option></select></label></>}<label className="block"><span className="label mb-1.5 block">group kind</span><select className={FIELD} value={draft.groupKind ?? "system"} onChange={(event) => setDraft({ ...draft, groupKind: event.target.value as ProjectDraft["groupKind"] })}><option value="bounded-context">bounded context</option><option value="system">system</option><option value="product">product</option><option value="team">team</option><option value="namespace">namespace</option></select></label>{draft.groupKind === "bounded-context" ? <label className="block"><span className="label mb-1.5 block">classification</span><select className={FIELD} value={draft.classification ?? "supporting"} onChange={(event) => setDraft({ ...draft, classification: event.target.value as ProjectDraft["classification"] })}><option value="core">core</option><option value="supporting">supporting</option><option value="generic">generic</option></select></label> : null}<p className="rounded-control bg-surface px-3 py-2 text-muted sm:col-span-2"><span className="font-medium text-ink">Tip:</span> {draft.groupKind === "bounded-context" ? "use one bounded context for components that share the same business language and rules — it does not have to match the folder tree." : "choose bounded context when this group owns a distinct business language; use system for a primarily technical boundary."}</p></div></section>
757
885
  <div><div className="label mb-2">detected capabilities</div>
758
886
  {discovery.detections.length ? <div className="grid gap-2">{discovery.detections.map((item) => {
759
887
  const checked = draft.plugins.includes(item.plugin);
@@ -765,16 +893,20 @@ function Wizard({ open, onClose, onAdded, onRunStarted }: { open: boolean; onClo
765
893
  ) : stage === "trial" && plan ? (
766
894
  <div className="space-y-5">
767
895
  <div className={`rounded-control border px-3 py-3 ${trial ? "border-verified bg-surface" : finished?.status === "failed" ? "border-unresolved" : "border-line bg-surface"}`}>
768
- <div className="flex items-start gap-3">{trial ? <Check size={18} className="mt-0.5 shrink-0 text-verified" /> : finished?.status === "failed" ? <CircleAlert size={18} className="mt-0.5 shrink-0 text-unresolved" /> : <LoaderCircle size={18} className="mt-0.5 shrink-0 animate-spin text-accent" />}<div><div className="font-medium text-ink">{trial ? "Extraction succeeded — repository unchanged" : finished?.status === "failed" ? "Trial extraction failed" : activeStep ? `Running ${activeStep.plugin}` : "Creating an isolated workspace…"}</div><p className="mt-0.5 text-muted">{trial ? "These results came from real catalog fragments. Apply is now safe to continue." : "Portolan is running the selected extractors without writing to portolan.json or your project."}</p></div></div>
896
+ <div className={trial ? "grid items-center gap-3 sm:grid-cols-[minmax(0,1fr)_120px]" : ""}>
897
+ <div className="flex items-start gap-3">{trial ? <Check size={18} className="mt-0.5 shrink-0 text-verified" /> : finished?.status === "failed" ? <CircleAlert size={18} className="mt-0.5 shrink-0 text-unresolved" /> : <LoaderCircle size={18} className="mt-0.5 shrink-0 animate-spin text-accent" />}<div><div className="font-medium text-ink">{trial ? "Extraction succeeded — repository unchanged" : finished?.status === "failed" ? "Trial extraction failed" : activeStep ? `Running ${activeStep.plugin}` : "Creating an isolated workspace…"}</div><p className="mt-0.5 text-muted">{trial ? "These results came from real catalog fragments. Apply is now safe to continue." : "Portolan is running the selected extractors without writing to portolan.json or your project."}</p></div></div>
898
+ {trial ? <CatIllustration scene="trial" className="cat-trial-illustration" /> : null}
899
+ </div>
769
900
  {trial?.previewUrl ? <a className="product-primary mt-3 inline-flex" href={trial.previewUrl} target="_blank" rel="noreferrer">Open catalog preview ↗</a> : null}
770
901
  {trial?.previewError ? <p className="mt-2 text-declared">The extraction is valid, but the temporary site could not start: {trial.previewError}</p> : null}
771
902
  {!finished ? <><div className="mt-3 h-1.5 overflow-hidden rounded-full bg-canvas"><div className="h-full bg-accent transition-[width]" style={{ width: `${percent}%` }} /></div><div className="mono mt-1 text-right text-muted">{completedSteps.length} / {totalSteps || "?"} steps</div></> : null}
772
903
  </div>
773
- {trial?.facts.length ? <section><div className="label mb-2">catalog facts found</div><div className="grid grid-cols-2 gap-2 sm:grid-cols-3">{trial.facts.map((fact) => <div key={fact.key} className="rounded-control border border-line bg-canvas px-3 py-2"><div className="tnum text-lg font-semibold text-ink">{fact.count}</div><div className="text-muted">{fact.label}</div></div>)}</div></section> : null}
904
+ {trial ? <section><div className="label mb-2">what Portolan found</div>{trial.facts.length ? <div className="grid grid-cols-2 gap-2 sm:grid-cols-3">{trial.facts.map((fact) => <div key={fact.key} className="rounded-control border border-line bg-canvas px-3 py-2"><div className="tnum text-lg font-semibold text-ink">{fact.count}</div><div className="text-muted">{fact.label}</div></div>)}</div> : <div className="rounded-control border border-line bg-surface px-3 py-3 text-muted">Only component metadata was found. You can add APIs, domain models and stores later without re-adding the project.</div>}</section> : null}
905
+ {trial ? <section><div className="label mb-2">catalog readiness</div><div className="grid gap-2 sm:grid-cols-2">{quality.map((item) => <div key={item.label} className="flex items-center gap-2 rounded-control border border-line px-3 py-2"><span className={`flex size-5 items-center justify-center rounded-full ${item.done ? "bg-verified text-canvas" : "bg-surface text-declared"}`}>{item.done ? <Check size={12} aria-hidden /> : <CircleAlert size={12} aria-hidden />}</span><span className={item.done ? "text-ink" : "text-muted"}>{item.label}</span></div>)}</div></section> : null}
774
906
  <section><div className="label mb-2">extractor results</div><div className="divide-y divide-line rounded-control border border-line">{(trial?.steps ?? completedSteps).map((step) => <div key={`${step.plugin}:${"ordinal" in step ? step.ordinal : "trial"}`} className="grid gap-1 px-3 py-2 sm:grid-cols-[1fr_auto_auto]"><span><span className="font-medium text-ink">{CAPABILITIES[step.plugin]?.title ?? step.plugin}</span><span className="mono ml-2 text-faint">{step.plugin}</span>{step.message ? <span className="mt-1 block text-unresolved">{step.message}</span> : null}</span><span className="mono text-muted">{step.fileCount} {plural(step.fileCount, "file")}</span><span className={`chip ${step.status === "failed" ? "status-unresolved" : "status-verified"}`}>{step.status === "failed" ? "failed" : "read"}</span></div>)}</div></section>
775
907
  {trial?.warnings.length ? <section><div className="label mb-2">warnings · {trial.warnings.length}</div><div className="space-y-2">{trial.warnings.map((warning, index) => <div key={`${warning.plugin}:${index}`} className="flex gap-2 rounded-control border border-declared px-3 py-2 text-muted"><CircleAlert size={15} className="mt-0.5 shrink-0 text-declared" /><span><span className="mono text-ink">{warning.plugin}</span> · {warning.message}</span></div>)}</div></section> : null}
776
908
  {finished?.status === "failed" && logs.length ? <details><summary className="cursor-pointer text-muted">Generator log · {logs.length} lines</summary><pre className="mono mt-2 max-h-48 overflow-auto whitespace-pre-wrap rounded-control bg-surface p-3 text-muted">{logs.map((event) => event.message).join("\n")}</pre></details> : null}
777
- {trial ? <div><div className="label mb-2">changes after apply</div><dl className="mono grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-2 text-muted"><dt>project</dt><dd className="text-ink">{plan.project.id}</dd>{plan.fetch ? <><dt>pin</dt><dd className="truncate text-ink">{plan.fetch.commit.slice(0, 12)}</dd></> : null}<dt>source</dt><dd className="truncate text-ink">{plan.source}</dd><dt>pipeline</dt><dd className="text-ink">{plan.steps.length + (plan.fetch ? 1 : 0)} {plural(plan.steps.length + (plan.fetch ? 1 : 0), "extract step")}</dd><dt>fragments</dt><dd className="text-ink">{trial.generatedFiles} generated</dd></dl></div> : null}
909
+ {trial ? <div><div className="label mb-2">changes after apply</div><dl className="mono grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-2 text-muted"><dt>project</dt><dd className="text-ink">{plan.project.id}</dd>{plan.fetch ? <><dt>pin</dt><dd className="truncate text-ink"><CommitLink commit={plan.fetch.commit} repository={plan.fetch.repo} length={12} /></dd></> : null}<dt>source</dt><dd className="truncate text-ink">{plan.source}</dd><dt>pipeline</dt><dd className="text-ink">{plan.steps.length + (plan.fetch ? 1 : 0)} {plural(plan.steps.length + (plan.fetch ? 1 : 0), "extract step")}</dd><dt>fragments</dt><dd className="text-ink">{trial.generatedFiles} generated</dd></dl></div> : null}
778
910
  </div>
779
911
  ) : null}
780
912
  {error ? repositoryError ? <RepositoryFailure failure={repositoryError} message={error} token={credentialToken} onTokenChange={setCredentialToken} onForget={() => void forgetCredential()} busy={busy} /> : <div role="alert" className="mt-4 rounded-control border border-unresolved px-3 py-2 text-unresolved">{error}</div> : null}
@@ -822,7 +954,7 @@ function RunDialog({ runId, open, onClose, onFinished, onApply }: { runId: strin
822
954
  {preview ? <section className="mt-5"><div className="label mb-2">generated diff · {preview.totalFiles} {plural(preview.totalFiles, "file")}</div>{preview.files.length ? <div className="divide-y divide-line overflow-hidden rounded-control border border-line">{preview.files.map((file) => <details key={file.path} className="group"><summary className="flex cursor-pointer items-center gap-2 px-3 py-2 hover:bg-surface"><span className={`chip ${file.status === "removed" ? "status-unresolved" : file.status === "added" ? "status-verified" : "status-declared"}`}>{file.status}</span><span className="mono truncate text-ink">{file.path}</span><ChevronDown size={15} className="ml-auto shrink-0 text-muted transition-transform group-open:rotate-180" /></summary><pre className="mono max-h-80 overflow-auto whitespace-pre p-3 text-muted bg-surface">{file.diff || "Binary file changed"}</pre></details>)}</div> : <div className="rounded-control border border-line bg-surface px-3 py-3 text-muted">Generated documentation is already up to date.</div>}{preview.truncated ? <p className="mt-2 text-muted">Showing the first {preview.files.length} changed files.</p> : null}</section> : null}
823
955
  {logs.length ? <details className="mt-4"><summary className="cursor-pointer text-muted">Generator log · {logs.length} lines</summary><pre className="mono mt-2 max-h-48 overflow-auto whitespace-pre-wrap rounded-control bg-surface p-3 text-muted">{logs.map((event) => event.type === "log" ? event.message : "").join("\n")}</pre></details> : null}
824
956
  </div>
825
- <div className="flex justify-end gap-2 border-t border-line px-5 py-4">{finished ? <><button type="button" className="tbtn" onClick={onClose}>{preview?.files.length ? "Cancel" : "Done"}</button>{preview?.files.length && finished.status === "ok" && runId ? <button type="button" className="product-primary" onClick={() => onApply(runId)}><Play size={15} /> Apply changes</button> : null}</> : <button type="button" className="tbtn" onClick={() => runId && void cancelGeneration(runId)}>Cancel generation</button>}</div>
957
+ <div className="flex flex-wrap justify-end gap-2 border-t border-line px-5 py-4">{finished ? <>{run?.mode === "write" && finished.status === "ok" ? <><Link to={`${paths.map()}?tour=1`} className="product-primary" onClick={onClose}>Explore the map</Link><Link to={paths.settingsDelivery()} className="tbtn" onClick={onClose}>Set up publishing</Link></> : null}<button type="button" className="tbtn" onClick={onClose}>{preview?.files.length ? "Cancel" : "Done"}</button>{preview?.files.length && finished.status === "ok" && runId ? <button type="button" className="product-primary" onClick={() => onApply(runId)}><Play size={15} /> Apply changes</button> : null}</> : <button type="button" className="tbtn" onClick={() => runId && void cancelGeneration(runId)}>Cancel generation</button>}</div>
826
958
  </Modal>
827
959
  );
828
960
  }
@@ -833,6 +965,7 @@ const SETTINGS_LINKS = [
833
965
  ["Pipeline", paths.settingsPipeline()],
834
966
  ["Delivery", paths.settingsDelivery()],
835
967
  ["Preferences", paths.settingsPreferences()],
968
+ ["About", paths.settingsAbout()],
836
969
  ] as const;
837
970
 
838
971
  function SettingsNav() {
@@ -860,11 +993,49 @@ function SettingsNav() {
860
993
  );
861
994
  }
862
995
 
863
- function OverviewSettings() {
996
+ function GettingStarted({ onGenerate }: { onGenerate: () => void }) {
997
+ const setupInfo = useSetup();
998
+ const starter = starterProject(setupInfo);
999
+ const ownProjects = starter ? [] : setupInfo.projects;
1000
+ const connected = ownProjects.length > 0;
1001
+ const placed = connected && ownProjects.some((project) => (project.group ?? project.context) && (project.component ?? project.service));
1002
+ const generated = placed && Boolean(setupInfo.run && !setupInfo.reportStale && setupInfo.run.status === "ok");
1003
+ const steps = [
1004
+ { label: "Clear the starter", detail: "Remove the placeholder project and catalog.", done: !starter },
1005
+ { label: "Connect a project", detail: placed ? "Project and architecture placement are configured." : "Choose a source and place it in a bounded context.", done: placed },
1006
+ { label: "Review the result", detail: "Preview and apply the generated documentation.", done: generated },
1007
+ ];
1008
+ const completed = steps.filter((step) => step.done).length;
1009
+ if (completed === steps.length) return null;
1010
+ const tip = starter
1011
+ ? "The starter is only configuration and generated catalog data. Removing it never touches application source code."
1012
+ : !connected
1013
+ ? "Start with one deployable component. A useful small catalog is easier to grow than a perfect estate-wide model."
1014
+ : !placed
1015
+ ? "A bounded context describes a language and responsibility boundary, not necessarily a directory or team."
1016
+ : "Preview first: Portolan shows the exact generated-file diff before anything is applied.";
1017
+ return (
1018
+ <section className="mb-section overflow-hidden rounded-card border border-accent bg-canvas shadow-xs">
1019
+ <div className="flex flex-wrap items-start justify-between gap-3 border-b border-line bg-surface px-card py-3">
1020
+ <div><div className="font-semibold text-ink">Getting started</div><p className="mt-1 text-muted">One path from an empty workspace to useful architecture documentation.</p></div>
1021
+ <div className="mono text-muted">{completed} / {steps.length} complete</div>
1022
+ </div>
1023
+ <div className="grid lg:grid-cols-[minmax(0,1.45fr)_minmax(16rem,0.75fr)]">
1024
+ <ol className="divide-y divide-line lg:border-r lg:border-line">
1025
+ {steps.map((step, index) => <li key={step.label} className="flex items-start gap-3 px-card py-3"><span className={`mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full ${step.done ? "bg-verified text-canvas" : index === completed ? "bg-accent text-canvas" : "bg-surface text-muted"}`}>{step.done ? <Check size={14} aria-hidden /> : index + 1}</span><span><span className="font-medium text-ink">{step.label}</span><span className="mt-0.5 block text-muted">{step.detail}</span></span></li>)}
1026
+ </ol>
1027
+ <div className="flex flex-col justify-between gap-4 p-card"><div><div className="label mb-2">tip for this step</div><p className="text-muted">{tip}</p></div>{starter || !placed ? <Link to={paths.settingsProjects()} className="product-primary self-start">{starter ? "Replace starter" : "Add a project"}</Link> : <button type="button" className="product-primary self-start" onClick={onGenerate}><Play size={15} aria-hidden /> Preview generated diff</button>}</div>
1028
+ </div>
1029
+ </section>
1030
+ );
1031
+ }
1032
+
1033
+ function OverviewSettings({ local, onGenerate }: { local: boolean; onGenerate: () => void }) {
864
1034
  const setupInfo = useSetup();
865
1035
  const active = setupInfo.plugins.filter((plugin) => plugin.stepCount > 0);
866
1036
  return (
867
1037
  <>
1038
+ {local ? <GettingStarted onGenerate={onGenerate} /> : null}
868
1039
  <BuildHealth />
869
1040
  <div className="mt-4 grid grid-cols-2 gap-grid lg:grid-cols-4">
870
1041
  <Metric value={setupInfo.projects.length} label="project" />
@@ -894,12 +1065,14 @@ function OverviewSettings() {
894
1065
  );
895
1066
  }
896
1067
 
897
- function ProjectsSettings({ local, onAdd }: { local: boolean; onAdd: () => void }) {
1068
+ function ProjectsSettings({ local, onAdd, onRemove }: { local: boolean; onAdd: (source: ProjectSource) => void; onRemove: (project: SetupProject) => void }) {
898
1069
  const setupInfo = useSetup();
1070
+ const starter = starterProject(setupInfo);
899
1071
  return (
900
1072
  <section>
901
- <SectionTitle right={local ? "editable in local mode" : "declared in portolan.json"}>Projects</SectionTitle>
902
- {setupInfo.projects.length === 0 && !local ? <Empty>portolan.json names no projects — every input here is the estate's own</Empty> : <div className="grid gap-grid xl:grid-cols-2">{setupInfo.projects.map((project) => <ProjectCard key={project.id} project={project} />)}{local ? <AddProjectCard onClick={onAdd} /> : null}</div>}
1073
+ <SectionTitle right={local ? <div className="flex items-center gap-3"><span className="hidden sm:inline">editable in local mode</span><button type="button" className="tbtn text-ink" onClick={() => onAdd("local")}><Plus size={14} aria-hidden /> Add project</button></div> : "declared in portolan.json"}>Projects</SectionTitle>
1074
+ {local && (starter || setupInfo.projects.length === 0) ? <OnboardingCard starter={starter} onAdd={onAdd} onRemove={onRemove} /> : null}
1075
+ {setupInfo.projects.length === 0 && !local ? <CatEmptyState scene="onboarding" title="No projects connected">portolan.json names no projects — every input here is the estate's own.</CatEmptyState> : <div className="grid gap-grid xl:grid-cols-2">{setupInfo.projects.map((project) => <ProjectCard key={project.id} project={project} onRemove={local ? onRemove : undefined} />)}{local ? <AddProjectCard onAdd={onAdd} /> : null}</div>}
903
1076
  </section>
904
1077
  );
905
1078
  }
@@ -911,8 +1084,23 @@ function PipelineSettings() {
911
1084
  <div className="space-y-section">
912
1085
  <section>
913
1086
  <SectionTitle right={`${active.length} of ${setupInfo.plugins.length} active`}>Plugins</SectionTitle>
914
- <PluginsList />
915
- <p className="mono mt-2 text-muted">WASM runs without network or environment access, and a generator without a filesystem. A host process runs with the permissions of the build; a plugin in the host is Portolan's own code doing what needs a socket, such as fetching another repository.</p>
1087
+ {active.length === 0 ? (
1088
+ <CapabilityEmpty
1089
+ title={setupInfo.projects.length === 0 ? "Connect a project to choose extractors" : "No extractors are active"}
1090
+ signal="Project detection selects extractors from the files and contracts it can prove are present."
1091
+ actions={<Link className="product-primary" to={paths.settingsProjects()}>{setupInfo.projects.length === 0 ? "Connect a project" : "Review projects"}</Link>}
1092
+ >
1093
+ {setupInfo.projects.length === 0
1094
+ ? "The pipeline is assembled from project evidence, so it starts after the first source is connected."
1095
+ : "The projects are configured, but no pipeline step currently reads them. Re-run project detection and review the proposed capabilities."
1096
+ }
1097
+ </CapabilityEmpty>
1098
+ ) : (
1099
+ <>
1100
+ <PluginsList />
1101
+ <p className="mono mt-2 text-muted">WASM runs without network or environment access, and a generator without a filesystem. A host process runs with the permissions of the build; a plugin in the host is Portolan's own code doing what needs a socket, such as fetching another repository.</p>
1102
+ </>
1103
+ )}
916
1104
  </section>
917
1105
  <details className="rounded-card border border-line shadow-xs">
918
1106
  <summary className="cursor-pointer select-none px-4 py-3 font-semibold text-ink">Advanced build inputs</summary>
@@ -929,7 +1117,126 @@ function PipelineSettings() {
929
1117
  );
930
1118
  }
931
1119
 
932
- function SettingsContent({ local, onAdd, onGenerate }: { local: boolean; onAdd: () => void; onGenerate: () => void }) {
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
+
1236
+ function SettingsContent({ local, onAdd, onRemove, onGenerate }: { local: boolean; onAdd: (source: ProjectSource) => void; onRemove: (project: SetupProject) => void; onGenerate: () => void }) {
1237
+ const pathname = useLocation().pathname.replace(/\/$/, "");
1238
+ const overview = pathname === paths.settings();
1239
+ const about = pathname === paths.settingsAbout();
933
1240
  return (
934
1241
  <div className="h-full overflow-y-auto p-gutter">
935
1242
  <div className="max-w-table">
@@ -938,20 +1245,21 @@ function SettingsContent({ local, onAdd, onGenerate }: { local: boolean; onAdd:
938
1245
  <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>
939
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>
940
1247
  </div>
941
- {local ? <button type="button" className="product-primary" onClick={onGenerate}><Play size={15} /> Preview generated diff</button> : null}
1248
+ {local && !overview && !about ? <button type="button" className="product-primary" onClick={onGenerate}><Play size={15} /> Preview generated diff</button> : null}
942
1249
  </div>
943
1250
  <SettingsNav />
944
1251
  <div className="mt-section">
945
1252
  <Routes>
946
- <Route index element={<OverviewSettings />} />
947
- <Route path="projects" element={<ProjectsSettings local={local} onAdd={onAdd} />} />
1253
+ <Route index element={<OverviewSettings local={local} onGenerate={onGenerate} />} />
1254
+ <Route path="projects" element={<ProjectsSettings local={local} onAdd={onAdd} onRemove={onRemove} />} />
948
1255
  <Route path="pipeline" element={<PipelineSettings />} />
949
1256
  <Route path="delivery" element={<section><SectionTitle right={local ? "preview before writing" : "local mode required"}>Delivery presets</SectionTitle><DeliverySettings local={local} /></section>} />
950
1257
  <Route path="preferences" element={<PreferencesSettings />} />
1258
+ <Route path="about" element={<AboutSettings />} />
951
1259
  <Route path="*" element={<Navigate to={paths.settings()} replace />} />
952
1260
  </Routes>
953
1261
  </div>
954
- <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>
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}
955
1263
  </div>
956
1264
  </div>
957
1265
  );
@@ -964,9 +1272,11 @@ export function Settings() {
964
1272
  // No local server means no local mode; the static build-time setup stands in.
965
1273
  const local = status.isSuccess;
966
1274
  const setup = status.data?.setup ?? staticSetupInfo;
967
- const [wizard, setWizard] = useState(false);
1275
+ const [wizardSource, setWizardSource] = useState<ProjectSource | null>(null);
968
1276
  const [runId, setRunId] = useState<string | null>(null);
969
1277
  const [runOpen, setRunOpen] = useState(false);
1278
+ const [removeTarget, setRemoveTarget] = useState<SetupProject | null>(null);
1279
+ const [removeBusy, setRemoveBusy] = useState(false);
970
1280
  const say = useToastStore((state) => state.say);
971
1281
  const activeRunId = status.data?.activeRun?.id ?? null;
972
1282
  useEffect(() => {
@@ -990,10 +1300,38 @@ export function Settings() {
990
1300
  say(cause instanceof Error ? cause.message : String(cause));
991
1301
  }
992
1302
  }
1303
+ async function confirmRemove() {
1304
+ if (!removeTarget) return;
1305
+ setRemoveBusy(true);
1306
+ try {
1307
+ const result = await removeLocalProject(removeTarget.id);
1308
+ setSetup(result.setup);
1309
+ setRemoveTarget(null);
1310
+ say(`${result.project.name} removed from portolan.json.`, {
1311
+ label: "Undo",
1312
+ run: () => { void (async () => {
1313
+ try {
1314
+ const restored = await undoProjectRemoval(result.undoToken);
1315
+ setSetup(restored.setup);
1316
+ say(`${result.project.name} restored.`);
1317
+ } catch (cause) { say(cause instanceof Error ? cause.message : String(cause)); }
1318
+ })(); },
1319
+ });
1320
+ } catch (cause) {
1321
+ say(cause instanceof Error ? cause.message : String(cause));
1322
+ } finally {
1323
+ setRemoveBusy(false);
1324
+ }
1325
+ }
993
1326
  return (
994
1327
  <SetupContext.Provider value={setup}>
995
- <SettingsContent local={local} onAdd={() => setWizard(true)} onGenerate={() => void generate()} />
996
- <Wizard open={wizard} onClose={() => setWizard(false)} onAdded={setSetup} onRunStarted={(id) => { setRunId(id); setRunOpen(true); }} />
1328
+ <SettingsContent local={local} onAdd={setWizardSource} onRemove={setRemoveTarget} onGenerate={() => void generate()} />
1329
+ <Wizard open={wizardSource !== null} initialSource={wizardSource ?? "local"} onClose={() => setWizardSource(null)} onAdded={setSetup} onRunStarted={(id) => { setRunId(id); setRunOpen(true); }} />
1330
+ <Modal open={removeTarget !== null} onClose={removeBusy ? () => {} : () => setRemoveTarget(null)} label={`Remove ${removeTarget?.name ?? "project"}`} width="min(520px,92vw)">
1331
+ <div className="border-b border-line px-5 py-4"><div className="font-semibold text-ink">Remove {removeTarget?.name}?</div></div>
1332
+ <div className="p-5"><p className="text-muted">This removes the project, its extraction steps and its dedicated catalog configuration from <span className="mono text-ink">portolan.json</span>. Your source code stays untouched.</p><p className="mt-3 text-muted">You can undo immediately. Generated documentation changes remain pending until you preview and apply them.</p></div>
1333
+ <div className="flex justify-end gap-2 border-t border-line px-5 py-4"><button type="button" className="tbtn" onClick={() => setRemoveTarget(null)} disabled={removeBusy}>Cancel</button><button type="button" className="product-primary" onClick={() => void confirmRemove()} disabled={removeBusy}>{removeBusy ? <LoaderCircle size={15} className="animate-spin" /> : <Trash2 size={15} />} Remove project</button></div>
1334
+ </Modal>
997
1335
  <RunDialog runId={runId} open={runOpen} onClose={() => setRunOpen(false)} onFinished={refresh} onApply={(preview) => void generate(preview)} />
998
1336
  </SetupContext.Provider>
999
1337
  );