@shortlink-org/portolan 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/README.md +7 -2
  2. package/cli/init.mjs +14 -6
  3. package/cli/init.test.mjs +8 -1
  4. package/cli/portolan.mjs +92 -13
  5. package/cli/portolan.test.mjs +47 -0
  6. package/package.json +7 -6
  7. package/plugins/extract-django/README.md +1 -1
  8. package/plugins/extract-django/extract_test.py +1 -1
  9. package/plugins/extract-django/openapi.py +4 -14
  10. package/plugins/extract-django/openapi_test.py +4 -4
  11. package/plugins/extract-go/options.schema.json +5 -0
  12. package/plugins/extract-project/options.schema.json +21 -0
  13. package/plugins/extract-ts/README.md +1 -1
  14. package/plugins/extract-ts/clients.ts +1 -1
  15. package/plugins/extract-ts/extract.test.ts +2 -2
  16. package/plugins/extract-ts/graphql.test.ts +2 -2
  17. package/plugins/extract-ts/openapi.test.ts +6 -7
  18. package/plugins/extract-ts/openapi.ts +3 -10
  19. package/plugins/portolan-go.wasm +0 -0
  20. package/public/404-cat-v1.webp +0 -0
  21. package/public/cat-about-v1.webp +0 -0
  22. package/public/cat-clear-skies-v1.webp +0 -0
  23. package/public/cat-no-changes-v1.webp +0 -0
  24. package/public/cat-onboarding-v1.webp +0 -0
  25. package/public/cat-trial-success-v1.webp +0 -0
  26. package/schema/portolan.schema.json +61 -0
  27. package/scripts/builtin-plugins.mjs +3 -2
  28. package/scripts/catalog-sources.mjs +14 -2
  29. package/scripts/catalog-sources.test.mjs +45 -0
  30. package/scripts/delivery-presets.mjs +207 -24
  31. package/scripts/diff.mjs +5 -1
  32. package/scripts/gen-likec4.mjs +242 -85
  33. package/scripts/gen-likec4.test.mjs +168 -14
  34. package/scripts/gen.mjs +5 -3
  35. package/scripts/host-plugins/fetch-git.mjs +22 -12
  36. package/scripts/host-plugins/fetch-git.test.mjs +25 -5
  37. package/scripts/local-api.mjs +330 -346
  38. package/scripts/local-api.test.mjs +245 -1
  39. package/scripts/local-discovery.mjs +378 -0
  40. package/scripts/manifest.mjs +42 -1
  41. package/scripts/manifest.test.mjs +24 -1
  42. package/scripts/plugin-host.mjs +14 -3
  43. package/scripts/plugin-host.test.mjs +8 -0
  44. package/scripts/run-builtin.mjs +4 -2
  45. package/scripts/schema.mjs +11 -0
  46. package/scripts/site-docs.mjs +2 -2
  47. package/src/app/CatalogApp.tsx +23 -8
  48. package/src/app/CommandPalette.tsx +7 -2
  49. package/src/app/Sidebar.tsx +13 -730
  50. package/src/app/SidebarFlowSections.tsx +251 -0
  51. package/src/app/SidebarFooter.tsx +160 -0
  52. package/src/app/SidebarTree.tsx +322 -0
  53. package/src/app/toast.tsx +11 -7
  54. package/src/catalog-index.ts +485 -0
  55. package/src/catalog-model.ts +1339 -0
  56. package/src/catalog-validation.ts +1570 -0
  57. package/src/catalog.test.ts +49 -0
  58. package/src/catalog.ts +6 -3124
  59. package/src/components/CatIllustration.tsx +30 -0
  60. package/src/components/CommitLink.tsx +154 -0
  61. package/src/components/EditorLink.tsx +15 -8
  62. package/src/components/GrpcMethodReference.tsx +93 -0
  63. package/src/components/Markdown.tsx +17 -0
  64. package/src/components/MethodRows.tsx +8 -1
  65. package/src/components/PageHeader.test.tsx +23 -0
  66. package/src/components/PageHeader.tsx +34 -0
  67. package/src/components/ProblemRow.tsx +3 -0
  68. package/src/components/ShapeRows.tsx +12 -0
  69. package/src/components/SourceDoc.tsx +27 -12
  70. package/src/components/SourcePreview.tsx +125 -37
  71. package/src/enrich.test.ts +209 -2
  72. package/src/enrich.ts +284 -15
  73. package/src/er/RedisSchema.tsx +86 -5
  74. package/src/flow/FlowTable.tsx +19 -4
  75. package/src/flow/StepDetail.tsx +461 -101
  76. package/src/flow/StepRail.tsx +36 -13
  77. package/src/flow/answers-response.test.ts +100 -0
  78. package/src/flow/answers.ts +11 -8
  79. package/src/flow/chapters.ts +2 -1
  80. package/src/flow/labels.ts +10 -0
  81. package/src/flow/mermaid.test.ts +87 -3
  82. package/src/flow/mermaid.ts +26 -14
  83. package/src/index.css +132 -17
  84. package/src/landing/LandingPage.tsx +16 -12
  85. package/src/lib/all-problems.ts +1 -1
  86. package/src/lib/api.test.ts +4 -6
  87. package/src/lib/build-info.test.ts +29 -0
  88. package/src/lib/build-info.ts +21 -0
  89. package/src/lib/data-model.ts +14 -0
  90. package/src/lib/derive.ts +1 -0
  91. package/src/lib/flow-tree.ts +1 -1
  92. package/src/lib/github-catalog.test.ts +51 -0
  93. package/src/lib/github-catalog.ts +69 -0
  94. package/src/lib/grpc-reference.test.ts +72 -0
  95. package/src/lib/grpc-reference.ts +117 -0
  96. package/src/lib/local-api.ts +36 -11
  97. package/src/lib/motion.test.ts +4 -2
  98. package/src/lib/motion.tsx +5 -4
  99. package/src/lib/product.ts +7 -0
  100. package/src/lib/proto-problems.test.ts +170 -3
  101. package/src/lib/proto-problems.ts +176 -4
  102. package/src/lib/queries.ts +16 -1
  103. package/src/lib/readme-assets.test.ts +19 -0
  104. package/src/lib/readme-assets.ts +34 -0
  105. package/src/lib/setup-info.test.ts +7 -0
  106. package/src/lib/setup-info.ts +5 -0
  107. package/src/lib/source-link.test.ts +106 -18
  108. package/src/lib/source-link.ts +22 -6
  109. package/src/merge.test.ts +101 -0
  110. package/src/merge.ts +65 -1
  111. package/src/pages/AdrDetail.tsx +3 -2
  112. package/src/pages/AdrIndex.tsx +10 -7
  113. package/src/pages/AggregatePage.tsx +34 -4
  114. package/src/pages/BlockPage.tsx +39 -32
  115. package/src/pages/Changes.tsx +7 -9
  116. package/src/pages/ContextMap.tsx +11 -2
  117. package/src/pages/ContextPage.tsx +11 -4
  118. package/src/pages/FlowIndex.tsx +16 -7
  119. package/src/pages/GraphPage.tsx +21 -7
  120. package/src/pages/Language.tsx +9 -9
  121. package/src/pages/NotFound.tsx +58 -15
  122. package/src/pages/Overview.tsx +6 -0
  123. package/src/pages/Problems.tsx +10 -8
  124. package/src/pages/RegistryIndex.tsx +10 -8
  125. package/src/pages/ServicePage.tsx +1 -1
  126. package/src/pages/Settings.tsx +257 -44
  127. package/src/pages/settings/AboutSettings.tsx +129 -0
  128. package/src/pages/settings/DeliverySettings.tsx +71 -15
  129. package/src/routes.test.ts +2 -0
  130. package/src/routes.ts +2 -1
  131. package/src/selection/DetailPanel.tsx +213 -113
  132. package/src/selection/pages.test.ts +14 -1
  133. package/src/selection/pages.ts +9 -3
  134. package/src/testing/setup.ts +14 -0
  135. package/vite.config.ts +3 -1
