From d3b6943fb153920fbfc73fae180b61cdaf80bc7e Mon Sep 17 00:00:00 2001 From: Tomasz Zajac Date: Fri, 25 Sep 2026 11:17:33 +0200 Subject: [PATCH] feat(hub): named views and sequences for blueprints Blueprints were one dense canvas. They now ship named views and sequences, and the Hub viewer and both studio import paths actually use them (the Hub format used to drop `views` entirely, so the few hand-authored ones never showed up anywhere). Code - hub/conceptViews.ts: one translation of concept views -> studio views, used by the Hub viewer (ids kept) and both import paths (ids remapped to the new nodes / relations / sequences, name prefixed with the concept). Views keep only elements that exist on the receiving side; a dynamic view whose sequence did not survive falls back to static; empty views drop. Stored positions are used only in the viewer (an import re-centres nodes). - hubFormat carries `views`; conceptToDiagramData adds the concept's canvas views ahead of the synthetic wiki / table views. - Hub viewer: a view selector next to Canvas ("All elements" + the concept's views), deep-linkable as #/c//v/canvas/cv/. The viewer runs in explore mode, where switching views deliberately keeps the current layout, so it reloads the concept laid out for the chosen view. Data (47 views, 26 sequences across the 7 blueprints) - Every blueprint: System context (people, the platform collapsed, external systems in one row), Containers, Governance (requirements, fitness functions and ADRs with what they constrain). - One dynamic view per screen flow over a sequence of its navigates-to steps, described as " -> . ". - New technical flows with a dynamic view for the three blueprints that had none: e-commerce "Place an order and pay", SaaS "Sign-up to a provisioned workspace", analytics "From event to dashboard". - The four existing technical flow views get explicit positions. Tests cover conceptViewsToDiagram (viewer and import), the cv route parameter, and view / sequence consistency in every blueprint. Co-Authored-By: Claude Opus 5.5 --- .../bp-ai-assistant-platform.radical | 531 ++++++++++++++- .../bp-analytical-data-platform.radical | 474 +++++++++++++ hub/blueprint/bp-ecommerce-platform.radical | 637 ++++++++++++++++++ .../bp-fintech-ledger-platform.radical | 542 ++++++++++++++- hub/blueprint/bp-marketplace-platform.radical | 564 +++++++++++++++- .../bp-realtime-collaboration.radical | 437 +++++++++++- hub/blueprint/bp-saas-b2b-platform.radical | 586 ++++++++++++++++ .../src/components/HubImportModal.tsx | 11 + src/renderer/src/hub/HubApp.tsx | 53 +- src/renderer/src/hub/conceptToDiagram.ts | 22 +- src/renderer/src/hub/conceptViews.ts | 97 +++ src/renderer/src/hub/hub.css | 11 + src/renderer/src/hub/hubFormat.ts | 8 +- src/renderer/src/hub/hubRoute.ts | 7 +- src/renderer/src/hub/importConcept.ts | 10 + tests/hubCatalogue.test.ts | 36 + tests/hubViewer.test.ts | 43 ++ 17 files changed, 4055 insertions(+), 14 deletions(-) create mode 100644 src/renderer/src/hub/conceptViews.ts diff --git a/hub/blueprint/bp-ai-assistant-platform.radical b/hub/blueprint/bp-ai-assistant-platform.radical index a8ac504..98f9ab2 100644 --- a/hub/blueprint/bp-ai-assistant-platform.radical +++ b/hub/blueprint/bp-ai-assistant-platform.radical @@ -951,9 +951,212 @@ "The case surfaces in the handoff queue for the next available agent.", "The agent resolves it from the console; had confidence been high enough, the orchestrator would instead have answered the customer directly." ] + }, + { + "id": "seq-flow-customer", + "name": "Customer journey", + "relationIds": [ + "mockup-chat-start--mockup-chat-answer", + "mockup-chat-answer--mockup-support-chat", + "mockup-support-chat--mockup-chat-human" + ], + "stepDescriptions": [ + "Ask → Chat — Grounded Answer. Answer grounded in retrieved sources; actions need the customer's confirmation.", + "Not resolved → Chat — Handoff. Customer-facing chat: the assistant answers with cited sources when it can ground the answer and hands off to a person when it cannot.", + "Agent joins → Chat — Agent Joined. A person joins with the full case history, within {{ESCALATION_SLA_MIN}} minutes of the handoff." + ] + }, + { + "id": "seq-flow-human-agent", + "name": "Human agent journey", + "relationIds": [ + "mockup-escalation-queue--mockup-case-review", + "mockup-case-review--mockup-case-resolved", + "mockup-case-resolved--mockup-case-audit", + "mockup-case-resolved--mockup-escalation-queue" + ], + "stepDescriptions": [ + "Pick up → Case Review. The assistant's proposed action with its confidence, per-field extraction confidence (below {{EXTRACTION_REVIEW_THRESHOLD_PCT}}% needs review) and the sources it used.", + "Approve & send → Case Resolved. Resolution with every autonomous and human decision recorded for {{AUDIT_RETENTION_DAYS}} days.", + "Audit trail → Case Audit Trail. Search decisions, sources and confidence across cases for audits.", + "Next case → Escalation Queue. Human agents pick up escalated cases oldest-first; each case must be picked up within {{ESCALATION_SLA_MIN}} minutes." + ] + }, + { + "id": "seq-flow-assistant-ops", + "name": "Assistant ops journey", + "relationIds": [ + "mockup-kb-sources--mockup-quality-drift", + "mockup-quality-drift--mockup-kb-sources" + ], + "stepDescriptions": [ + "Quality → Quality & Drift. Autonomous resolution rate, eval results and cost per case.", + "Fix stale source → Knowledge Sources. Source sync status against the {{KB_STALENESS_HOURS}}-hour freshness limit." + ] } ], "views": [ + { + "id": "view-context", + "name": "System context", + "kind": "static", + "nodeIds": [ + "customer", + "human-agent", + "ops", + "platform", + "llm-provider", + "source-channel" + ], + "collapsedNodeIds": [ + "platform" + ], + "positions": { + "customer": { + "x": 0, + "y": 0, + "width": 150, + "height": 170 + }, + "human-agent": { + "x": 210, + "y": 0, + "width": 150, + "height": 170 + }, + "ops": { + "x": 420, + "y": 0, + "width": 150, + "height": 170 + }, + "platform": { + "x": 0, + "y": 290, + "width": 280, + "height": 180 + }, + "llm-provider": { + "x": 400, + "y": 290, + "width": 360, + "height": 260 + }, + "source-channel": { + "x": 820, + "y": 290, + "width": 360, + "height": 260 + } + } + }, + { + "id": "view-containers", + "name": "Containers", + "kind": "static", + "nodeIds": [ + "customer", + "human-agent", + "ops", + "platform", + "ingestion-api", + "extraction-worker", + "retriever", + "orchestrator", + "handoff-queue", + "agent-console", + "knowledge-base", + "case-store", + "llm-provider", + "source-channel" + ], + "positions": { + "agent-console": { + "x": 710, + "y": 350, + "width": 210, + "height": 140 + }, + "case-store": { + "x": 370, + "y": 590, + "width": 190, + "height": 130 + }, + "customer": { + "x": 0, + "y": 0, + "width": 150, + "height": 170 + }, + "extraction-worker": { + "x": 370, + "y": 110, + "width": 300, + "height": 200 + }, + "handoff-queue": { + "x": 370, + "y": 350, + "width": 220, + "height": 95 + }, + "human-agent": { + "x": 210, + "y": 0, + "width": 150, + "height": 170 + }, + "ingestion-api": { + "x": 30, + "y": 110, + "width": 300, + "height": 200 + }, + "knowledge-base": { + "x": 30, + "y": 590, + "width": 190, + "height": 130 + }, + "llm-provider": { + "x": 0, + "y": 1160, + "width": 360, + "height": 260 + }, + "ops": { + "x": 420, + "y": 0, + "width": 150, + "height": 170 + }, + "orchestrator": { + "x": 30, + "y": 350, + "width": 300, + "height": 200 + }, + "platform": { + "x": 0, + "y": 290, + "width": 1040, + "height": 750 + }, + "retriever": { + "x": 710, + "y": 110, + "width": 300, + "height": 200 + }, + "source-channel": { + "x": 420, + "y": 1160, + "width": 360, + "height": 260 + } + } + }, { "id": "bp-ai-assistant-platform-flow", "name": "Autonomous resolution with escalation fallback", @@ -975,7 +1178,333 @@ "llm-provider", "source-channel" ], - "positions": {} + "positions": { + "agent-console": { + "x": 710, + "y": 350, + "width": 210, + "height": 140 + }, + "case-store": { + "x": 370, + "y": 590, + "width": 190, + "height": 130 + }, + "customer": { + "x": 0, + "y": 0, + "width": 150, + "height": 170 + }, + "extraction-worker": { + "x": 370, + "y": 110, + "width": 300, + "height": 200 + }, + "handoff-queue": { + "x": 370, + "y": 350, + "width": 220, + "height": 95 + }, + "human-agent": { + "x": 210, + "y": 0, + "width": 150, + "height": 170 + }, + "ingestion-api": { + "x": 30, + "y": 110, + "width": 300, + "height": 200 + }, + "knowledge-base": { + "x": 30, + "y": 590, + "width": 190, + "height": 130 + }, + "llm-provider": { + "x": 0, + "y": 1160, + "width": 360, + "height": 260 + }, + "ops": { + "x": 420, + "y": 0, + "width": 150, + "height": 170 + }, + "orchestrator": { + "x": 30, + "y": 350, + "width": 300, + "height": 200 + }, + "platform": { + "x": 0, + "y": 290, + "width": 1040, + "height": 750 + }, + "retriever": { + "x": 710, + "y": 110, + "width": 300, + "height": 200 + }, + "source-channel": { + "x": 420, + "y": 1160, + "width": 360, + "height": 260 + } + } + }, + { + "id": "view-governance", + "name": "Governance", + "kind": "static", + "nodeIds": [ + "req-escalation-pickup", + "req-extraction-review", + "req-kb-freshness", + "req-case-audit-trail", + "ff-escalation-backlog-guard", + "ff-extraction-drift-monitor", + "ff-cost-per-case-guard", + "adr-confidence-gated-autonomy", + "adr-retrieval-over-finetuning", + "handoff-queue", + "extraction-worker", + "knowledge-base", + "case-store", + "orchestrator", + "retriever" + ], + "positions": { + "adr-confidence-gated-autonomy": { + "x": 0, + "y": 1912, + "width": 260, + "height": 52 + }, + "adr-retrieval-over-finetuning": { + "x": 320, + "y": 1912, + "width": 260, + "height": 52 + }, + "case-store": { + "x": 370, + "y": 590, + "width": 190, + "height": 130 + }, + "extraction-worker": { + "x": 370, + "y": 110, + "width": 300, + "height": 200 + }, + "ff-cost-per-case-guard": { + "x": 640, + "y": 1740, + "width": 260, + "height": 52 + }, + "ff-escalation-backlog-guard": { + "x": 0, + "y": 1740, + "width": 260, + "height": 52 + }, + "ff-extraction-drift-monitor": { + "x": 320, + "y": 1740, + "width": 260, + "height": 52 + }, + "handoff-queue": { + "x": 370, + "y": 350, + "width": 220, + "height": 95 + }, + "knowledge-base": { + "x": 30, + "y": 590, + "width": 190, + "height": 130 + }, + "orchestrator": { + "x": 30, + "y": 350, + "width": 300, + "height": 200 + }, + "platform": { + "x": 0, + "y": 290, + "width": 1040, + "height": 750 + }, + "req-case-audit-trail": { + "x": 960, + "y": 1540, + "width": 260, + "height": 80 + }, + "req-escalation-pickup": { + "x": 0, + "y": 1540, + "width": 260, + "height": 80 + }, + "req-extraction-review": { + "x": 320, + "y": 1540, + "width": 260, + "height": 80 + }, + "req-kb-freshness": { + "x": 640, + "y": 1540, + "width": 260, + "height": 80 + }, + "retriever": { + "x": 710, + "y": 110, + "width": 300, + "height": 200 + } + } + }, + { + "id": "view-flow-customer", + "name": "Customer journey", + "kind": "dynamic", + "sequenceId": "seq-flow-customer", + "nodeIds": [ + "flow-customer", + "mockup-chat-start", + "mockup-chat-answer", + "mockup-support-chat", + "mockup-chat-human" + ], + "positions": { + "flow-customer": { + "x": 0, + "y": 2084, + "width": 1240, + "height": 330 + }, + "mockup-chat-answer": { + "x": 350, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-chat-human": { + "x": 990, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-chat-start": { + "x": 30, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-support-chat": { + "x": 670, + "y": 110, + "width": 220, + "height": 190 + } + } + }, + { + "id": "view-flow-human-agent", + "name": "Human agent journey", + "kind": "dynamic", + "sequenceId": "seq-flow-human-agent", + "nodeIds": [ + "flow-human-agent", + "mockup-escalation-queue", + "mockup-case-review", + "mockup-case-resolved", + "mockup-case-audit" + ], + "positions": { + "flow-human-agent": { + "x": 0, + "y": 2494, + "width": 1240, + "height": 330 + }, + "mockup-case-audit": { + "x": 990, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-case-resolved": { + "x": 670, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-case-review": { + "x": 350, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-escalation-queue": { + "x": 30, + "y": 110, + "width": 220, + "height": 190 + } + } + }, + { + "id": "view-flow-assistant-ops", + "name": "Assistant ops journey", + "kind": "dynamic", + "sequenceId": "seq-flow-assistant-ops", + "nodeIds": [ + "flow-assistant-ops", + "mockup-kb-sources", + "mockup-quality-drift" + ], + "positions": { + "flow-assistant-ops": { + "x": 0, + "y": 2904, + "width": 600, + "height": 330 + }, + "mockup-kb-sources": { + "x": 30, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-quality-drift": { + "x": 350, + "y": 110, + "width": 220, + "height": 190 + } + } } ] } diff --git a/hub/blueprint/bp-analytical-data-platform.radical b/hub/blueprint/bp-analytical-data-platform.radical index baed821..b573db0 100644 --- a/hub/blueprint/bp-analytical-data-platform.radical +++ b/hub/blueprint/bp-analytical-data-platform.radical @@ -833,5 +833,479 @@ "targetId": "orchestrator", "relationType": "presented-by" } + ], + "sequences": [ + { + "id": "seq-technical", + "name": "From event to dashboard", + "relationIds": [ + "stream--ingestion", + "ingestion--lake", + "orchestrator--transform", + "transform--lake", + "transform--catalog", + "analyst--bi", + "bi--query", + "query--catalog", + "query--lake" + ], + "stepDescriptions": [ + "Operational events stream into ingestion.", + "Raw events land in the lake within the freshness target.", + "Orchestrator schedules the transformation run.", + "Models turn raw into curated tables; failing rows are quarantined.", + "Lineage and test results are published to the catalog.", + "Analyst opens a dashboard.", + "BI runs the governed query.", + "Query engine resolves row and column policies for the user.", + "Curated tables are read with the policies applied." + ] + }, + { + "id": "seq-flow-analyst", + "name": "Analyst journey", + "relationIds": [ + "mockup-bi-home--mockup-sales-dashboard", + "mockup-sales-dashboard--mockup-explore", + "mockup-explore--mockup-access-request", + "mockup-access-request--mockup-dataset-lineage", + "mockup-dataset-lineage--mockup-access-request", + "mockup-sales-dashboard--mockup-dataset-lineage" + ], + "stepDescriptions": [ + "Open → Sales Dashboard. Self-service dashboard showing how fresh the data is; restricted columns are masked for users whose role does not allow them.", + "Explore → Explore. Self-service exploration; row and column policies from the catalogue still apply.", + "Request access → Request Access. Access to restricted columns is requested from the dataset owner, time-boxed and audited.", + "Send request → Dataset & Lineage. Catalogue page for a curated dataset with owner, classification and column-level lineage from source to dashboard.", + "Request access → Request Access. Access to restricted columns is requested from the dataset owner, time-boxed and audited.", + "View dataset → Dataset & Lineage. Catalogue page for a curated dataset with owner, classification and column-level lineage from source to dashboard." + ] + }, + { + "id": "seq-flow-data-engineer", + "name": "Data engineer journey", + "relationIds": [ + "mockup-pipelines--mockup-quality-runs", + "mockup-quality-runs--mockup-quarantine", + "mockup-quarantine--mockup-quality-runs", + "mockup-pipelines--mockup-freshness" + ], + "stepDescriptions": [ + "Open runs → Data Quality Runs. Run results with the quality gate applied: failing records are quarantined, the affected models fail and the dataset owner is notified before anything reaches consumers.", + "Open quarantine → Quarantine. Rows that failed the quality gate, held back until fixed, discarded or released.", + "Release after fix → Data Quality Runs. Run results with the quality gate applied: failing records are quarantined, the affected models fail and the dataset owner is notified before anything reaches consumers.", + "Freshness → Freshness Monitor. Streaming latency against the {{INGESTION_LATENCY_SECONDS}} s target from event to raw zone." + ] + } + ], + "views": [ + { + "id": "view-context", + "name": "System context", + "kind": "static", + "nodeIds": [ + "analyst", + "data-engineer", + "platform" + ], + "collapsedNodeIds": [ + "platform" + ], + "positions": { + "analyst": { + "x": 0, + "y": 0, + "width": 150, + "height": 170 + }, + "data-engineer": { + "x": 210, + "y": 0, + "width": 150, + "height": 170 + }, + "platform": { + "x": 0, + "y": 290, + "width": 280, + "height": 180 + } + } + }, + { + "id": "view-containers", + "name": "Containers", + "kind": "static", + "nodeIds": [ + "analyst", + "data-engineer", + "platform", + "ingestion", + "stream", + "lake", + "transform", + "orchestrator", + "catalog", + "query", + "bi" + ], + "positions": { + "analyst": { + "x": 0, + "y": 0, + "width": 150, + "height": 170 + }, + "bi": { + "x": 1050, + "y": 350, + "width": 210, + "height": 140 + }, + "catalog": { + "x": 370, + "y": 350, + "width": 300, + "height": 200 + }, + "data-engineer": { + "x": 210, + "y": 0, + "width": 150, + "height": 170 + }, + "ingestion": { + "x": 30, + "y": 110, + "width": 300, + "height": 200 + }, + "lake": { + "x": 710, + "y": 110, + "width": 190, + "height": 130 + }, + "orchestrator": { + "x": 30, + "y": 350, + "width": 300, + "height": 200 + }, + "platform": { + "x": 0, + "y": 290, + "width": 1380, + "height": 580 + }, + "query": { + "x": 710, + "y": 350, + "width": 300, + "height": 200 + }, + "stream": { + "x": 370, + "y": 110, + "width": 220, + "height": 95 + }, + "transform": { + "x": 1050, + "y": 110, + "width": 300, + "height": 200 + } + } + }, + { + "id": "view-technical-flow", + "name": "From event to dashboard", + "kind": "dynamic", + "sequenceId": "seq-technical", + "nodeIds": [ + "stream", + "ingestion", + "lake", + "orchestrator", + "transform", + "catalog", + "analyst", + "bi", + "query" + ], + "positions": { + "analyst": { + "x": 0, + "y": 0, + "width": 150, + "height": 170 + }, + "bi": { + "x": 1050, + "y": 350, + "width": 210, + "height": 140 + }, + "catalog": { + "x": 370, + "y": 350, + "width": 300, + "height": 200 + }, + "ingestion": { + "x": 30, + "y": 110, + "width": 300, + "height": 200 + }, + "lake": { + "x": 710, + "y": 110, + "width": 190, + "height": 130 + }, + "orchestrator": { + "x": 30, + "y": 350, + "width": 300, + "height": 200 + }, + "platform": { + "x": 0, + "y": 290, + "width": 1380, + "height": 580 + }, + "query": { + "x": 710, + "y": 350, + "width": 300, + "height": 200 + }, + "stream": { + "x": 370, + "y": 110, + "width": 220, + "height": 95 + }, + "transform": { + "x": 1050, + "y": 110, + "width": 300, + "height": 200 + } + } + }, + { + "id": "view-governance", + "name": "Governance", + "kind": "static", + "nodeIds": [ + "req-freshness", + "req-quality-gate", + "req-rls", + "req-lineage", + "ff-freshness", + "ff-quality", + "ff-cost", + "adr-lakehouse", + "adr-dbt", + "ingestion", + "transform", + "query", + "catalog", + "lake" + ], + "positions": { + "adr-dbt": { + "x": 320, + "y": 1362, + "width": 260, + "height": 52 + }, + "adr-lakehouse": { + "x": 0, + "y": 1362, + "width": 260, + "height": 52 + }, + "catalog": { + "x": 370, + "y": 350, + "width": 300, + "height": 200 + }, + "ff-cost": { + "x": 640, + "y": 1190, + "width": 260, + "height": 52 + }, + "ff-freshness": { + "x": 0, + "y": 1190, + "width": 260, + "height": 52 + }, + "ff-quality": { + "x": 320, + "y": 1190, + "width": 260, + "height": 52 + }, + "ingestion": { + "x": 30, + "y": 110, + "width": 300, + "height": 200 + }, + "lake": { + "x": 710, + "y": 110, + "width": 190, + "height": 130 + }, + "platform": { + "x": 0, + "y": 290, + "width": 1380, + "height": 580 + }, + "query": { + "x": 710, + "y": 350, + "width": 300, + "height": 200 + }, + "req-freshness": { + "x": 0, + "y": 990, + "width": 260, + "height": 80 + }, + "req-lineage": { + "x": 960, + "y": 990, + "width": 260, + "height": 80 + }, + "req-quality-gate": { + "x": 320, + "y": 990, + "width": 260, + "height": 80 + }, + "req-rls": { + "x": 640, + "y": 990, + "width": 260, + "height": 80 + }, + "transform": { + "x": 1050, + "y": 110, + "width": 300, + "height": 200 + } + } + }, + { + "id": "view-flow-analyst", + "name": "Analyst journey", + "kind": "dynamic", + "sequenceId": "seq-flow-analyst", + "nodeIds": [ + "flow-analyst", + "mockup-bi-home", + "mockup-sales-dashboard", + "mockup-explore", + "mockup-dataset-lineage", + "mockup-access-request" + ], + "positions": { + "flow-analyst": { + "x": 0, + "y": 1534, + "width": 1560, + "height": 330 + }, + "mockup-access-request": { + "x": 1310, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-bi-home": { + "x": 30, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-dataset-lineage": { + "x": 990, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-explore": { + "x": 670, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-sales-dashboard": { + "x": 350, + "y": 110, + "width": 220, + "height": 190 + } + } + }, + { + "id": "view-flow-data-engineer", + "name": "Data engineer journey", + "kind": "dynamic", + "sequenceId": "seq-flow-data-engineer", + "nodeIds": [ + "flow-data-engineer", + "mockup-pipelines", + "mockup-quality-runs", + "mockup-quarantine", + "mockup-freshness" + ], + "positions": { + "flow-data-engineer": { + "x": 0, + "y": 1944, + "width": 1240, + "height": 330 + }, + "mockup-freshness": { + "x": 990, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-pipelines": { + "x": 30, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-quality-runs": { + "x": 350, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-quarantine": { + "x": 670, + "y": 110, + "width": 220, + "height": 190 + } + } + } ] } diff --git a/hub/blueprint/bp-ecommerce-platform.radical b/hub/blueprint/bp-ecommerce-platform.radical index 272e825..a367375 100644 --- a/hub/blueprint/bp-ecommerce-platform.radical +++ b/hub/blueprint/bp-ecommerce-platform.radical @@ -1196,5 +1196,642 @@ "relationType": "navigates-to", "label": "Back to orders" } + ], + "sequences": [ + { + "id": "seq-technical", + "name": "Place an order and pay", + "relationIds": [ + "shopper--storefront", + "storefront--gateway", + "gateway--cart", + "cart--catalog", + "cart--orders", + "orders--orders-db", + "orders--bus", + "bus--payments", + "payments--psp", + "payments--bus", + "bus--orders", + "orders--email", + "bus--fulfilment", + "fulfilment--carrier" + ], + "stepDescriptions": [ + "Shopper adds the item to the cart and checks out.", + "Storefront submits the checkout step through the API gateway.", + "Gateway routes /checkout/** to Cart & Checkout.", + "Cart re-checks prices and availability.", + "Cart creates the order.", + "Order and outbox event are written in one transaction; stock is reserved.", + "OrderPlaced is published via the outbox.", + "Payment Service consumes OrderPlaced.", + "Payment is captured once, keyed by the order id.", + "PaymentCaptured is published.", + "Order Service marks the order paid.", + "Order confirmation e-mail goes to the shopper.", + "Fulfilment consumes PaymentCaptured.", + "Fulfilment books the shipment with the carrier." + ] + }, + { + "id": "seq-flow-shopper", + "name": "Shopper journey", + "relationIds": [ + "mockup-home--mockup-search-results", + "mockup-search-results--mockup-product-page", + "mockup-product-page--mockup-cart", + "mockup-cart--mockup-checkout-address", + "mockup-checkout-address--mockup-checkout-shipping", + "mockup-checkout-shipping--mockup-checkout", + "mockup-checkout--mockup-order-confirmation", + "mockup-order-confirmation--mockup-order-tracking", + "mockup-checkout--mockup-payment-declined", + "mockup-payment-declined--mockup-checkout", + "mockup-order-confirmation--mockup-home" + ], + "stepDescriptions": [ + "Search → Search Results. Filtered results from the search index (at most {{INDEX_LAG_SECONDS}} s behind the catalogue).", + "Open product → Product Page. Product detail with live availability from the catalogue; \"Add to cart\" leads into checkout.", + "Add to cart → Cart. Cart with quantities and promo code; shipping is quoted at the next step.", + "Checkout → Checkout — Address. First checkout step; like every step it answers within {{CHECKOUT_P95_MS}} ms at p95.", + "Continue → Checkout — Shipping. Live carrier quotes; still inside the checkout latency budget.", + "Continue → Checkout — Payment. Final checkout step.", + "Pay → Order Confirmation. Shown once payment is captured.", + "Track order → Order Tracking. Order status and carrier events after fulfilment books the shipment.", + "Card declined → Payment Declined. Declined payment: nothing was captured, items stay reserved for the rest of the {{RESERVATION_MINUTES}}-minute hold, and retrying reuses the same idempotency key.", + "Try another card → Checkout — Payment. Final checkout step.", + "Continue shopping → Home. Landing page: search, campaigns and categories." + ] + }, + { + "id": "seq-flow-operations", + "name": "Operations journey", + "relationIds": [ + "mockup-bo-catalogue--mockup-bo-product-editor", + "mockup-bo-product-editor--mockup-bo-catalogue", + "mockup-bo-catalogue--mockup-bo-orders", + "mockup-bo-orders--mockup-bo-order-detail", + "mockup-bo-order-detail--mockup-bo-orders" + ], + "stepDescriptions": [ + "Edit product → Product Editor. Edit price, stock and copy; changes reach the search index within {{INDEX_LAG_SECONDS}} s.", + "Save changes → Catalogue. Products with price, stock and publication state.", + "Orders → Orders. Order list filtered to the exceptions staff act on.", + "Open order → Order Detail. Payment attempts share one idempotency key, so staff can see nothing was captured twice; stock is released automatically when the hold expires.", + "Back to orders → Orders. Order list filtered to the exceptions staff act on." + ] + } + ], + "views": [ + { + "id": "view-context", + "name": "System context", + "kind": "static", + "nodeIds": [ + "shopper", + "ops", + "platform", + "psp", + "carrier", + "email" + ], + "collapsedNodeIds": [ + "platform" + ], + "positions": { + "ops": { + "x": 210, + "y": 0, + "width": 150, + "height": 170 + }, + "platform": { + "x": 0, + "y": 290, + "width": 280, + "height": 180 + }, + "shopper": { + "x": 0, + "y": 0, + "width": 150, + "height": 170 + }, + "psp": { + "x": 400, + "y": 290, + "width": 360, + "height": 260 + }, + "carrier": { + "x": 820, + "y": 290, + "width": 360, + "height": 260 + }, + "email": { + "x": 1240, + "y": 290, + "width": 360, + "height": 260 + } + } + }, + { + "id": "view-containers", + "name": "Containers", + "kind": "static", + "nodeIds": [ + "shopper", + "ops", + "platform", + "storefront", + "backoffice", + "gateway", + "bus", + "catalog", + "cart", + "orders", + "payments", + "fulfilment", + "catalog-db", + "cart-db", + "orders-db", + "payments-db", + "search", + "psp", + "carrier", + "email" + ], + "positions": { + "backoffice": { + "x": 370, + "y": 110, + "width": 210, + "height": 140 + }, + "bus": { + "x": 1050, + "y": 110, + "width": 220, + "height": 95 + }, + "carrier": { + "x": 420, + "y": 1160, + "width": 360, + "height": 260 + }, + "cart": { + "x": 30, + "y": 350, + "width": 300, + "height": 200 + }, + "cart-db": { + "x": 30, + "y": 590, + "width": 190, + "height": 130 + }, + "catalog": { + "x": 1390, + "y": 110, + "width": 300, + "height": 200 + }, + "catalog-db": { + "x": 1390, + "y": 350, + "width": 190, + "height": 130 + }, + "email": { + "x": 840, + "y": 1160, + "width": 360, + "height": 260 + }, + "fulfilment": { + "x": 1050, + "y": 350, + "width": 300, + "height": 200 + }, + "gateway": { + "x": 710, + "y": 110, + "width": 300, + "height": 200 + }, + "ops": { + "x": 210, + "y": 0, + "width": 150, + "height": 170 + }, + "orders": { + "x": 370, + "y": 350, + "width": 300, + "height": 200 + }, + "orders-db": { + "x": 370, + "y": 590, + "width": 190, + "height": 130 + }, + "payments": { + "x": 710, + "y": 350, + "width": 300, + "height": 200 + }, + "payments-db": { + "x": 710, + "y": 590, + "width": 190, + "height": 130 + }, + "platform": { + "x": 0, + "y": 290, + "width": 1720, + "height": 750 + }, + "psp": { + "x": 0, + "y": 1160, + "width": 360, + "height": 260 + }, + "search": { + "x": 1050, + "y": 590, + "width": 190, + "height": 130 + }, + "shopper": { + "x": 0, + "y": 0, + "width": 150, + "height": 170 + }, + "storefront": { + "x": 30, + "y": 110, + "width": 210, + "height": 140 + } + } + }, + { + "id": "view-technical-flow", + "name": "Place an order and pay", + "kind": "dynamic", + "sequenceId": "seq-technical", + "nodeIds": [ + "shopper", + "storefront", + "gateway", + "cart", + "catalog", + "orders", + "orders-db", + "bus", + "payments", + "psp", + "email", + "fulfilment", + "carrier" + ], + "positions": { + "bus": { + "x": 1050, + "y": 110, + "width": 220, + "height": 95 + }, + "carrier": { + "x": 420, + "y": 1160, + "width": 360, + "height": 260 + }, + "cart": { + "x": 30, + "y": 350, + "width": 300, + "height": 200 + }, + "catalog": { + "x": 1390, + "y": 110, + "width": 300, + "height": 200 + }, + "email": { + "x": 840, + "y": 1160, + "width": 360, + "height": 260 + }, + "fulfilment": { + "x": 1050, + "y": 350, + "width": 300, + "height": 200 + }, + "gateway": { + "x": 710, + "y": 110, + "width": 300, + "height": 200 + }, + "orders": { + "x": 370, + "y": 350, + "width": 300, + "height": 200 + }, + "orders-db": { + "x": 370, + "y": 590, + "width": 190, + "height": 130 + }, + "payments": { + "x": 710, + "y": 350, + "width": 300, + "height": 200 + }, + "platform": { + "x": 0, + "y": 290, + "width": 1720, + "height": 750 + }, + "psp": { + "x": 0, + "y": 1160, + "width": 360, + "height": 260 + }, + "shopper": { + "x": 0, + "y": 0, + "width": 150, + "height": 170 + }, + "storefront": { + "x": 30, + "y": 110, + "width": 210, + "height": 140 + } + } + }, + { + "id": "view-governance", + "name": "Governance", + "kind": "static", + "nodeIds": [ + "req-checkout-latency", + "req-payment-once", + "req-stock-reservation", + "req-pci-scope", + "ff-checkout-conversion", + "ff-payment-reconciliation", + "ff-search-freshness", + "adr-hosted-payments", + "adr-database-per-service", + "cart", + "payments", + "orders", + "search", + "platform" + ], + "positions": { + "adr-database-per-service": { + "x": 320, + "y": 1912, + "width": 260, + "height": 52 + }, + "adr-hosted-payments": { + "x": 0, + "y": 1912, + "width": 260, + "height": 52 + }, + "cart": { + "x": 30, + "y": 350, + "width": 300, + "height": 200 + }, + "ff-checkout-conversion": { + "x": 0, + "y": 1740, + "width": 260, + "height": 52 + }, + "ff-payment-reconciliation": { + "x": 320, + "y": 1740, + "width": 260, + "height": 52 + }, + "ff-search-freshness": { + "x": 640, + "y": 1740, + "width": 260, + "height": 52 + }, + "orders": { + "x": 370, + "y": 350, + "width": 300, + "height": 200 + }, + "payments": { + "x": 710, + "y": 350, + "width": 300, + "height": 200 + }, + "platform": { + "x": 0, + "y": 290, + "width": 1720, + "height": 750 + }, + "req-checkout-latency": { + "x": 0, + "y": 1540, + "width": 260, + "height": 80 + }, + "req-payment-once": { + "x": 320, + "y": 1540, + "width": 260, + "height": 80 + }, + "req-pci-scope": { + "x": 960, + "y": 1540, + "width": 260, + "height": 80 + }, + "req-stock-reservation": { + "x": 640, + "y": 1540, + "width": 260, + "height": 80 + }, + "search": { + "x": 1050, + "y": 590, + "width": 190, + "height": 130 + } + } + }, + { + "id": "view-flow-shopper", + "name": "Shopper journey", + "kind": "dynamic", + "sequenceId": "seq-flow-shopper", + "nodeIds": [ + "flow-shopper", + "mockup-home", + "mockup-search-results", + "mockup-product-page", + "mockup-cart", + "mockup-checkout-address", + "mockup-checkout-shipping", + "mockup-checkout", + "mockup-payment-declined", + "mockup-order-confirmation", + "mockup-order-tracking" + ], + "positions": { + "flow-shopper": { + "x": 0, + "y": 2084, + "width": 1560, + "height": 630 + }, + "mockup-cart": { + "x": 990, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-checkout": { + "x": 350, + "y": 410, + "width": 220, + "height": 190 + }, + "mockup-checkout-address": { + "x": 1310, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-checkout-shipping": { + "x": 30, + "y": 410, + "width": 220, + "height": 190 + }, + "mockup-home": { + "x": 30, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-order-confirmation": { + "x": 990, + "y": 410, + "width": 220, + "height": 190 + }, + "mockup-order-tracking": { + "x": 1310, + "y": 410, + "width": 220, + "height": 190 + }, + "mockup-payment-declined": { + "x": 670, + "y": 410, + "width": 220, + "height": 190 + }, + "mockup-product-page": { + "x": 670, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-search-results": { + "x": 350, + "y": 110, + "width": 220, + "height": 190 + } + } + }, + { + "id": "view-flow-operations", + "name": "Operations journey", + "kind": "dynamic", + "sequenceId": "seq-flow-operations", + "nodeIds": [ + "flow-operations", + "mockup-bo-catalogue", + "mockup-bo-product-editor", + "mockup-bo-orders", + "mockup-bo-order-detail" + ], + "positions": { + "flow-operations": { + "x": 0, + "y": 2794, + "width": 1240, + "height": 330 + }, + "mockup-bo-catalogue": { + "x": 30, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-bo-order-detail": { + "x": 990, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-bo-orders": { + "x": 670, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-bo-product-editor": { + "x": 350, + "y": 110, + "width": 220, + "height": 190 + } + } + } ] } diff --git a/hub/blueprint/bp-fintech-ledger-platform.radical b/hub/blueprint/bp-fintech-ledger-platform.radical index ea55cc0..cd8f430 100644 --- a/hub/blueprint/bp-fintech-ledger-platform.radical +++ b/hub/blueprint/bp-fintech-ledger-platform.radical @@ -986,9 +986,216 @@ "Reconciliation worker independently reads posted entries from the journal.", "Reconciliation worker also fetches the bank's own end-of-day statement in a nightly batch and matches it against what was posted, catching anything the event stream alone would have missed." ] + }, + { + "id": "seq-flow-customer", + "name": "Customer journey", + "relationIds": [ + "mockup-accounts-overview--mockup-statement", + "mockup-statement--mockup-transaction-detail", + "mockup-transaction-detail--mockup-dispute", + "mockup-dispute--mockup-dispute-status" + ], + "stepDescriptions": [ + "View statements → Account Statement. Customer statement generated strictly from balances replayed through a fixed cut-off; each line links to a dispute.", + "Open transaction → Transaction Detail. One statement line with its settlement status and the journal entry behind it.", + "Dispute → Dispute a Transaction. Opens an investigation the moment it is submitted; the customer gets a resolution or explanation within {{DISPUTE_INVESTIGATION_DAYS}} days.", + "Submit dispute → Dispute Status. Open dispute with the time left until the {{DISPUTE_INVESTIGATION_DAYS}}-day resolution deadline." + ] + }, + { + "id": "seq-flow-finance-ops", + "name": "Finance Ops journey", + "relationIds": [ + "mockup-reconciliation-exceptions--mockup-exception-detail", + "mockup-exception-detail--mockup-compensating-entry", + "mockup-compensating-entry--mockup-entry-approval", + "mockup-entry-approval--mockup-journal-entry", + "mockup-journal-entry--mockup-reconciliation-exceptions", + "mockup-exception-detail--mockup-reconciliation-exceptions" + ], + "stepDescriptions": [ + "Open exception → Exception Detail. Settlement event next to candidate journal entries; a manual match or a compensating entry clears it.", + "Compensating entry → Compensating Entry. Double-entry form that cannot be submitted unless debits equal credits.", + "Submit for approval → Four-Eyes Approval. A second person approves before the entry is posted; nobody approves their own entries.", + "Approve & post → Journal Entry. Posted entries are immutable; the only correction is another entry.", + "Exception cleared → Reconciliation Exceptions. Finance Ops queue of settlement events not matched within {{RECONCILIATION_LAG_MIN}} minutes.", + "Confirm match → Reconciliation Exceptions. Finance Ops queue of settlement events not matched within {{RECONCILIATION_LAG_MIN}} minutes." + ] + }, + { + "id": "seq-flow-compliance", + "name": "Compliance journey", + "relationIds": [ + "mockup-audit-trail--mockup-regulatory-reports" + ], + "stepDescriptions": [ + "Reports → Regulatory Reports. Scheduled regulatory extracts built from the journal at a fixed cut-off, signed off by Compliance." + ] } ], "views": [ + { + "id": "view-context", + "name": "System context", + "kind": "static", + "nodeIds": [ + "customer", + "finance-ops", + "compliance-officer", + "platform", + "bank-rail", + "regulator" + ], + "collapsedNodeIds": [ + "platform" + ], + "positions": { + "compliance-officer": { + "x": 420, + "y": 0, + "width": 150, + "height": 170 + }, + "customer": { + "x": 0, + "y": 0, + "width": 150, + "height": 170 + }, + "finance-ops": { + "x": 210, + "y": 0, + "width": 150, + "height": 170 + }, + "platform": { + "x": 0, + "y": 290, + "width": 280, + "height": 180 + }, + "bank-rail": { + "x": 400, + "y": 290, + "width": 360, + "height": 260 + }, + "regulator": { + "x": 820, + "y": 290, + "width": 360, + "height": 260 + } + } + }, + { + "id": "view-containers", + "name": "Containers", + "kind": "static", + "nodeIds": [ + "customer", + "finance-ops", + "compliance-officer", + "platform", + "posting-api", + "balance-projector", + "reconciliation-worker", + "statement-service", + "finance-ops-console", + "settlement-events", + "journal-store", + "balance-store", + "bank-rail", + "regulator" + ], + "positions": { + "balance-projector": { + "x": 370, + "y": 110, + "width": 300, + "height": 200 + }, + "balance-store": { + "x": 370, + "y": 590, + "width": 190, + "height": 130 + }, + "bank-rail": { + "x": 0, + "y": 1160, + "width": 360, + "height": 260 + }, + "compliance-officer": { + "x": 420, + "y": 0, + "width": 150, + "height": 170 + }, + "customer": { + "x": 0, + "y": 0, + "width": 150, + "height": 170 + }, + "finance-ops": { + "x": 210, + "y": 0, + "width": 150, + "height": 170 + }, + "finance-ops-console": { + "x": 370, + "y": 350, + "width": 210, + "height": 140 + }, + "journal-store": { + "x": 30, + "y": 590, + "width": 190, + "height": 130 + }, + "platform": { + "x": 0, + "y": 290, + "width": 1040, + "height": 750 + }, + "posting-api": { + "x": 30, + "y": 110, + "width": 300, + "height": 200 + }, + "reconciliation-worker": { + "x": 710, + "y": 110, + "width": 300, + "height": 200 + }, + "regulator": { + "x": 420, + "y": 1160, + "width": 360, + "height": 260 + }, + "settlement-events": { + "x": 710, + "y": 350, + "width": 220, + "height": 95 + }, + "statement-service": { + "x": 30, + "y": 350, + "width": 300, + "height": 200 + } + } + }, { "id": "bp-fintech-ledger-platform-flow", "name": "Settlement arrives, posts, and reconciles", @@ -1010,7 +1217,340 @@ "bank-rail", "regulator" ], - "positions": {} + "positions": { + "balance-projector": { + "x": 370, + "y": 110, + "width": 300, + "height": 200 + }, + "balance-store": { + "x": 370, + "y": 590, + "width": 190, + "height": 130 + }, + "bank-rail": { + "x": 0, + "y": 1160, + "width": 360, + "height": 260 + }, + "compliance-officer": { + "x": 420, + "y": 0, + "width": 150, + "height": 170 + }, + "customer": { + "x": 0, + "y": 0, + "width": 150, + "height": 170 + }, + "finance-ops": { + "x": 210, + "y": 0, + "width": 150, + "height": 170 + }, + "finance-ops-console": { + "x": 370, + "y": 350, + "width": 210, + "height": 140 + }, + "journal-store": { + "x": 30, + "y": 590, + "width": 190, + "height": 130 + }, + "platform": { + "x": 0, + "y": 290, + "width": 1040, + "height": 750 + }, + "posting-api": { + "x": 30, + "y": 110, + "width": 300, + "height": 200 + }, + "reconciliation-worker": { + "x": 710, + "y": 110, + "width": 300, + "height": 200 + }, + "regulator": { + "x": 420, + "y": 1160, + "width": 360, + "height": 260 + }, + "settlement-events": { + "x": 710, + "y": 350, + "width": 220, + "height": 95 + }, + "statement-service": { + "x": 30, + "y": 350, + "width": 300, + "height": 200 + } + } + }, + { + "id": "view-governance", + "name": "Governance", + "kind": "static", + "nodeIds": [ + "req-balanced-entry-enforcement", + "req-reconciliation-freshness", + "req-statement-accuracy-cutoff", + "req-dispute-investigation-sla", + "ff-settlement-backlog-guard", + "ff-manual-adjustment-rate-guard", + "ff-regulatory-report-timeliness", + "adr-suspense-account-for-unmatched", + "adr-ledger-as-system-of-record", + "posting-api", + "reconciliation-worker", + "statement-service", + "finance-ops-console", + "balance-projector" + ], + "positions": { + "adr-ledger-as-system-of-record": { + "x": 320, + "y": 1912, + "width": 260, + "height": 52 + }, + "adr-suspense-account-for-unmatched": { + "x": 0, + "y": 1912, + "width": 260, + "height": 52 + }, + "balance-projector": { + "x": 370, + "y": 110, + "width": 300, + "height": 200 + }, + "ff-manual-adjustment-rate-guard": { + "x": 320, + "y": 1740, + "width": 260, + "height": 52 + }, + "ff-regulatory-report-timeliness": { + "x": 640, + "y": 1740, + "width": 260, + "height": 52 + }, + "ff-settlement-backlog-guard": { + "x": 0, + "y": 1740, + "width": 260, + "height": 52 + }, + "finance-ops-console": { + "x": 370, + "y": 350, + "width": 210, + "height": 140 + }, + "platform": { + "x": 0, + "y": 290, + "width": 1040, + "height": 750 + }, + "posting-api": { + "x": 30, + "y": 110, + "width": 300, + "height": 200 + }, + "reconciliation-worker": { + "x": 710, + "y": 110, + "width": 300, + "height": 200 + }, + "req-balanced-entry-enforcement": { + "x": 0, + "y": 1540, + "width": 260, + "height": 80 + }, + "req-dispute-investigation-sla": { + "x": 960, + "y": 1540, + "width": 260, + "height": 80 + }, + "req-reconciliation-freshness": { + "x": 320, + "y": 1540, + "width": 260, + "height": 80 + }, + "req-statement-accuracy-cutoff": { + "x": 640, + "y": 1540, + "width": 260, + "height": 80 + }, + "statement-service": { + "x": 30, + "y": 350, + "width": 300, + "height": 200 + } + } + }, + { + "id": "view-flow-customer", + "name": "Customer journey", + "kind": "dynamic", + "sequenceId": "seq-flow-customer", + "nodeIds": [ + "flow-customer", + "mockup-accounts-overview", + "mockup-statement", + "mockup-transaction-detail", + "mockup-dispute", + "mockup-dispute-status" + ], + "positions": { + "flow-customer": { + "x": 0, + "y": 2084, + "width": 1560, + "height": 330 + }, + "mockup-accounts-overview": { + "x": 30, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-dispute": { + "x": 990, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-dispute-status": { + "x": 1310, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-statement": { + "x": 350, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-transaction-detail": { + "x": 670, + "y": 110, + "width": 220, + "height": 190 + } + } + }, + { + "id": "view-flow-finance-ops", + "name": "Finance Ops journey", + "kind": "dynamic", + "sequenceId": "seq-flow-finance-ops", + "nodeIds": [ + "flow-finance-ops", + "mockup-reconciliation-exceptions", + "mockup-exception-detail", + "mockup-compensating-entry", + "mockup-entry-approval", + "mockup-journal-entry" + ], + "positions": { + "flow-finance-ops": { + "x": 0, + "y": 2494, + "width": 1560, + "height": 330 + }, + "mockup-compensating-entry": { + "x": 670, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-entry-approval": { + "x": 990, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-exception-detail": { + "x": 350, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-journal-entry": { + "x": 1310, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-reconciliation-exceptions": { + "x": 30, + "y": 110, + "width": 220, + "height": 190 + } + } + }, + { + "id": "view-flow-compliance", + "name": "Compliance journey", + "kind": "dynamic", + "sequenceId": "seq-flow-compliance", + "nodeIds": [ + "flow-compliance", + "mockup-audit-trail", + "mockup-regulatory-reports" + ], + "positions": { + "flow-compliance": { + "x": 0, + "y": 2904, + "width": 600, + "height": 330 + }, + "mockup-audit-trail": { + "x": 30, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-regulatory-reports": { + "x": 350, + "y": 110, + "width": 220, + "height": 190 + } + } } ] } diff --git a/hub/blueprint/bp-marketplace-platform.radical b/hub/blueprint/bp-marketplace-platform.radical index 70963be..c8c592c 100644 --- a/hub/blueprint/bp-marketplace-platform.radical +++ b/hub/blueprint/bp-marketplace-platform.radical @@ -1047,9 +1047,225 @@ "Once the order is delivered and the dispute window has passed with no dispute open, the payout worker marks it payout-eligible.", "Payout worker instructs the facilitator to release the seller's share, net of commission." ] + }, + { + "id": "seq-flow-buyer", + "name": "Buyer journey", + "relationIds": [ + "mockup-mp-search--mockup-mp-listing", + "mockup-mp-listing--mockup-mp-checkout", + "mockup-mp-checkout--mockup-order-dispute", + "mockup-order-dispute--mockup-mp-open-dispute", + "mockup-mp-open-dispute--mockup-mp-dispute-status" + ], + "stepDescriptions": [ + "Open listing → Listing. Listing page with seller reputation and buyer protection terms.", + "Buy now → Checkout. Payment goes to the payments facilitator, which holds the funds; the platform never custodies them.", + "Pay → Order & Buyer Protection. Buyer view of an order: funds stay held for {{DISPUTE_WINDOW_DAYS}} days after delivery, and opening a dispute freezes the payout.", + "Open a dispute → Open a Dispute. Opening a dispute inside the {{DISPUTE_WINDOW_DAYS}}-day window freezes the seller payout.", + "Open dispute → Dispute Status. Conversation and status while Trust & Safety reviews." + ] + }, + { + "id": "seq-flow-seller", + "name": "Seller journey", + "relationIds": [ + "mockup-seller-dashboard--mockup-new-listing", + "mockup-new-listing--mockup-my-listings", + "mockup-my-listings--mockup-new-listing", + "mockup-seller-dashboard--mockup-seller-payouts" + ], + "stepDescriptions": [ + "New listing → New Listing. Seller creates a listing; it goes into review and only appears in search once approved (within {{LISTING_REVIEW_SLA_HOURS}} h).", + "Submit for review → My Listings. Listing states from the moderation workflow — in review, live, rejected with a reason.", + "Edit & resubmit → New Listing. Seller creates a listing; it goes into review and only appears in search once approved (within {{LISTING_REVIEW_SLA_HOURS}} h).", + "Payouts → Payouts. Per-order split: {{COMMISSION_PCT}}% commission deducted, payouts held for the protection window and frozen while a dispute is open." + ] + }, + { + "id": "seq-flow-trust-safety", + "name": "Trust & Safety journey", + "relationIds": [ + "mockup-listing-review-queue--mockup-seller-investigation", + "mockup-seller-investigation--mockup-ts-dispute-case", + "mockup-seller-investigation--mockup-listing-review-queue" + ], + "stepDescriptions": [ + "Suspend → Seller Investigation. Suspending a seller removes every listing from search within {{SUPPRESSION_MINUTES}} minutes.", + "Disputes → Dispute Case. Evidence from both sides; the payout stays frozen until a resolution is chosen.", + "Suspend seller → Listing Review Queue. Trust & Safety queue sorted by time left on the review SLA." + ] } ], "views": [ + { + "id": "view-context", + "name": "System context", + "kind": "static", + "nodeIds": [ + "buyer", + "seller", + "trust-safety-agent", + "platform", + "payments-provider", + "notification-provider" + ], + "collapsedNodeIds": [ + "platform" + ], + "positions": { + "buyer": { + "x": 0, + "y": 0, + "width": 150, + "height": 170 + }, + "platform": { + "x": 0, + "y": 290, + "width": 280, + "height": 180 + }, + "seller": { + "x": 210, + "y": 0, + "width": 150, + "height": 170 + }, + "trust-safety-agent": { + "x": 420, + "y": 0, + "width": 150, + "height": 170 + }, + "payments-provider": { + "x": 400, + "y": 290, + "width": 360, + "height": 260 + }, + "notification-provider": { + "x": 820, + "y": 290, + "width": 360, + "height": 260 + } + } + }, + { + "id": "view-containers", + "name": "Containers", + "kind": "static", + "nodeIds": [ + "buyer", + "seller", + "trust-safety-agent", + "platform", + "listings-service", + "order-checkout-service", + "payments-facilitator-adapter", + "payout-worker", + "trust-safety-console", + "order-events", + "listings-db", + "orders-db", + "search-index", + "payments-provider", + "notification-provider" + ], + "positions": { + "buyer": { + "x": 0, + "y": 0, + "width": 150, + "height": 170 + }, + "listings-db": { + "x": 30, + "y": 590, + "width": 190, + "height": 130 + }, + "listings-service": { + "x": 30, + "y": 110, + "width": 300, + "height": 200 + }, + "notification-provider": { + "x": 420, + "y": 1160, + "width": 360, + "height": 260 + }, + "order-checkout-service": { + "x": 370, + "y": 110, + "width": 300, + "height": 200 + }, + "order-events": { + "x": 710, + "y": 350, + "width": 220, + "height": 95 + }, + "orders-db": { + "x": 370, + "y": 590, + "width": 190, + "height": 130 + }, + "payments-facilitator-adapter": { + "x": 710, + "y": 110, + "width": 300, + "height": 200 + }, + "payments-provider": { + "x": 0, + "y": 1160, + "width": 360, + "height": 260 + }, + "payout-worker": { + "x": 30, + "y": 350, + "width": 300, + "height": 200 + }, + "platform": { + "x": 0, + "y": 290, + "width": 1040, + "height": 750 + }, + "search-index": { + "x": 710, + "y": 590, + "width": 190, + "height": 130 + }, + "seller": { + "x": 210, + "y": 0, + "width": 150, + "height": 170 + }, + "trust-safety-agent": { + "x": 420, + "y": 0, + "width": 150, + "height": 170 + }, + "trust-safety-console": { + "x": 370, + "y": 350, + "width": 210, + "height": 140 + } + } + }, { "id": "bp-marketplace-platform-flow", "name": "Seller lists, buyer orders, payout releases after the dispute window", @@ -1072,7 +1288,353 @@ "payments-provider", "notification-provider" ], - "positions": {} + "positions": { + "buyer": { + "x": 0, + "y": 0, + "width": 150, + "height": 170 + }, + "listings-db": { + "x": 30, + "y": 590, + "width": 190, + "height": 130 + }, + "listings-service": { + "x": 30, + "y": 110, + "width": 300, + "height": 200 + }, + "notification-provider": { + "x": 420, + "y": 1160, + "width": 360, + "height": 260 + }, + "order-checkout-service": { + "x": 370, + "y": 110, + "width": 300, + "height": 200 + }, + "order-events": { + "x": 710, + "y": 350, + "width": 220, + "height": 95 + }, + "orders-db": { + "x": 370, + "y": 590, + "width": 190, + "height": 130 + }, + "payments-facilitator-adapter": { + "x": 710, + "y": 110, + "width": 300, + "height": 200 + }, + "payments-provider": { + "x": 0, + "y": 1160, + "width": 360, + "height": 260 + }, + "payout-worker": { + "x": 30, + "y": 350, + "width": 300, + "height": 200 + }, + "platform": { + "x": 0, + "y": 290, + "width": 1040, + "height": 750 + }, + "search-index": { + "x": 710, + "y": 590, + "width": 190, + "height": 130 + }, + "seller": { + "x": 210, + "y": 0, + "width": 150, + "height": 170 + }, + "trust-safety-agent": { + "x": 420, + "y": 0, + "width": 150, + "height": 170 + }, + "trust-safety-console": { + "x": 370, + "y": 350, + "width": 210, + "height": 140 + } + } + }, + { + "id": "view-governance", + "name": "Governance", + "kind": "static", + "nodeIds": [ + "req-listing-moderation", + "req-commission-accuracy", + "req-dispute-window-hold", + "req-delisting-suppression", + "ff-payout-reconciliation-guard", + "ff-chargeback-rate-guard", + "ff-search-relevance-guard", + "adr-centralised-search-index", + "adr-suspend-first-moderation", + "listings-service", + "payments-facilitator-adapter", + "payout-worker", + "search-index", + "trust-safety-console" + ], + "positions": { + "adr-centralised-search-index": { + "x": 0, + "y": 1912, + "width": 260, + "height": 52 + }, + "adr-suspend-first-moderation": { + "x": 320, + "y": 1912, + "width": 260, + "height": 52 + }, + "ff-chargeback-rate-guard": { + "x": 320, + "y": 1740, + "width": 260, + "height": 52 + }, + "ff-payout-reconciliation-guard": { + "x": 0, + "y": 1740, + "width": 260, + "height": 52 + }, + "ff-search-relevance-guard": { + "x": 640, + "y": 1740, + "width": 260, + "height": 52 + }, + "listings-service": { + "x": 30, + "y": 110, + "width": 300, + "height": 200 + }, + "payments-facilitator-adapter": { + "x": 710, + "y": 110, + "width": 300, + "height": 200 + }, + "payout-worker": { + "x": 30, + "y": 350, + "width": 300, + "height": 200 + }, + "platform": { + "x": 0, + "y": 290, + "width": 1040, + "height": 750 + }, + "req-commission-accuracy": { + "x": 320, + "y": 1540, + "width": 260, + "height": 80 + }, + "req-delisting-suppression": { + "x": 960, + "y": 1540, + "width": 260, + "height": 80 + }, + "req-dispute-window-hold": { + "x": 640, + "y": 1540, + "width": 260, + "height": 80 + }, + "req-listing-moderation": { + "x": 0, + "y": 1540, + "width": 260, + "height": 80 + }, + "search-index": { + "x": 710, + "y": 590, + "width": 190, + "height": 130 + }, + "trust-safety-console": { + "x": 370, + "y": 350, + "width": 210, + "height": 140 + } + } + }, + { + "id": "view-flow-buyer", + "name": "Buyer journey", + "kind": "dynamic", + "sequenceId": "seq-flow-buyer", + "nodeIds": [ + "flow-buyer", + "mockup-mp-search", + "mockup-mp-listing", + "mockup-mp-checkout", + "mockup-order-dispute", + "mockup-mp-open-dispute", + "mockup-mp-dispute-status" + ], + "positions": { + "flow-buyer": { + "x": 0, + "y": 2084, + "width": 1560, + "height": 630 + }, + "mockup-mp-checkout": { + "x": 670, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-mp-dispute-status": { + "x": 30, + "y": 410, + "width": 220, + "height": 190 + }, + "mockup-mp-listing": { + "x": 350, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-mp-open-dispute": { + "x": 1310, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-mp-search": { + "x": 30, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-order-dispute": { + "x": 990, + "y": 110, + "width": 220, + "height": 190 + } + } + }, + { + "id": "view-flow-seller", + "name": "Seller journey", + "kind": "dynamic", + "sequenceId": "seq-flow-seller", + "nodeIds": [ + "flow-seller", + "mockup-seller-dashboard", + "mockup-new-listing", + "mockup-my-listings", + "mockup-seller-payouts" + ], + "positions": { + "flow-seller": { + "x": 0, + "y": 2794, + "width": 1240, + "height": 330 + }, + "mockup-my-listings": { + "x": 670, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-new-listing": { + "x": 350, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-seller-dashboard": { + "x": 30, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-seller-payouts": { + "x": 990, + "y": 110, + "width": 220, + "height": 190 + } + } + }, + { + "id": "view-flow-trust-safety", + "name": "Trust & Safety journey", + "kind": "dynamic", + "sequenceId": "seq-flow-trust-safety", + "nodeIds": [ + "flow-trust-safety", + "mockup-listing-review-queue", + "mockup-seller-investigation", + "mockup-ts-dispute-case" + ], + "positions": { + "flow-trust-safety": { + "x": 0, + "y": 3204, + "width": 920, + "height": 330 + }, + "mockup-listing-review-queue": { + "x": 30, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-seller-investigation": { + "x": 350, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-ts-dispute-case": { + "x": 670, + "y": 110, + "width": 220, + "height": 190 + } + } } ] } diff --git a/hub/blueprint/bp-realtime-collaboration.radical b/hub/blueprint/bp-realtime-collaboration.radical index 8acd379..70d210e 100644 --- a/hub/blueprint/bp-realtime-collaboration.radical +++ b/hub/blueprint/bp-realtime-collaboration.radical @@ -842,9 +842,167 @@ "Every other gateway instance receives it and relays it to its own connected replicas — merge order doesn't matter, so this can happen in any order relative to other concurrent edits.", "The originating gateway durably appends the operation to the op log." ] + }, + { + "id": "seq-flow-collaborator", + "name": "Collaborator journey", + "relationIds": [ + "mockup-doc-list--mockup-document-editor", + "mockup-document-editor--mockup-reconnect-merge", + "mockup-document-editor--mockup-version-history", + "mockup-version-history--mockup-restore-version", + "mockup-restore-version--mockup-document-editor" + ], + "stepDescriptions": [ + "Open → Document Editor. Everyone edits a local replica: changes apply instantly, keep working offline (up to {{OFFLINE_GRACE_HOURS}} h) and merge on reconnect; presence shows up to {{MAX_DOC_COLLABORATORS}} collaborators.", + "Reconnect → Back Online — Merged. On reconnect the offline operation log is replayed and merged automatically — no conflict dialogs, no lost edits.", + "History → Version History. Browse and restore any earlier state of the document, reconstructed from snapshots plus the operation log.", + "Restore this version → Restore Version. Restoring an older state is itself a new version, reconstructed from snapshot plus operation log.", + "Restore → Document Editor. Everyone edits a local replica: changes apply instantly, keep working offline (up to {{OFFLINE_GRACE_HOURS}} h) and merge on reconnect; presence shows up to {{MAX_DOC_COLLABORATORS}} collaborators." + ] + }, + { + "id": "seq-flow-workspace-admin", + "name": "Workspace admin journey", + "relationIds": [ + "mockup-share-dialog--mockup-workspace-members", + "mockup-workspace-members--mockup-remove-member", + "mockup-remove-member--mockup-access-revoked" + ], + "stepDescriptions": [ + "Manage members → Workspace Members. Everyone with access to the workspace and their role.", + "Remove → Remove Member. Removal disconnects live sessions immediately instead of waiting for tokens to expire.", + "Remove → Access Removed. What the removed collaborator sees: the session is closed and further operations are rejected." + ] } ], "views": [ + { + "id": "view-context", + "name": "System context", + "kind": "static", + "nodeIds": [ + "collaborator", + "workspace-admin", + "platform", + "identity-provider" + ], + "collapsedNodeIds": [ + "platform" + ], + "positions": { + "collaborator": { + "x": 0, + "y": 0, + "width": 150, + "height": 170 + }, + "platform": { + "x": 0, + "y": 290, + "width": 280, + "height": 180 + }, + "workspace-admin": { + "x": 210, + "y": 0, + "width": 150, + "height": 170 + }, + "identity-provider": { + "x": 400, + "y": 290, + "width": 360, + "height": 260 + } + } + }, + { + "id": "view-containers", + "name": "Containers", + "kind": "static", + "nodeIds": [ + "collaborator", + "workspace-admin", + "platform", + "editor-client", + "sync-gateway", + "op-broadcast-bus", + "snapshot-worker", + "permission-service", + "op-log-store", + "snapshot-store", + "identity-provider" + ], + "positions": { + "collaborator": { + "x": 0, + "y": 0, + "width": 150, + "height": 170 + }, + "editor-client": { + "x": 30, + "y": 110, + "width": 210, + "height": 140 + }, + "identity-provider": { + "x": 0, + "y": 1160, + "width": 360, + "height": 260 + }, + "op-broadcast-bus": { + "x": 710, + "y": 110, + "width": 220, + "height": 95 + }, + "op-log-store": { + "x": 30, + "y": 590, + "width": 190, + "height": 130 + }, + "permission-service": { + "x": 370, + "y": 350, + "width": 300, + "height": 200 + }, + "platform": { + "x": 0, + "y": 290, + "width": 960, + "height": 750 + }, + "snapshot-store": { + "x": 370, + "y": 590, + "width": 190, + "height": 130 + }, + "snapshot-worker": { + "x": 30, + "y": 350, + "width": 300, + "height": 200 + }, + "sync-gateway": { + "x": 370, + "y": 110, + "width": 300, + "height": 200 + }, + "workspace-admin": { + "x": 210, + "y": 0, + "width": 150, + "height": 170 + } + } + }, { "id": "bp-realtime-collaboration-flow", "name": "An edit is accepted, broadcast, and persisted", @@ -863,7 +1021,284 @@ "snapshot-store", "identity-provider" ], - "positions": {} + "positions": { + "collaborator": { + "x": 0, + "y": 0, + "width": 150, + "height": 170 + }, + "editor-client": { + "x": 30, + "y": 110, + "width": 210, + "height": 140 + }, + "identity-provider": { + "x": 0, + "y": 1160, + "width": 360, + "height": 260 + }, + "op-broadcast-bus": { + "x": 710, + "y": 110, + "width": 220, + "height": 95 + }, + "op-log-store": { + "x": 30, + "y": 590, + "width": 190, + "height": 130 + }, + "permission-service": { + "x": 370, + "y": 350, + "width": 300, + "height": 200 + }, + "platform": { + "x": 0, + "y": 290, + "width": 960, + "height": 750 + }, + "snapshot-store": { + "x": 370, + "y": 590, + "width": 190, + "height": 130 + }, + "snapshot-worker": { + "x": 30, + "y": 350, + "width": 300, + "height": 200 + }, + "sync-gateway": { + "x": 370, + "y": 110, + "width": 300, + "height": 200 + }, + "workspace-admin": { + "x": 210, + "y": 0, + "width": 150, + "height": 170 + } + } + }, + { + "id": "view-governance", + "name": "Governance", + "kind": "static", + "nodeIds": [ + "req-offline-merge", + "req-room-capacity", + "req-access-revocation", + "req-snapshot-recovery", + "ff-presence-fanout-guard", + "ff-snapshot-compaction-lag", + "ff-reconnect-success-rate", + "adr-crdt-over-ot", + "adr-stateless-gateway-fleet", + "sync-gateway", + "permission-service", + "op-log-store", + "snapshot-worker", + "editor-client" + ], + "positions": { + "adr-crdt-over-ot": { + "x": 0, + "y": 1912, + "width": 260, + "height": 52 + }, + "adr-stateless-gateway-fleet": { + "x": 320, + "y": 1912, + "width": 260, + "height": 52 + }, + "editor-client": { + "x": 30, + "y": 110, + "width": 210, + "height": 140 + }, + "ff-presence-fanout-guard": { + "x": 0, + "y": 1740, + "width": 260, + "height": 52 + }, + "ff-reconnect-success-rate": { + "x": 640, + "y": 1740, + "width": 260, + "height": 52 + }, + "ff-snapshot-compaction-lag": { + "x": 320, + "y": 1740, + "width": 260, + "height": 52 + }, + "op-log-store": { + "x": 30, + "y": 590, + "width": 190, + "height": 130 + }, + "permission-service": { + "x": 370, + "y": 350, + "width": 300, + "height": 200 + }, + "platform": { + "x": 0, + "y": 290, + "width": 960, + "height": 750 + }, + "req-access-revocation": { + "x": 640, + "y": 1540, + "width": 260, + "height": 80 + }, + "req-offline-merge": { + "x": 0, + "y": 1540, + "width": 260, + "height": 80 + }, + "req-room-capacity": { + "x": 320, + "y": 1540, + "width": 260, + "height": 80 + }, + "req-snapshot-recovery": { + "x": 960, + "y": 1540, + "width": 260, + "height": 80 + }, + "snapshot-worker": { + "x": 30, + "y": 350, + "width": 300, + "height": 200 + }, + "sync-gateway": { + "x": 370, + "y": 110, + "width": 300, + "height": 200 + } + } + }, + { + "id": "view-flow-collaborator", + "name": "Collaborator journey", + "kind": "dynamic", + "sequenceId": "seq-flow-collaborator", + "nodeIds": [ + "flow-collaborator", + "mockup-doc-list", + "mockup-document-editor", + "mockup-reconnect-merge", + "mockup-version-history", + "mockup-restore-version" + ], + "positions": { + "flow-collaborator": { + "x": 0, + "y": 2084, + "width": 1560, + "height": 330 + }, + "mockup-doc-list": { + "x": 30, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-document-editor": { + "x": 350, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-reconnect-merge": { + "x": 670, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-restore-version": { + "x": 1310, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-version-history": { + "x": 990, + "y": 110, + "width": 220, + "height": 190 + } + } + }, + { + "id": "view-flow-workspace-admin", + "name": "Workspace admin journey", + "kind": "dynamic", + "sequenceId": "seq-flow-workspace-admin", + "nodeIds": [ + "flow-workspace-admin", + "mockup-share-dialog", + "mockup-workspace-members", + "mockup-remove-member", + "mockup-access-revoked" + ], + "positions": { + "flow-workspace-admin": { + "x": 0, + "y": 2494, + "width": 1240, + "height": 330 + }, + "mockup-access-revoked": { + "x": 990, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-remove-member": { + "x": 670, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-share-dialog": { + "x": 30, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-workspace-members": { + "x": 350, + "y": 110, + "width": 220, + "height": 190 + } + } } ] } diff --git a/hub/blueprint/bp-saas-b2b-platform.radical b/hub/blueprint/bp-saas-b2b-platform.radical index 6396024..1581387 100644 --- a/hub/blueprint/bp-saas-b2b-platform.radical +++ b/hub/blueprint/bp-saas-b2b-platform.radical @@ -971,5 +971,591 @@ "targetId": "admin-console", "relationType": "presented-by" } + ], + "sequences": [ + { + "id": "seq-technical", + "name": "Sign-up to a provisioned workspace", + "relationIds": [ + "tenant-admin--webapp", + "webapp--gateway", + "gateway--identity-service", + "identity-service--identity-db", + "identity-service--event-bus", + "notification-service--event-bus", + "notification-service--email-provider", + "tenant-user--webapp", + "gateway--core-service", + "core-service--tenant-db" + ], + "stepDescriptions": [ + "The new customer's admin signs up in the web app.", + "Web app calls the sign-up API.", + "Gateway routes to the Identity & Tenant Service.", + "Tenant, admin user and default roles are created.", + "TenantProvisioned is published.", + "Notification Service picks up the lifecycle event.", + "Welcome e-mail with the workspace link is sent.", + "Invited users sign in to the new workspace.", + "Requests reach the core service with the resolved tenant context.", + "Every query is scoped by tenant_id." + ] + }, + { + "id": "seq-flow-onboarding", + "name": "Onboarding journey", + "relationIds": [ + "mockup-signup--mockup-provisioning", + "mockup-provisioning--mockup-invite-team" + ], + "stepDescriptions": [ + "Create workspace → Provisioning. Provisioning progress; an isolated workspace is ready within {{PROVISION_MINUTES}} minutes.", + "Ready → Invite Your Team. Last onboarding step; invites land in the new tenant only." + ] + }, + { + "id": "seq-flow-tenant-user", + "name": "Tenant user journey", + "relationIds": [ + "mockup-workspace-dashboard--mockup-project", + "mockup-workspace-dashboard--mockup-read-only" + ], + "stepDescriptions": [ + "Open project → Project. Project board; all data is scoped by tenant id — nothing from other tenants is reachable.", + "Payment fails → Read-Only Workspace. After a failed payment the workspace stays readable but writes are blocked until billing is fixed." + ] + }, + { + "id": "seq-flow-tenant-admin", + "name": "Tenant admin journey", + "relationIds": [ + "mockup-billing-usage--mockup-update-payment", + "mockup-billing-usage--mockup-sso-settings", + "mockup-sso-settings--mockup-users-roles" + ], + "stepDescriptions": [ + "Update payment method → Update Payment Method. New card; the failed invoice is retried immediately and write access comes back on success.", + "Single sign-on → Single Sign-On. SAML/OIDC federation with the tenant's own identity provider.", + "Users & roles → Users & Roles. Seats, roles and how each user signs in." + ] + }, + { + "id": "seq-flow-platform-ops", + "name": "Platform ops journey", + "relationIds": [ + "mockup-tenant-list--mockup-tenant-detail" + ], + "stepDescriptions": [ + "Open tenant → Tenant Detail. Per-tenant health against the {{MAX_TENANT_SHARE_PCT}}% noisy-neighbour limit; impersonation is audited." + ] + } + ], + "views": [ + { + "id": "view-context", + "name": "System context", + "kind": "static", + "nodeIds": [ + "tenant-user", + "tenant-admin", + "ops-staff", + "platform", + "idp", + "payment-provider", + "email-provider" + ], + "collapsedNodeIds": [ + "platform" + ], + "positions": { + "ops-staff": { + "x": 420, + "y": 0, + "width": 150, + "height": 170 + }, + "platform": { + "x": 0, + "y": 290, + "width": 280, + "height": 180 + }, + "tenant-admin": { + "x": 210, + "y": 0, + "width": 150, + "height": 170 + }, + "tenant-user": { + "x": 0, + "y": 0, + "width": 150, + "height": 170 + }, + "idp": { + "x": 400, + "y": 290, + "width": 360, + "height": 260 + }, + "payment-provider": { + "x": 820, + "y": 290, + "width": 360, + "height": 260 + }, + "email-provider": { + "x": 1240, + "y": 290, + "width": 360, + "height": 260 + } + } + }, + { + "id": "view-containers", + "name": "Containers", + "kind": "static", + "nodeIds": [ + "tenant-user", + "tenant-admin", + "ops-staff", + "platform", + "webapp", + "admin-console", + "gateway", + "identity-service", + "billing-service", + "core-service", + "notification-service", + "event-bus", + "tenant-db", + "identity-db", + "idp", + "payment-provider", + "email-provider" + ], + "positions": { + "admin-console": { + "x": 280, + "y": 110, + "width": 210, + "height": 140 + }, + "billing-service": { + "x": 370, + "y": 350, + "width": 300, + "height": 200 + }, + "core-service": { + "x": 710, + "y": 350, + "width": 300, + "height": 200 + }, + "email-provider": { + "x": 840, + "y": 1400, + "width": 360, + "height": 260 + }, + "event-bus": { + "x": 370, + "y": 590, + "width": 220, + "height": 95 + }, + "gateway": { + "x": 530, + "y": 110, + "width": 300, + "height": 200 + }, + "identity-db": { + "x": 260, + "y": 830, + "width": 190, + "height": 130 + }, + "identity-service": { + "x": 30, + "y": 350, + "width": 300, + "height": 200 + }, + "idp": { + "x": 0, + "y": 1400, + "width": 360, + "height": 260 + }, + "notification-service": { + "x": 30, + "y": 590, + "width": 300, + "height": 200 + }, + "ops-staff": { + "x": 420, + "y": 0, + "width": 150, + "height": 170 + }, + "payment-provider": { + "x": 420, + "y": 1400, + "width": 360, + "height": 260 + }, + "platform": { + "x": 0, + "y": 290, + "width": 1040, + "height": 990 + }, + "tenant-admin": { + "x": 210, + "y": 0, + "width": 150, + "height": 170 + }, + "tenant-db": { + "x": 30, + "y": 830, + "width": 190, + "height": 130 + }, + "tenant-user": { + "x": 0, + "y": 0, + "width": 150, + "height": 170 + }, + "webapp": { + "x": 30, + "y": 110, + "width": 210, + "height": 140 + } + } + }, + { + "id": "view-technical-flow", + "name": "Sign-up to a provisioned workspace", + "kind": "dynamic", + "sequenceId": "seq-technical", + "nodeIds": [ + "tenant-admin", + "webapp", + "gateway", + "identity-service", + "identity-db", + "event-bus", + "notification-service", + "email-provider", + "tenant-user", + "core-service", + "tenant-db" + ], + "positions": { + "core-service": { + "x": 710, + "y": 350, + "width": 300, + "height": 200 + }, + "email-provider": { + "x": 840, + "y": 1400, + "width": 360, + "height": 260 + }, + "event-bus": { + "x": 370, + "y": 590, + "width": 220, + "height": 95 + }, + "gateway": { + "x": 530, + "y": 110, + "width": 300, + "height": 200 + }, + "identity-db": { + "x": 260, + "y": 830, + "width": 190, + "height": 130 + }, + "identity-service": { + "x": 30, + "y": 350, + "width": 300, + "height": 200 + }, + "notification-service": { + "x": 30, + "y": 590, + "width": 300, + "height": 200 + }, + "platform": { + "x": 0, + "y": 290, + "width": 1040, + "height": 990 + }, + "tenant-admin": { + "x": 210, + "y": 0, + "width": 150, + "height": 170 + }, + "tenant-db": { + "x": 30, + "y": 830, + "width": 190, + "height": 130 + }, + "tenant-user": { + "x": 0, + "y": 0, + "width": 150, + "height": 170 + }, + "webapp": { + "x": 30, + "y": 110, + "width": 210, + "height": 140 + } + } + }, + { + "id": "view-governance", + "name": "Governance", + "kind": "static", + "nodeIds": [ + "req-tenant-isolation", + "req-tenant-provisioning-time", + "req-subscription-enforcement", + "req-usage-metering-accuracy", + "ff-tenant-isolation-fuzz", + "ff-noisy-neighbor-guard", + "ff-billing-reconciliation", + "adr-tenancy-model", + "adr-sso-federation" + ], + "positions": { + "adr-sso-federation": { + "x": 320, + "y": 2152, + "width": 260, + "height": 52 + }, + "adr-tenancy-model": { + "x": 0, + "y": 2152, + "width": 260, + "height": 52 + }, + "ff-billing-reconciliation": { + "x": 640, + "y": 1980, + "width": 260, + "height": 52 + }, + "ff-noisy-neighbor-guard": { + "x": 320, + "y": 1980, + "width": 260, + "height": 52 + }, + "ff-tenant-isolation-fuzz": { + "x": 0, + "y": 1980, + "width": 260, + "height": 52 + }, + "req-subscription-enforcement": { + "x": 640, + "y": 1780, + "width": 260, + "height": 80 + }, + "req-tenant-isolation": { + "x": 0, + "y": 1780, + "width": 260, + "height": 80 + }, + "req-tenant-provisioning-time": { + "x": 320, + "y": 1780, + "width": 260, + "height": 80 + }, + "req-usage-metering-accuracy": { + "x": 960, + "y": 1780, + "width": 260, + "height": 80 + } + } + }, + { + "id": "view-flow-onboarding", + "name": "Onboarding journey", + "kind": "dynamic", + "sequenceId": "seq-flow-onboarding", + "nodeIds": [ + "flow-onboarding", + "mockup-signup", + "mockup-provisioning", + "mockup-invite-team" + ], + "positions": { + "flow-onboarding": { + "x": 0, + "y": 2324, + "width": 920, + "height": 330 + }, + "mockup-invite-team": { + "x": 670, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-provisioning": { + "x": 350, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-signup": { + "x": 30, + "y": 110, + "width": 220, + "height": 190 + } + } + }, + { + "id": "view-flow-tenant-user", + "name": "Tenant user journey", + "kind": "dynamic", + "sequenceId": "seq-flow-tenant-user", + "nodeIds": [ + "flow-tenant-user", + "mockup-workspace-dashboard", + "mockup-project", + "mockup-read-only" + ], + "positions": { + "flow-tenant-user": { + "x": 0, + "y": 2734, + "width": 920, + "height": 330 + }, + "mockup-project": { + "x": 350, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-read-only": { + "x": 670, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-workspace-dashboard": { + "x": 30, + "y": 110, + "width": 220, + "height": 190 + } + } + }, + { + "id": "view-flow-tenant-admin", + "name": "Tenant admin journey", + "kind": "dynamic", + "sequenceId": "seq-flow-tenant-admin", + "nodeIds": [ + "flow-tenant-admin", + "mockup-billing-usage", + "mockup-update-payment", + "mockup-sso-settings", + "mockup-users-roles" + ], + "positions": { + "flow-tenant-admin": { + "x": 0, + "y": 3144, + "width": 1240, + "height": 330 + }, + "mockup-billing-usage": { + "x": 30, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-sso-settings": { + "x": 670, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-update-payment": { + "x": 350, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-users-roles": { + "x": 990, + "y": 110, + "width": 220, + "height": 190 + } + } + }, + { + "id": "view-flow-platform-ops", + "name": "Platform ops journey", + "kind": "dynamic", + "sequenceId": "seq-flow-platform-ops", + "nodeIds": [ + "flow-platform-ops", + "mockup-tenant-list", + "mockup-tenant-detail" + ], + "positions": { + "flow-platform-ops": { + "x": 0, + "y": 3554, + "width": 600, + "height": 330 + }, + "mockup-tenant-detail": { + "x": 350, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-tenant-list": { + "x": 30, + "y": 110, + "width": 220, + "height": 190 + } + } + } ] } diff --git a/src/renderer/src/components/HubImportModal.tsx b/src/renderer/src/components/HubImportModal.tsx index 70d6c0a..ba5b434 100644 --- a/src/renderer/src/components/HubImportModal.tsx +++ b/src/renderer/src/components/HubImportModal.tsx @@ -6,6 +6,7 @@ import type { C4Node, C4Relation, C4ElementType, DiagramSequence } from '../type import { NODE_SIZES } from '../types/c4' import { isParentAllowed } from '../types/metamodel' import { nodeTypeTheme } from '../types/hubTheme' +import { conceptViewsToDiagram } from '../hub/conceptViews' // ─── Props ────────────────────────────────────────────────────────────────── @@ -570,11 +571,13 @@ export function HubImportModal({ open, onClose, preselectedIds }: Props): React. // Sequences (dynamic flows) survive only if every step's relation was imported. const newSequences: Record = {} + const seqIdMap = new Map() for (const raw of concept.sequences ?? []) { const ids = (raw.relationIds as string[] | undefined) ?? [] const mapped = ids.map((id) => relIdMap.get(id)) if (ids.length === 0 || mapped.some((id) => !id)) continue const seqId = crypto.randomUUID() + if (typeof raw.id === 'string') seqIdMap.set(raw.id, seqId) newSequences[seqId] = { id: seqId, name: (raw.name as string) || concept.name, @@ -585,12 +588,20 @@ export function HubImportModal({ open, onClose, preselectedIds }: Props): React. // Single undo + bulk insert — bypasses per-node metamodel validation // so curated hub concepts always import cleanly. + // Named views travel with the concept, restricted to what was imported. + const newViews = conceptViewsToDiagram( + concept.views, + { node: (id) => idMap.get(id), relation: (id) => relIdMap.get(id), sequence: (id) => seqIdMap.get(id) }, + { newId: () => crypto.randomUUID(), namePrefix: concept.name }, + ) + store._pushUndo() store._markMilestoneEdit() useDiagramStore.setState((state) => { Object.assign(state.c4Nodes, newNodes) Object.assign(state.c4Relations, newRelations) Object.assign(state.sequences, newSequences) + for (const v of newViews) state.views[v.id] = v if (state.activeViewId && state.views[state.activeViewId]) { state.views[state.activeViewId].nodeIds.push(...Object.keys(newNodes)) } diff --git a/src/renderer/src/hub/HubApp.tsx b/src/renderer/src/hub/HubApp.tsx index 5be5a5c..c8d414b 100644 --- a/src/renderer/src/hub/HubApp.tsx +++ b/src/renderer/src/hub/HubApp.tsx @@ -263,8 +263,10 @@ function HubAppInner(): React.ReactElement { const [theme, setTheme] = useState<'dark' | 'light'>(() => (localStorage.getItem(LS_THEME) as 'dark' | 'light') || 'dark') const [collapsedGroups, setCollapsedGroups] = useState>(() => new Set()) const [tagsExpanded, setTagsExpanded] = useState(false) - // Pending view kind from a deep link, applied once the concept is loaded. + // Pending view kind (and named canvas view) from a deep link, applied once the concept is loaded. const pendingView = useRef(parseHubHash(window.location.hash).view) + const pendingCanvasView = useRef(parseHubHash(window.location.hash).canvasView) + const views = useDiagramStore((s) => s.views) useEffect(() => { document.documentElement.setAttribute('data-theme', theme) @@ -291,6 +293,13 @@ function HubAppInner(): React.ReactElement { ) const [conceptError, setConceptError] = useState(null) const viewKind = kindForViewId(activeViewId) + // The concept's own named canvas views ("Containers", a screen flow, …); + // `null` = the full model ("All elements"). + const canvasViews = useMemo( + () => Object.values(views).filter((v) => kindForViewId(v.id) === 'canvas'), + [views], + ) + const canvasViewId = viewKind === 'canvas' ? activeViewId : null // Fetch the selected concept's .radical file once the index knows about it. useEffect(() => { @@ -308,8 +317,12 @@ function HubAppInner(): React.ReactElement { if (!concept) return loadDiagram(conceptToDiagramData(concept)) const kind = pendingView.current ?? defaultViewKind(concept) + const named = pendingCanvasView.current pendingView.current = undefined - setActiveView(viewIdForKind(kind)) + pendingCanvasView.current = undefined + const hasNamed = kind === 'canvas' && !!named && (concept.views ?? []).some((v) => v.id === named) + if (hasNamed) loadDiagram(conceptToDiagramData(concept, { layoutFrom: named })) + setActiveView(hasNamed ? named! : viewIdForKind(kind)) if (kind === 'canvas') return scheduleFit(fitAll) }, [concept, loadDiagram, setActiveView, fitAll]) @@ -328,6 +341,7 @@ function HubAppInner(): React.ReactElement { browse: browsing, concept: concept?.id, view: concept ? viewKind : undefined, + canvasView: concept && canvasViewId ? canvasViewId : undefined, category: activeCategory ?? undefined, tag: setToCsv(activeTags), status: setToCsv(activeStatuses), @@ -339,14 +353,15 @@ function HubAppInner(): React.ReactElement { const url = hash || window.location.pathname if (changed) history.pushState(null, '', url) else history.replaceState(null, '', url) - }, [browsing, concept, viewKind, activeCategory, activeTags, activeStatuses, sortBy]) + }, [browsing, concept, viewKind, canvasViewId, activeCategory, activeTags, activeStatuses, sortBy]) // URL → state (back/forward, pasted links). useEffect(() => { const onHash = () => { const r = parseHubHash(window.location.hash) setBrowsing(!!r.browse) - if (r.concept !== conceptId) { pendingView.current = r.view; setConceptId(r.concept) } + if (r.concept !== conceptId) { pendingView.current = r.view; pendingCanvasView.current = r.canvasView; setConceptId(r.concept) } + else if (r.view === 'canvas' && (r.canvasView ?? null) !== canvasViewId) switchCanvasViewRef.current(r.canvasView ?? null) else if (r.view && r.view !== viewKind) setActiveView(viewIdForKind(r.view)) setCategory(r.category ?? null) setTags(csvToSet(r.tag)) @@ -359,12 +374,23 @@ function HubAppInner(): React.ReactElement { window.removeEventListener('hashchange', onHash) window.removeEventListener('popstate', onHash) } - }, [conceptId, viewKind, setActiveView, setCategory, setTags, setStatuses, setSortBy]) + }, [conceptId, viewKind, canvasViewId, setActiveView, setCategory, setTags, setStatuses, setSortBy]) + + // Reload the concept laid out for the chosen view (null = the full model + // in its default layout) — see conceptToDiagramData's `layoutFrom`. + const switchCanvasView = useCallback((viewId: string | null) => { + if (!concept) return + loadDiagram(conceptToDiagramData(concept, { layoutFrom: viewId })) + setActiveView(viewId) + scheduleFit(fitAll) + }, [concept, loadDiagram, setActiveView, fitAll]) + const switchCanvasViewRef = useRef(switchCanvasView) + switchCanvasViewRef.current = switchCanvasView const switchView = useCallback((kind: HubViewKind) => { + if (kind === 'canvas') return switchCanvasView(null) setActiveView(viewIdForKind(kind)) - if (kind === 'canvas') scheduleFit(fitAll) - }, [setActiveView, fitAll]) + }, [setActiveView, switchCanvasView]) const toggleSelect = useCallback((id: string) => { setSelectedIds((prev) => toggleInSet(prev, id)) @@ -663,6 +689,19 @@ function HubAppInner(): React.ReactElement { {viewKind === 'canvas' && ( <> + {canvasViews.length > 0 && ( + + )} diff --git a/src/renderer/src/hub/conceptToDiagram.ts b/src/renderer/src/hub/conceptToDiagram.ts index 2d2f3e2..74b38c3 100644 --- a/src/renderer/src/hub/conceptToDiagram.ts +++ b/src/renderer/src/hub/conceptToDiagram.ts @@ -12,6 +12,7 @@ import type { C4Node, C4Relation, C4ElementType, DiagramData, DiagramSequence, D import { NODE_SIZES } from '../types/c4' import { builtInGovernanceMetamodel } from '../types/metamodel' import type { HubConcept, TemplateParam } from '../store/hubStore' +import { conceptViewsToDiagram } from './conceptViews' export const HUB_WIKI_VIEW_ID = 'hub-wiki' export const HUB_TABLE_VIEW_ID = 'hub-table' @@ -52,7 +53,11 @@ export function substituteTemplateDefaults(str: string, params: TemplateParam[] }) } -export function conceptToDiagramData(concept: HubConcept): DiagramData { +/** `layoutFrom`: id of one of the concept's views whose stored positions + * replace the node coordinates — the Hub viewer runs in explore mode, where + * switching the active view deliberately keeps the current layout, so it + * reloads the concept laid out for the chosen view instead. */ +export function conceptToDiagramData(concept: HubConcept, opts: { layoutFrom?: string | null } = {}): DiagramData { const ids = new Set(concept.nodes.map((n) => String(n.id))) const params = concept.templateParams @@ -102,11 +107,26 @@ export function conceptToDiagramData(concept: HubConcept): DiagramData { const allIds = nodes.map((n) => n.id) const firstRoot = nodes.find((n) => !n.parentId)?.id ?? allIds[0] ?? null + const seqIds = new Set(sequences.map((s) => s.id)) + const keep = (set: Set) => (id: string) => (set.has(id) ? id : undefined) const views: DiagramView[] = [ + ...conceptViewsToDiagram( + concept.views, + { node: keep(ids), relation: keep(relIds), sequence: keep(seqIds) }, + { keepPositions: true }, + ), { id: HUB_WIKI_VIEW_ID, name: 'Wiki', kind: 'wiki', nodeIds: allIds, positions: {}, wikiFocusId: firstRoot }, { id: HUB_TABLE_VIEW_ID, name: 'Table', kind: 'table', nodeIds: allIds, positions: {} }, ] + const layout = opts.layoutFrom ? views.find((v) => v.id === opts.layoutFrom)?.positions : undefined + if (layout) { + for (const n of nodes) { + const p = layout[n.id] + if (p) Object.assign(n, { x: p.x, y: p.y, width: p.width, height: p.height }) + } + } + return { nodes, relations, diff --git a/src/renderer/src/hub/conceptViews.ts b/src/renderer/src/hub/conceptViews.ts new file mode 100644 index 0000000..ffab088 --- /dev/null +++ b/src/renderer/src/hub/conceptViews.ts @@ -0,0 +1,97 @@ +// ─── Hub concept views → studio views ─────────────────────────────────────── +// A concept file may carry named canvas views (`views`: static diagrams such +// as "Containers" or a screen flow, and dynamic views that play a sequence). +// The same translation is needed in three places — the Hub viewer (ids kept +// as-is) and both studio import paths (ids remapped to the freshly created +// nodes / relations / sequences) — so it lives here once. +// +// Only canvas kinds are taken over: the Hub builds its own wiki / table views +// and the studio has its defaults. A view keeps only the elements that exist +// on the receiving side, so a partial blueprint import still gets sensible +// views, and a view left with no nodes is dropped. A dynamic view whose +// sequence did not survive falls back to a static view of the same nodes. +// +// Per-view positions are only meaningful where node ids and coordinates are +// the concept's own (the Hub viewer); an import re-ids nodes and re-centres +// them on the viewport, so it drops them and the view keeps the imported +// layout. + +import type { DiagramView, NodePosition } from '../types/c4' + +type Raw = Record +type IdMap = (id: string) => string | undefined + +export interface ConceptViewMaps { + node: IdMap + relation: IdMap + sequence: IdMap +} + +export interface ConceptViewOptions { + /** New id for each view (import); omitted → keep the concept's view id. */ + newId?: () => string + /** Prepended to each view name, e.g. the concept name on import. */ + namePrefix?: string + /** Keep the view's stored node positions (only when ids are unchanged). */ + keepPositions?: boolean +} + +function mapPositions(raw: unknown, map: IdMap): Record { + const out: Record = {} + if (!raw || typeof raw !== 'object') return out + for (const [id, p] of Object.entries(raw as Record)) { + const mapped = map(id) + const pos = p as Record | null + if (!mapped || !pos) continue + const { x, y, width, height } = pos + // applyPositions overwrites width/height too, so an entry without them + // would blank the node's size — skip anything incomplete. + if ([x, y, width, height].every((v) => typeof v === 'number')) { + out[mapped] = { x, y, width, height } as NodePosition + } + } + return out +} + +function mapIds(raw: unknown, map: IdMap): string[] { + if (!Array.isArray(raw)) return [] + const out: string[] = [] + for (const id of raw) { + if (typeof id !== 'string') continue + const mapped = map(id) + if (mapped) out.push(mapped) + } + return out +} + +export function conceptViewsToDiagram( + rawViews: Raw[] | undefined, + maps: ConceptViewMaps, + opts: ConceptViewOptions = {}, +): DiagramView[] { + const out: DiagramView[] = [] + for (const [i, raw] of (rawViews ?? []).entries()) { + const kind = raw.kind === 'dynamic' ? 'dynamic' : raw.kind === undefined || raw.kind === 'static' ? 'static' : null + if (!kind) continue + const nodeIds = mapIds(raw.nodeIds, maps.node) + if (nodeIds.length === 0) continue + const sequenceId = kind === 'dynamic' && typeof raw.sequenceId === 'string' ? maps.sequence(raw.sequenceId) : undefined + const name = typeof raw.name === 'string' && raw.name ? raw.name : `View ${i + 1}` + const view: DiagramView = { + id: opts.newId ? opts.newId() : typeof raw.id === 'string' ? raw.id : `view-${i}`, + name: opts.namePrefix ? `${opts.namePrefix} — ${name}` : name, + kind: sequenceId ? 'dynamic' : 'static', + nodeIds, + positions: opts.keepPositions ? mapPositions(raw.positions, maps.node) : {}, + } + if (sequenceId) view.sequenceId = sequenceId + const collapsed = mapIds(raw.collapsedNodeIds, maps.node) + if (collapsed.length) view.collapsedNodeIds = collapsed + const expanded = mapIds(raw.expandedNodeIds, maps.node) + if (expanded.length) view.expandedNodeIds = expanded + const hidden = mapIds(raw.hiddenRelationIds, maps.relation) + if (hidden.length) view.hiddenRelationIds = hidden + out.push(view) + } + return out +} diff --git a/src/renderer/src/hub/hub.css b/src/renderer/src/hub/hub.css index 9d71caf..acd6a3f 100644 --- a/src/renderer/src/hub/hub.css +++ b/src/renderer/src/hub/hub.css @@ -207,6 +207,17 @@ } .hub-list-count { display: flex; align-items: center; gap: 8px; } .hub-sort { display: flex; align-items: center; gap: 6px; } +.hub-view-select { + max-width: 260px; + height: 28px; + padding: 0 8px; + border: 1px solid var(--border-color); + border-radius: 6px; + background: var(--input-bg); + color: var(--text-primary); + font: inherit; + font-size: 12px; +} .hub-sort select { height: 24px; padding: 0 5px; border-radius: 6px; diff --git a/src/renderer/src/hub/hubFormat.ts b/src/renderer/src/hub/hubFormat.ts index 78fd22e..9b71625 100644 --- a/src/renderer/src/hub/hubFormat.ts +++ b/src/renderer/src/hub/hubFormat.ts @@ -47,6 +47,8 @@ export interface HubRadicalDoc { nodes: Array> relations?: Array> sequences?: Array> + /** Named canvas views (static, or dynamic over a sequence). */ + views?: Array> [extra: string]: unknown } @@ -56,6 +58,8 @@ export interface HubConcept extends HubConceptMeta { relations?: Array> /** Named flows over `relations` (Studio dynamic views). */ sequences?: Array> + /** Named canvas views — see hub/conceptViews.ts. */ + views?: Array> } /** One entry of `hub/index.json`. Enough to render cards and decide drop @@ -82,14 +86,16 @@ export function conceptFile(meta: Pick): stri export function docToConcept(doc: HubRadicalDoc): HubConcept { const c: HubConcept = { ...doc.hub, nodes: doc.nodes, relations: doc.relations } if (doc.sequences?.length) c.sequences = doc.sequences + if (doc.views?.length) c.views = doc.views return c } export function conceptToDoc(concept: HubConcept): HubRadicalDoc { - const { nodes, relations, sequences, ...hub } = concept + const { nodes, relations, sequences, views, ...hub } = concept const d: HubRadicalDoc = { hub, nodes } if (relations) d.relations = relations if (sequences?.length) d.sequences = sequences + if (views?.length) d.views = views return d } diff --git a/src/renderer/src/hub/hubRoute.ts b/src/renderer/src/hub/hubRoute.ts index eadf191..7a28030 100644 --- a/src/renderer/src/hub/hubRoute.ts +++ b/src/renderer/src/hub/hubRoute.ts @@ -4,7 +4,7 @@ // the catalogue: // // #/browse -// #/c/[/v/][/cat/][/tag/][/status/][/sort/] +// #/c/[/v/][/cv/][/cat/][/tag/][/status/][/sort/] // // `c` selects the concept shown in the viewer, `v` its presentation, `cat` / // `tag` / `status` / `sort` the catalogue filters. `tag` and `status` hold a @@ -21,6 +21,8 @@ export interface HubRoute { browse?: boolean concept?: string view?: HubViewKind + /** Named canvas view of the concept (only with view = canvas). */ + canvasView?: string category?: string /** Comma-separated tag list. */ tag?: string @@ -49,6 +51,7 @@ export function parseHubHash(hash: string): HubRoute { const view = (VIEW_KINDS as readonly string[]).includes(map.v) ? (map.v as HubViewKind) : undefined const sort = (SORT_KEYS as readonly string[]).includes(map.sort) ? (map.sort as HubSortKey) : undefined const concept = map.c || undefined + const canvasView = view === 'canvas' || !view ? map.cv || undefined : undefined const category = map.cat || undefined const tag = map.tag || undefined const status = map.status || undefined @@ -57,6 +60,7 @@ export function parseHubHash(hash: string): HubRoute { ...(browse ? { browse: true } : {}), concept, view, + ...(canvasView ? { canvasView } : {}), category, tag, status, @@ -68,6 +72,7 @@ export function formatHubHash(route: HubRoute): string { const segs: string[] = [] if (route.concept) segs.push('c', encodeURIComponent(route.concept)) if (route.concept && route.view) segs.push('v', route.view) + if (route.concept && route.view === 'canvas' && route.canvasView) segs.push('cv', encodeURIComponent(route.canvasView)) if (route.category) segs.push('cat', encodeURIComponent(route.category)) if (route.tag) segs.push('tag', encodeURIComponent(route.tag)) if (route.status) segs.push('status', encodeURIComponent(route.status)) diff --git a/src/renderer/src/hub/importConcept.ts b/src/renderer/src/hub/importConcept.ts index 4850568..93c6036 100644 --- a/src/renderer/src/hub/importConcept.ts +++ b/src/renderer/src/hub/importConcept.ts @@ -10,6 +10,7 @@ import type { C4ElementType, C4Node, C4Relation, DiagramSequence } from '../type import { NODE_SIZES } from '../types/c4' import { useDiagramStore } from '../store/diagramStore' import type { HubConcept, HubImportRecord, TemplateParam } from '../store/hubStore' +import { conceptViewsToDiagram } from './conceptViews' export interface ImportHubConceptResult { nodeIds: string[] @@ -125,11 +126,13 @@ export function importHubConceptIntoDiagram( } const newSequences: Record = {} + const seqIdMap = new Map() for (const raw of templated.sequences ?? []) { const ids = (raw.relationIds as string[] | undefined) ?? [] const mapped = ids.map((id) => relIdMap.get(id)) if (ids.length === 0 || mapped.some((id) => !id)) continue const seqId = crypto.randomUUID() + if (typeof raw.id === 'string') seqIdMap.set(raw.id, seqId) newSequences[seqId] = { id: seqId, name: (raw.name as string) || concept.name, @@ -138,12 +141,19 @@ export function importHubConceptIntoDiagram( } } + const newViews = conceptViewsToDiagram( + templated.views, + { node: (id) => idMap.get(id), relation: (id) => relIdMap.get(id), sequence: (id) => seqIdMap.get(id) }, + { newId: () => crypto.randomUUID(), namePrefix: concept.name }, + ) + store._pushUndo() store._markMilestoneEdit() useDiagramStore.setState((state) => { Object.assign(state.c4Nodes, newNodes) Object.assign(state.c4Relations, newRelations) Object.assign(state.sequences, newSequences) + for (const v of newViews) state.views[v.id] = v if (state.activeViewId && state.views[state.activeViewId]) { state.views[state.activeViewId].nodeIds.push(...Object.keys(newNodes)) } diff --git a/tests/hubCatalogue.test.ts b/tests/hubCatalogue.test.ts index 994d61b..59c4d08 100644 --- a/tests/hubCatalogue.test.ts +++ b/tests/hubCatalogue.test.ts @@ -7,6 +7,8 @@ * - blueprint mockups form per-actor screen flows (flow groups linked by * navigates-to), use metamodel-valid relations and carry wireframes that * are already sanitised + * - blueprints ship named views (context, containers, governance, one per + * screen flow) over sequences whose steps all exist */ import { describe, it, expect } from 'vitest' import { resolve } from 'node:path' @@ -119,3 +121,37 @@ describe('blueprint mockups', () => { } }) }) + +describe('blueprint views and sequences', () => { + const blueprints = readCatalogue(HUB_DIR).filter((e) => e.doc.hub.category === 'blueprint') + + it.each(blueprints.map((e) => [e.file, e.doc] as const))('%s has consistent views and sequences', (_file, doc) => { + const nodeIds = new Set(doc.nodes.map((n) => String(n.id))) + const relIds = new Set((doc.relations ?? []).map((r) => String(r.id))) + const sequences = (doc.sequences ?? []) as Array<{ id: string; relationIds: string[]; stepDescriptions?: string[] }> + const seqIds = new Set(sequences.map((s) => s.id)) + const views = (doc.views ?? []) as Array<{ id: string; kind: string; sequenceId?: string; nodeIds: string[]; positions: Record> }> + + for (const s of sequences) { + expect(s.relationIds.length).toBeGreaterThan(0) + for (const id of s.relationIds) expect(relIds.has(id), `${s.id}: unknown relation ${id}`).toBe(true) + expect(s.stepDescriptions ?? []).toHaveLength(s.relationIds.length) + } + for (const v of views) { + expect(['static', 'dynamic']).toContain(v.kind) + for (const id of v.nodeIds) expect(nodeIds.has(id), `${v.id}: unknown node ${id}`).toBe(true) + if (v.kind === 'dynamic') expect(seqIds.has(v.sequenceId!), `${v.id}: unknown sequence`).toBe(true) + for (const [id, p] of Object.entries(v.positions)) { + expect(nodeIds.has(id)).toBe(true) + for (const k of ['x', 'y', 'width', 'height']) expect(typeof p[k], `${v.id}.${id}.${k}`).toBe('number') + } + } + + const ids = views.map((v) => v.id) + expect(ids).toEqual(expect.arrayContaining(['view-context', 'view-containers', 'view-governance'])) + expect(views.some((v) => v.kind === 'dynamic' && !v.id.startsWith('view-flow-')), 'technical flow').toBe(true) + for (const g of doc.nodes.filter((n) => n.type === 'group' && String(n.id).startsWith('flow-'))) { + expect(ids, `view for ${g.id}`).toContain(`view-${g.id}`) + } + }) +}) diff --git a/tests/hubViewer.test.ts b/tests/hubViewer.test.ts index fcbc5e5..8ddd555 100644 --- a/tests/hubViewer.test.ts +++ b/tests/hubViewer.test.ts @@ -5,6 +5,7 @@ * (ids preserved, governance metamodel, synthetic wiki/table views, * template defaults substituted, dangling parents/relations dropped) * - defaultViewKind: single governance element → wiki, bundles → canvas + * - conceptViewsToDiagram: concept views kept in the viewer / remapped on import * - parseHubHash / formatHubHash round-trip */ import { describe, it, expect } from 'vitest' @@ -16,6 +17,7 @@ import { HUB_TABLE_VIEW_ID, } from '../src/renderer/src/hub/conceptToDiagram' import { parseHubHash, formatHubHash, studioImportUrl } from '../src/renderer/src/hub/hubRoute' +import { conceptViewsToDiagram } from '../src/renderer/src/hub/conceptViews' import type { HubConcept } from '../src/renderer/src/store/hubStore' const requirement: HubConcept = { @@ -135,6 +137,15 @@ describe('hub route', () => { expect(parseHubHash(hash)).toEqual(r) }) + it('round-trips a named canvas view, and only on the canvas', () => { + const r = { browse: true, concept: 'bp-x', view: 'canvas' as const, canvasView: 'view-flow-shopper' } + const hash = formatHubHash(r) + expect(hash).toBe('#/c/bp-x/v/canvas/cv/view-flow-shopper') + expect(parseHubHash(hash)).toEqual(r) + expect(formatHubHash({ ...r, view: 'wiki' })).toBe('#/c/bp-x/v/wiki') + expect(parseHubHash('#/c/bp-x/v/wiki/cv/view-flow-shopper').canvasView).toBeUndefined() + }) + it('omits view without a concept and ignores unknown view kinds', () => { expect(formatHubHash({ view: 'table', category: 'adr' })).toBe('#/cat/adr') expect(parseHubHash('#/c/x/v/bogus')).toEqual({ browse: true, concept: 'x' }) @@ -163,3 +174,35 @@ describe('hub route', () => { expect(parseHubHash('#/sort/bogus')).toEqual({}) }) }) + +describe('concept views', () => { + const withViews: HubConcept = { + ...pattern, + sequences: [{ id: 's1', name: 'Write', relationIds: ['r1'] }], + views: [ + { id: 'v-flow', name: 'Write flow', kind: 'dynamic', sequenceId: 's1', nodeIds: ['sys', 'cmd'], + positions: { sys: { x: 5, y: 6, width: 360, height: 260 }, cmd: { x: 1, y: 2 } } }, + { id: 'v-wiki', name: 'Not a canvas view', kind: 'wiki', nodeIds: ['sys'] }, + { id: 'v-empty', name: 'Only ghosts', kind: 'static', nodeIds: ['ghost'] }, + ], + } + + it('shows the concept\'s canvas views in the viewer, before wiki / table, with complete positions only', () => { + const views = conceptToDiagramData(withViews).views! + expect(views.map((v) => v.id)).toEqual(['v-flow', HUB_WIKI_VIEW_ID, HUB_TABLE_VIEW_ID]) + expect(views[0]).toMatchObject({ kind: 'dynamic', sequenceId: 's1', nodeIds: ['sys', 'cmd'] }) + expect(views[0].positions).toEqual({ sys: { x: 5, y: 6, width: 360, height: 260 } }) + }) + + it('remaps ids on import, drops unmapped elements and positions, and falls back to static without the sequence', () => { + let n = 0 + const views = conceptViewsToDiagram( + withViews.views, + { node: (id) => (id === 'sys' ? 'new-sys' : undefined), relation: () => undefined, sequence: () => undefined }, + { newId: () => `id-${++n}`, namePrefix: 'CQRS' }, + ) + expect(views).toEqual([ + { id: 'id-1', name: 'CQRS — Write flow', kind: 'static', nodeIds: ['new-sys'], positions: {} }, + ]) + }) +})