@@ -1,6 +1,6 @@
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
6
  Box,
@@ -8,6 +8,7 @@ import {
8
8
  ChevronDown,
9
9
  CircleAlert,
10
10
  FolderGit2,
11
+ GitBranch,
11
12
  KeyRound,
12
13
  LoaderCircle,
13
14
  Play,
@@ -37,23 +38,29 @@ import {
37
38
  forgetRepositoryCredential,
38
39
  inspectRepository,
39
40
  LocalApiError,
41
+ removeProject as removeLocalProject,
40
42
  saveRepositoryCredential,
41
43
  startGeneration,
42
44
  startProjectTrial,
43
45
  subscribeToRun,
46
+ undoProjectRemoval,
44
47
  } from "../lib/local-api";
45
48
  import type { Discovery, ProjectDraft, ProjectPlan, RunEvent } from "../lib/local-api";
46
49
  import { useQuery, useQueryClient } from "@tanstack/react-query";
47
50
  import { localKeys, localStatusQuery } from "../lib/queries";
48
51
  import { sourceHref, treeHref } from "../lib/source-link";
49
52
  import { paths } from "../routes";
50
- import { Empty, SectionTitle } from "../components/PageHeader";
53
+ import { CapabilityEmpty, Empty, SectionTitle } from "../components/PageHeader";
51
54
  import { Modal } from "../components/Overlay";
52
55
  import { MachineDocs } from "../components/MachineDocs";
53
56
  import { DeliverySettings } from "./settings/DeliverySettings";
54
57
  import { PreferencesSettings } from "./settings/PreferencesSettings";
58
+ import { AboutSettings } from "./settings/AboutSettings";
59
+ import { CatEmptyState, CatIllustration } from "../components/CatIllustration";
60
+ import { CommitLink } from "../components/CommitLink";
55
61
 
56
62
  type Health = "healthy" | "changed" | "failed" | "unchecked";
63
+ type ProjectSource = ProjectDraft["source"];
57
64
 
58
65
  const SetupContext = createContext<SetupInfo>(staticSetupInfo);
59
66
  const useSetup = () => useContext(SetupContext);
@@ -205,6 +212,7 @@ function BuildHealth() {
205
212
 
206
213
  function projectHref(project: SetupProject): string | null {
207
214
  const group = project.group ?? project.context;
215
+ if (project.components?.length) return group && catalog.contexts.some((item) => item.id === group) ? paths.context(group) : null;
208
216
  const component = project.component ?? project.service;
209
217
  const context = catalog.contexts.find((item) => item.id === group);
210
218
  if (!context) return null;
@@ -217,6 +225,13 @@ function projectHref(project: SetupProject): string | null {
217
225
  return service ? paths.service(context.id, service.slug) : paths.context(context.id);
218
226
  }
219
227
 
228
+ function starterProject(setupInfo: SetupInfo): SetupProject | undefined {
229
+ if (setupInfo.projects.length !== 1) return undefined;
230
+ const project = setupInfo.projects[0];
231
+ const steps = setupInfo.steps.filter((step) => step.projectId === project?.id);
232
+ return project?.root === "." && !project.groupKind && !project.componentKind && steps.length === 1 && steps[0]?.plugin === "project" ? project : undefined;
233
+ }
234
+
220
235
  function forge(project: SetupProject): { href: string; title: string } | null {
221
236
  if (project.repository) {
222
237
  return {
@@ -286,7 +301,7 @@ function StepWarnings({ warnings }: { warnings: string[] }) {
286
301
  );
287
302
  }
288
303
 
289
- function ProjectCard({ project }: { project: SetupProject }) {
304
+ function ProjectCard({ project, onRemove }: { project: SetupProject; onRemove?: (project: SetupProject) => void }) {
290
305
  const setupInfo = useSetup();
291
306
  const declared = setupInfo.steps.filter((step) => step.projectId === project.id);
292
307
  const runSteps = setupInfo.run?.steps.filter((step) => step.projectId === project.id) ?? [];
@@ -319,13 +334,14 @@ function ProjectCard({ project }: { project: SetupProject }) {
319
334
  ) : null}
320
335
  </div>
321
336
  </div>
337
+ {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
338
  </div>
323
339
 
324
340
  <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>
341
+ <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
342
  <dt>pipeline</dt><dd className="text-ink">{declared.length} {plural(declared.length, "step")}</dd>
327
343
  <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>
344
+ <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
345
  </dl>
330
346
 
331
347
  <div className="mt-4 flex flex-wrap gap-1.5" aria-label="Active plugins">
@@ -476,6 +492,21 @@ function PluginsList() {
476
492
  }
477
493
 
478
494
  const FIELD = "mono w-full rounded-control border border-line bg-canvas px-3 py-2 text-ink outline-none focus:border-accent";
495
+ const PROJECT_RESUME_KEY = "portolan.onboarding.project-source.v1";
496
+ const PROJECT_PRESETS: Array<{ label: string; kind: NonNullable<ProjectDraft["componentKind"]>; summary: string }> = [
497
+ { label: "Service", kind: "service", summary: "A deployable business capability." },
498
+ { label: "Web app", kind: "webapp", summary: "A browser-facing application." },
499
+ { label: "Worker", kind: "worker", summary: "A long-running background consumer." },
500
+ { label: "CLI", kind: "cli", summary: "A command-line application." },
501
+ { label: "Library", kind: "library", summary: "Reusable code, not deployed alone." },
502
+ ];
503
+
504
+ function readProjectResume(): { source: ProjectSource; path: string; repository: string; ref: string; sourcePath: string } | null {
505
+ try {
506
+ const value = JSON.parse(localStorage.getItem(PROJECT_RESUME_KEY) ?? "null");
507
+ return value && (value.source === "local" || value.source === "external") ? value : null;
508
+ } catch { return null; }
509
+ }
479
510
 
480
511
  function Field({ label, value, onChange, placeholder, required = false }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; required?: boolean }) {
481
512
  return (
@@ -486,15 +517,59 @@ function Field({ label, value, onChange, placeholder, required = false }: { labe
486
517
  );
487
518
  }
488
519
 
489
- function AddProjectCard({ onClick }: { onClick: () => void }) {
520
+ function AddProjectCard({ onAdd }: { onAdd: (source: ProjectSource) => void }) {
490
521
  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>
522
+ <article className="rounded-card border border-line bg-canvas p-card shadow-xs xl:col-span-2">
523
+ <div className="flex flex-col gap-4 sm:flex-row sm:items-center">
524
+ <span className="flex size-10 shrink-0 items-center justify-center rounded-control border border-line-strong bg-surface text-accent">
525
+ <Plus size={19} aria-hidden />
526
+ </span>
527
+ <div className="min-w-0 flex-1">
528
+ <div className="font-semibold text-ink">Add a project</div>
529
+ <p className="mt-1 text-muted">Connect a local folder or Git repository. Portolan will detect the right extractors.</p>
530
+ </div>
531
+ <div className="flex flex-wrap gap-2 sm:justify-end">
532
+ <button type="button" className="tbtn px-3 py-1.5" onClick={() => onAdd("local")}>
533
+ <FolderGit2 size={15} aria-hidden /> Local folder
534
+ </button>
535
+ <button type="button" className="product-primary" onClick={() => onAdd("external")}>
536
+ <GitBranch size={15} aria-hidden /> Git repository
537
+ </button>
538
+ </div>
539
+ </div>
540
+ </article>
541
+ );
542
+ }
543
+
544
+ function OnboardingCard({ starter, onAdd, onRemove }: { starter?: SetupProject; onAdd: (source: ProjectSource) => void; onRemove: (project: SetupProject) => void }) {
545
+ return (
546
+ <article className="mb-grid overflow-hidden rounded-card border border-accent bg-canvas shadow-xs">
547
+ <div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4 border-b border-line bg-surface px-card py-3">
548
+ <div>
549
+ <div className="font-semibold text-ink">Start with your architecture</div>
550
+ <p className="mt-1 text-muted">Replace the starter catalog with the project you actually want to describe.</p>
551
+ </div>
552
+ <CatIllustration scene="onboarding" className="cat-onboarding-illustration" />
553
+ </div>
554
+ <div className="grid gap-0 md:grid-cols-2">
555
+ <div className="flex items-start gap-3 p-card md:border-r md:border-line">
556
+ <span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-accent text-canvas"><Plus size={15} aria-hidden /></span>
557
+ <div className="min-w-0 flex-1">
558
+ <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>
559
+ <p className="mt-1 text-muted">A successful trial replaces the starter and adds your project in one reviewed change.</p>
560
+ <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>
561
+ </div>
562
+ </div>
563
+ <div className="flex items-start gap-3 p-card">
564
+ <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>
565
+ <div className="min-w-0 flex-1">
566
+ <div className="font-medium text-ink">Or start with an empty catalog</div>
567
+ <p className="mt-1 text-muted">Remove the starter now if you want to connect projects later. Application source code is never touched.</p>
568
+ {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>}
569
+ </div>
570
+ </div>
571
+ </div>
572
+ </article>
498
573
  );
499
574
  }
500
575
 
@@ -546,8 +621,11 @@ function RepositoryFailure({ failure, message, token, onTokenChange, onForget, b
546
621
  );
547
622
  }
548
623
 
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");
624
+ function Wizard({ open, initialSource, onClose, onAdded, onRunStarted }: { open: boolean; initialSource: ProjectSource; onClose: () => void; onAdded: (setup: SetupInfo) => void; onRunStarted: (runId: string) => void }) {
625
+ const setupInfo = useSetup();
626
+ const starter = starterProject(setupInfo);
627
+ const say = useToastStore((state) => state.say);
628
+ const [source, setSource] = useState<ProjectSource>(initialSource);
551
629
  const [stage, setStage] = useState<"source" | "scope" | "configure" | "trial">("source");
552
630
  const [path, setPath] = useState("");
553
631
  const [repository, setRepository] = useState("");
@@ -568,12 +646,26 @@ function Wizard({ open, onClose, onAdded, onRunStarted }: { open: boolean; onClo
568
646
  const [repositoryError, setRepositoryError] = useState<LocalApiError | null>(null);
569
647
  const [retryComponent, setRetryComponent] = useState<{ path: string; batch: boolean } | null>(null);
570
648
  const [credentialToken, setCredentialToken] = useState("");
649
+ const [resumed, setResumed] = useState(false);
571
650
 
572
651
  useEffect(() => {
652
+ if (open) {
653
+ setSource(initialSource);
654
+ const saved = readProjectResume();
655
+ if (saved?.source === initialSource) {
656
+ setPath(saved.path); setRepository(saved.repository); setRef(saved.ref); setSourcePath(saved.sourcePath); setResumed(true);
657
+ }
658
+ return;
659
+ }
573
660
  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);
661
+ 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
662
  }
576
- }, [open]);
663
+ }, [initialSource, open]);
664
+
665
+ useEffect(() => {
666
+ if (!open || (!path.trim() && !repository.trim())) return;
667
+ try { localStorage.setItem(PROJECT_RESUME_KEY, JSON.stringify({ source, path, repository, ref, sourcePath })); } catch {}
668
+ }, [open, path, ref, repository, source, sourcePath]);
577
669
 
578
670
  useEffect(() => {
579
671
  if (!trialRunId) return;
@@ -593,6 +685,11 @@ function Wizard({ open, onClose, onAdded, onRunStarted }: { open: boolean; onClo
593
685
  clearRepositoryFailure();
594
686
  }
595
687
 
688
+ function startOver() {
689
+ try { localStorage.removeItem(PROJECT_RESUME_KEY); } catch {}
690
+ setSource(initialSource); setPath(""); setRepository(""); setRef("main"); setSourcePath(""); setResumed(false); clearRepositoryFailure();
691
+ }
692
+
596
693
  async function detect() {
597
694
  setBusy(true); setError("");
598
695
  setRepositoryError(null); setRetryComponent(null);
@@ -613,7 +710,9 @@ function Wizard({ open, onClose, onAdded, onRunStarted }: { open: boolean; onClo
613
710
 
614
711
  function configure(found: Discovery, commit: string, selectedSourcePath: string, projectId?: string) {
615
712
  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) });
713
+ const plugins = found.detections.filter((item) => item.selected).map((item) => item.plugin);
714
+ const domainModel = plugins.some((plugin) => ["go-domain", "ts-domain", "rust-domain", "java-domain", "django-domain"].includes(plugin));
715
+ 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
716
  setStage("configure");
618
717
  }
619
718
 
@@ -703,8 +802,19 @@ function Wizard({ open, onClose, onAdded, onRunStarted }: { open: boolean; onClo
703
802
  setTrialRunId(null); setTrialEvents([]); setPlan(null);
704
803
  await chooseComponent(next, true);
705
804
  } else {
805
+ try { localStorage.removeItem(PROJECT_RESUME_KEY); } catch {}
706
806
  onClose();
707
807
  if (result.run) onRunStarted(result.run.runId);
808
+ else say(`${result.project.name} added to portolan.json.`, {
809
+ label: "Undo",
810
+ run: () => { void (async () => {
811
+ try {
812
+ const restored = await undoProjectRemoval(result.undoToken);
813
+ onAdded(restored.setup);
814
+ say(`${result.project.name} addition undone.`);
815
+ } catch (cause) { say(cause instanceof Error ? cause.message : String(cause)); }
816
+ })(); },
817
+ });
708
818
  }
709
819
  } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); setBusy(false); }
710
820
  }
@@ -716,9 +826,17 @@ function Wizard({ open, onClose, onAdded, onRunStarted }: { open: boolean; onClo
716
826
  const trial = trialEvents.find((event): event is Extract<RunEvent, { type: "project-trial-ready" }> => event.type === "project-trial-ready");
717
827
  const logs = trialEvents.filter((event): event is Extract<RunEvent, { type: "log" }> => event.type === "log");
718
828
  const trialRunning = stage === "trial" && !finished;
829
+ const confirmedDeployables = discovery?.deployables.filter((candidate) => candidate.confidence === "high") ?? [];
830
+ const splitsDeployables = confirmedDeployables.length > 1 && draft?.component === discovery?.defaults.component;
831
+ const quality = trial && draft ? [
832
+ { label: "Stable project identity", done: Boolean(draft.id.trim() && draft.name.trim()) },
833
+ { label: "Architecture placement", done: Boolean(draft.group.trim() && (splitsDeployables || draft.component.trim())) },
834
+ { label: "Catalog facts extracted", done: trial.facts.some((fact) => fact.count > 0) },
835
+ { label: "Extraction without warnings", done: trial.warnings.length === 0 },
836
+ ] : [];
719
837
  const totalSteps = pipeline?.stepCount ?? plan?.steps.length ?? 0;
720
838
  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";
839
+ const heading = stage === "source" ? "Add a project" : stage === "scope" ? "Choose a component" : stage === "configure" ? "Place the project" : "Trial extraction";
722
840
  function back() {
723
841
  if (stage === "trial") { if (trialRunId) void disposeProjectTrial(trialRunId); setStage("configure"); setTrialRunId(null); setTrialEvents([]); setPlan(null); }
724
842
  else if (stage === "configure" && scopeDiscovery) { setStage("scope"); setPendingComponents([]); setBatchPosition(null); }
@@ -729,14 +847,16 @@ function Wizard({ open, onClose, onAdded, onRunStarted }: { open: boolean; onClo
729
847
  <Modal open={open} onClose={busy || trialRunning ? () => {} : onClose} label={heading} width="min(800px,94vw)">
730
848
  <div className="flex items-center gap-3 border-b border-line px-5 py-4">
731
849
  {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>
850
+ <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
851
  <button type="button" className="tbtn p-1.5" onClick={onClose} disabled={busy || trialRunning} aria-label="Close"><X size={16} /></button>
734
852
  </div>
735
853
  <div className="min-h-0 flex-1 overflow-y-auto p-5">
736
854
  {stage === "source" ? (
737
855
  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">
856
+ {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
857
  <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
858
  <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>
859
+ <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
860
  {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
861
  </div>
742
862
  ) : stage === "scope" && scopeDiscovery ? (
@@ -748,12 +868,10 @@ function Wizard({ open, onClose, onAdded, onRunStarted }: { open: boolean; onClo
748
868
  ) : stage === "configure" && discovery && draft ? (
749
869
  <div className="space-y-5">
750
870
  <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>
871
+ {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}
872
+ {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>}
873
+ <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>
874
+ <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
875
  <div><div className="label mb-2">detected capabilities</div>
758
876
  {discovery.detections.length ? <div className="grid gap-2">{discovery.detections.map((item) => {
759
877
  const checked = draft.plugins.includes(item.plugin);
@@ -765,16 +883,20 @@ function Wizard({ open, onClose, onAdded, onRunStarted }: { open: boolean; onClo
765
883
  ) : stage === "trial" && plan ? (
766
884
  <div className="space-y-5">
767
885
  <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>
886
+ <div className={trial ? "grid items-center gap-3 sm:grid-cols-[minmax(0,1fr)_120px]" : ""}>
887
+ <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>
888
+ {trial ? <CatIllustration scene="trial" className="cat-trial-illustration" /> : null}
889
+ </div>
769
890
  {trial?.previewUrl ? <a className="product-primary mt-3 inline-flex" href={trial.previewUrl} target="_blank" rel="noreferrer">Open catalog preview ↗</a> : null}
770
891
  {trial?.previewError ? <p className="mt-2 text-declared">The extraction is valid, but the temporary site could not start: {trial.previewError}</p> : null}
771
892
  {!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
893
  </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}
894
+ {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}
895
+ {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
896
  <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
897
  {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
898
  {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}
899
+ {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
900
  </div>
779
901
  ) : null}
780
902
  {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 +944,7 @@ function RunDialog({ runId, open, onClose, onFinished, onApply }: { runId: strin
822
944
  {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
945
  {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
946
  </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>
947
+ <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
948
  </Modal>
827
949
  );
828
950
  }
@@ -833,6 +955,7 @@ const SETTINGS_LINKS = [
833
955
  ["Pipeline", paths.settingsPipeline()],
834
956
  ["Delivery", paths.settingsDelivery()],
835
957
  ["Preferences", paths.settingsPreferences()],
958
+ ["About", paths.settingsAbout()],
836
959
  ] as const;
837
960
 
838
961
  function SettingsNav() {
@@ -860,11 +983,49 @@ function SettingsNav() {
860
983
  );
861
984
  }
862
985
 
863
- function OverviewSettings() {
986
+ function GettingStarted({ onGenerate }: { onGenerate: () => void }) {
987
+ const setupInfo = useSetup();
988
+ const starter = starterProject(setupInfo);
989
+ const ownProjects = starter ? [] : setupInfo.projects;
990
+ const connected = ownProjects.length > 0;
991
+ const placed = connected && ownProjects.some((project) => (project.group ?? project.context) && (project.component ?? project.service));
992
+ const generated = placed && Boolean(setupInfo.run && !setupInfo.reportStale && setupInfo.run.status === "ok");
993
+ const steps = [
994
+ { label: "Clear the starter", detail: "Remove the placeholder project and catalog.", done: !starter },
995
+ { label: "Connect a project", detail: placed ? "Project and architecture placement are configured." : "Choose a source and place it in a bounded context.", done: placed },
996
+ { label: "Review the result", detail: "Preview and apply the generated documentation.", done: generated },
997
+ ];
998
+ const completed = steps.filter((step) => step.done).length;
999
+ if (completed === steps.length) return null;
1000
+ const tip = starter
1001
+ ? "The starter is only configuration and generated catalog data. Removing it never touches application source code."
1002
+ : !connected
1003
+ ? "Start with one deployable component. A useful small catalog is easier to grow than a perfect estate-wide model."
1004
+ : !placed
1005
+ ? "A bounded context describes a language and responsibility boundary, not necessarily a directory or team."
1006
+ : "Preview first: Portolan shows the exact generated-file diff before anything is applied.";
1007
+ return (
1008
+ <section className="mb-section overflow-hidden rounded-card border border-accent bg-canvas shadow-xs">
1009
+ <div className="flex flex-wrap items-start justify-between gap-3 border-b border-line bg-surface px-card py-3">
1010
+ <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>
1011
+ <div className="mono text-muted">{completed} / {steps.length} complete</div>
1012
+ </div>
1013
+ <div className="grid lg:grid-cols-[minmax(0,1.45fr)_minmax(16rem,0.75fr)]">
1014
+ <ol className="divide-y divide-line lg:border-r lg:border-line">
1015
+ {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>)}
1016
+ </ol>
1017
+ <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>
1018
+ </div>
1019
+ </section>
1020
+ );
1021
+ }
1022
+
1023
+ function OverviewSettings({ local, onGenerate }: { local: boolean; onGenerate: () => void }) {
864
1024
  const setupInfo = useSetup();
865
1025
  const active = setupInfo.plugins.filter((plugin) => plugin.stepCount > 0);
866
1026
  return (
867
1027
  <>
1028
+ {local ? <GettingStarted onGenerate={onGenerate} /> : null}
868
1029
  <BuildHealth />
869
1030
  <div className="mt-4 grid grid-cols-2 gap-grid lg:grid-cols-4">
870
1031
  <Metric value={setupInfo.projects.length} label="project" />
@@ -894,12 +1055,14 @@ function OverviewSettings() {
894
1055
  );
895
1056
  }
896
1057
 
897
- function ProjectsSettings({ local, onAdd }: { local: boolean; onAdd: () => void }) {
1058
+ function ProjectsSettings({ local, onAdd, onRemove }: { local: boolean; onAdd: (source: ProjectSource) => void; onRemove: (project: SetupProject) => void }) {
898
1059
  const setupInfo = useSetup();
1060
+ const starter = starterProject(setupInfo);
899
1061
  return (
900
1062
  <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>}
1063
+ <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>
1064
+ {local && (starter || setupInfo.projects.length === 0) ? <OnboardingCard starter={starter} onAdd={onAdd} onRemove={onRemove} /> : null}
1065
+ {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
1066
  </section>
904
1067
  );
905
1068
  }
@@ -911,8 +1074,23 @@ function PipelineSettings() {
911
1074
  <div className="space-y-section">
912
1075
  <section>
913
1076
  <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>
1077
+ {active.length === 0 ? (
1078
+ <CapabilityEmpty
1079
+ title={setupInfo.projects.length === 0 ? "Connect a project to choose extractors" : "No extractors are active"}
1080
+ signal="Project detection selects extractors from the files and contracts it can prove are present."
1081
+ actions={<Link className="product-primary" to={paths.settingsProjects()}>{setupInfo.projects.length === 0 ? "Connect a project" : "Review projects"}</Link>}
1082
+ >
1083
+ {setupInfo.projects.length === 0
1084
+ ? "The pipeline is assembled from project evidence, so it starts after the first source is connected."
1085
+ : "The projects are configured, but no pipeline step currently reads them. Re-run project detection and review the proposed capabilities."
1086
+ }
1087
+ </CapabilityEmpty>
1088
+ ) : (
1089
+ <>
1090
+ <PluginsList />
1091
+ <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>
1092
+ </>
1093
+ )}
916
1094
  </section>
917
1095
  <details className="rounded-card border border-line shadow-xs">
918
1096
  <summary className="cursor-pointer select-none px-4 py-3 font-semibold text-ink">Advanced build inputs</summary>
@@ -929,7 +1107,11 @@ function PipelineSettings() {
929
1107
  );
930
1108
  }
931
1109
 
932
- function SettingsContent({ local, onAdd, onGenerate }: { local: boolean; onAdd: () => void; onGenerate: () => void }) {
1110
+
1111
+ function SettingsContent({ local, onAdd, onRemove, onGenerate }: { local: boolean; onAdd: (source: ProjectSource) => void; onRemove: (project: SetupProject) => void; onGenerate: () => void }) {
1112
+ const pathname = useLocation().pathname.replace(/\/$/, "");
1113
+ const overview = pathname === paths.settings();
1114
+ const about = pathname === paths.settingsAbout();
933
1115
  return (
934
1116
  <div className="h-full overflow-y-auto p-gutter">
935
1117
  <div className="max-w-table">
@@ -938,20 +1120,21 @@ function SettingsContent({ local, onAdd, onGenerate }: { local: boolean; onAdd:
938
1120
  <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
1121
  <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
1122
  </div>
941
- {local ? <button type="button" className="product-primary" onClick={onGenerate}><Play size={15} /> Preview generated diff</button> : null}
1123
+ {local && !overview && !about ? <button type="button" className="product-primary" onClick={onGenerate}><Play size={15} /> Preview generated diff</button> : null}
942
1124
  </div>
943
1125
  <SettingsNav />
944
1126
  <div className="mt-section">
945
1127
  <Routes>
946
- <Route index element={<OverviewSettings />} />
947
- <Route path="projects" element={<ProjectsSettings local={local} onAdd={onAdd} />} />
1128
+ <Route index element={<OverviewSettings local={local} onGenerate={onGenerate} />} />
1129
+ <Route path="projects" element={<ProjectsSettings local={local} onAdd={onAdd} onRemove={onRemove} />} />
948
1130
  <Route path="pipeline" element={<PipelineSettings />} />
949
1131
  <Route path="delivery" element={<section><SectionTitle right={local ? "preview before writing" : "local mode required"}>Delivery presets</SectionTitle><DeliverySettings local={local} /></section>} />
950
1132
  <Route path="preferences" element={<PreferencesSettings />} />
1133
+ <Route path="about" element={<AboutSettings />} />
951
1134
  <Route path="*" element={<Navigate to={paths.settings()} replace />} />
952
1135
  </Routes>
953
1136
  </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>
1137
+ {!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
1138
  </div>
956
1139
  </div>
957
1140
  );
@@ -964,9 +1147,11 @@ export function Settings() {
964
1147
  // No local server means no local mode; the static build-time setup stands in.
965
1148
  const local = status.isSuccess;
966
1149
  const setup = status.data?.setup ?? staticSetupInfo;
967
- const [wizard, setWizard] = useState(false);
1150
+ const [wizardSource, setWizardSource] = useState<ProjectSource | null>(null);
968
1151
  const [runId, setRunId] = useState<string | null>(null);
969
1152
  const [runOpen, setRunOpen] = useState(false);
1153
+ const [removeTarget, setRemoveTarget] = useState<SetupProject | null>(null);
1154
+ const [removeBusy, setRemoveBusy] = useState(false);
970
1155
  const say = useToastStore((state) => state.say);
971
1156
  const activeRunId = status.data?.activeRun?.id ?? null;
972
1157
  useEffect(() => {
@@ -990,10 +1175,38 @@ export function Settings() {
990
1175
  say(cause instanceof Error ? cause.message : String(cause));
991
1176
  }
992
1177
  }
1178
+ async function confirmRemove() {
1179
+ if (!removeTarget) return;
1180
+ setRemoveBusy(true);
1181
+ try {
1182
+ const result = await removeLocalProject(removeTarget.id);
1183
+ setSetup(result.setup);
1184
+ setRemoveTarget(null);
1185
+ say(`${result.project.name} removed from portolan.json.`, {
1186
+ label: "Undo",
1187
+ run: () => { void (async () => {
1188
+ try {
1189
+ const restored = await undoProjectRemoval(result.undoToken);
1190
+ setSetup(restored.setup);
1191
+ say(`${result.project.name} restored.`);
1192
+ } catch (cause) { say(cause instanceof Error ? cause.message : String(cause)); }
1193
+ })(); },
1194
+ });
1195
+ } catch (cause) {
1196
+ say(cause instanceof Error ? cause.message : String(cause));
1197
+ } finally {
1198
+ setRemoveBusy(false);
1199
+ }
1200
+ }
993
1201
  return (
994
1202
  <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); }} />
1203
+ <SettingsContent local={local} onAdd={setWizardSource} onRemove={setRemoveTarget} onGenerate={() => void generate()} />
1204
+ <Wizard open={wizardSource !== null} initialSource={wizardSource ?? "local"} onClose={() => setWizardSource(null)} onAdded={setSetup} onRunStarted={(id) => { setRunId(id); setRunOpen(true); }} />
1205
+ <Modal open={removeTarget !== null} onClose={removeBusy ? () => {} : () => setRemoveTarget(null)} label={`Remove ${removeTarget?.name ?? "project"}`} width="min(520px,92vw)">
1206
+ <div className="border-b border-line px-5 py-4"><div className="font-semibold text-ink">Remove {removeTarget?.name}?</div></div>
1207
+ <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>
1208
+ <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>
1209
+ </Modal>
997
1210
  <RunDialog runId={runId} open={runOpen} onClose={() => setRunOpen(false)} onFinished={refresh} onApply={(preview) => void generate(preview)} />
998
1211
  </SetupContext.Provider>
999
1212
  );