docs: add architecture reports, public-landing routing plan, build-a-page tutorial, self-serve signup phase-2 design
All checks were successful
Mirror to GitHub / mirror (push) Successful in 5s
CI / frontend (pull_request) Successful in 6m45s
CI / e2e (pull_request) Successful in 10m13s
CI / backend (pull_request) Successful in 11m27s

- docs/architecture/: god-node map + report (2026-05-06), workflows.json/html + analysis snapshot
- docs/plans/2026-05-13-public-landing-routing-refactor.md
- docs/tutorials/build-a-page.md
- abc-feat-self-serve-signup-phase-2-design-20260507-112020.md (root)

Core dumps (core.144926, core.145678, docs/architecture/core.1392564) and
agent .remember/ state are intentionally left untracked.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-13 23:59:29 -04:00
parent dc88797469
commit e5b26245ca
8 changed files with 7132 additions and 0 deletions

View File

@@ -0,0 +1,336 @@
{
"nodes": [
{
"id": "title",
"type": "text",
"x": -860,
"y": -520,
"width": 1080,
"height": 150,
"color": "#2563eb",
"text": "# God Node Map\nResolutionFlow architecture hotspots, 2026-05-06\n\nRead left to right: behavioral risk -> expected infrastructure -> self-serve boundaries."
},
{
"id": "frontend_group",
"type": "group",
"x": -900,
"y": -300,
"width": 720,
"height": 760,
"color": "#fee2e2",
"label": "Frontend Behavioral Hubs"
},
{
"id": "assistant_page",
"type": "text",
"x": -860,
"y": -240,
"width": 300,
"height": 190,
"color": "#ef4444",
"text": "## AssistantChatPage.tsx\n\nHighest-risk frontend node.\n\n- 2,493 LOC\n- 39 outbound imports\n- 77 changes in 90 days\n- Owns many unrelated workflows"
},
{
"id": "tree_navigation_page",
"type": "text",
"x": -520,
"y": -240,
"width": 300,
"height": 160,
"color": "#f97316",
"text": "## TreeNavigationPage.tsx\n\nLarge page orchestrator.\n\n- 1,385 LOC\n- 31 outbound imports\n- 33 changes in 90 days"
},
{
"id": "procedural_navigation_page",
"type": "text",
"x": -860,
"y": 0,
"width": 300,
"height": 160,
"color": "#f97316",
"text": "## ProceduralNavigationPage.tsx\n\nLarge page orchestrator.\n\n- 1,021 LOC\n- 33 outbound imports\n- 22 changes in 90 days"
},
{
"id": "frontend_pages",
"type": "text",
"x": -520,
"y": 0,
"width": 300,
"height": 190,
"color": "#f59e0b",
"text": "## Other Page Hubs\n\n- TreeLibraryPage.tsx\n- TreeEditorPage.tsx\n- SessionDetailPage.tsx\n\nTreat as page shells. Extract workflow hooks when touched."
},
{
"id": "frontend_action",
"type": "text",
"x": -860,
"y": 250,
"width": 640,
"height": 150,
"color": "#16a34a",
"text": "## Frontend Rule\n\nDo not start a broad cleanup. For new self-serve work, keep billing in `useBillingStore`, keep onboarding state narrow, and prefer direct API module imports over the `@/api` barrel."
},
{
"id": "backend_group",
"type": "group",
"x": -80,
"y": -300,
"width": 740,
"height": 760,
"color": "#ffedd5",
"label": "Backend Behavioral Hubs"
},
{
"id": "flowpilot_engine",
"type": "text",
"x": -40,
"y": -240,
"width": 310,
"height": 190,
"color": "#ef4444",
"text": "## flowpilot_engine.py\n\nReal backend behavioral hub.\n\n- 1,793 LOC\n- prompts\n- structured parsing\n- session state transitions\n- model orchestration"
},
{
"id": "ai_sessions_endpoint",
"type": "text",
"x": 310,
"y": -240,
"width": 310,
"height": 180,
"color": "#f97316",
"text": "## ai_sessions.py\n\nController plus mapper.\n\n- 1,173 LOC\n- 15 outbound imports\n- 32 changes in 90 days\n\nKeep subscription/onboarding logic out."
},
{
"id": "sessions_trees_endpoints",
"type": "text",
"x": -40,
"y": 0,
"width": 310,
"height": 190,
"color": "#f59e0b",
"text": "## sessions.py / trees.py\n\nLarge endpoint hubs.\n\n- ownership\n- exports\n- sharing\n- limits\n- tree/session behavior\n\nUse guards and services instead of handler sprawl."
},
{
"id": "admin_endpoint",
"type": "text",
"x": 310,
"y": 0,
"width": 310,
"height": 150,
"color": "#f59e0b",
"text": "## admin.py\n\nLarge admin surface.\n\nHigh LOC, lower churn. Extend carefully, but not a self-serve blocker."
},
{
"id": "backend_action",
"type": "text",
"x": -40,
"y": 250,
"width": 660,
"height": 150,
"color": "#16a34a",
"text": "## Backend Rule\n\nMount subscription and email-verification checks at dependency/router boundaries. Keep billing behavior in BillingService and subscription models, not in AI/session/tree endpoints."
},
{
"id": "infra_group",
"type": "group",
"x": 820,
"y": -300,
"width": 640,
"height": 760,
"color": "#dbeafe",
"label": "Expected Infrastructure Hubs"
},
{
"id": "frontend_infra",
"type": "text",
"x": 860,
"y": -240,
"width": 260,
"height": 200,
"color": "#3b82f6",
"text": "## Frontend Infra\n\nExpected central nodes:\n\n- lib/utils.ts\n- lib/toast.ts\n- api/client.ts\n- types/index.ts\n- ui/Button.tsx"
},
{
"id": "backend_infra",
"type": "text",
"x": 1160,
"y": -240,
"width": 260,
"height": 200,
"color": "#3b82f6",
"text": "## Backend Infra\n\nExpected central nodes:\n\n- core/database.py\n- api/deps.py\n- core/config.py\n- ORM models"
},
{
"id": "barrel_cycles",
"type": "text",
"x": 860,
"y": 10,
"width": 260,
"height": 170,
"color": "#60a5fa",
"text": "## Barrel Cycles\n\n`frontend/src/api/*` has a large barrel/export cycle.\n\nLow urgency. Prefer direct imports in new code."
},
{
"id": "orm_cycles",
"type": "text",
"x": 1160,
"y": 10,
"width": 260,
"height": 170,
"color": "#60a5fa",
"text": "## ORM Cycles\n\nSQLAlchemy model cycles are expected.\n\nKeep behavior in services, not model methods."
},
{
"id": "infra_action",
"type": "text",
"x": 860,
"y": 250,
"width": 560,
"height": 150,
"color": "#16a34a",
"text": "## Infrastructure Rule\n\nDo not refactor a file just because it has high inbound count. Central utilities, clients, config, database, and model definitions are allowed to be central."
},
{
"id": "self_serve_group",
"type": "group",
"x": -900,
"y": 560,
"width": 2360,
"height": 300,
"color": "#dcfce7",
"label": "Self-Serve Signup Guidance"
},
{
"id": "no_blocker",
"type": "text",
"x": -860,
"y": 620,
"width": 360,
"height": 160,
"color": "#22c55e",
"text": "## Do Now\n\nNo large refactor is required before self-serve signup.\n\nUse this map to avoid accidental coupling while implementing the plans."
},
{
"id": "self_serve_boundaries",
"type": "text",
"x": -440,
"y": 620,
"width": 440,
"height": 170,
"color": "#22c55e",
"text": "## During Self-Serve\n\n- `useBillingStore`, not `authStore`\n- `BillingService`, not AI/session/tree endpoints\n- dependency guards, not repeated handler checks\n- direct API imports in new frontend code"
},
{
"id": "opportunistic_refactors",
"type": "text",
"x": 60,
"y": 620,
"width": 440,
"height": 170,
"color": "#84cc16",
"text": "## Opportunistic Refactors\n\n- Extract one Assistant workflow at a time\n- Extract FlowPilot prompt/validation pieces when touched\n- Move ai_sessions mapping helpers if touched again"
},
{
"id": "avoid_refactors",
"type": "text",
"x": 560,
"y": 620,
"width": 820,
"height": 170,
"color": "#a3e635",
"text": "## Avoid\n\n- Broad `AssistantChatPage` cleanup before product work\n- ORM cycle cleanup unless there is a runtime issue\n- Splitting utilities, toast, API client, or database just because they are central\n- Running self-serve behavior through AI/product endpoints"
}
],
"edges": [
{
"id": "edge_assistant_frontend_action",
"fromNode": "assistant_page",
"fromSide": "bottom",
"toNode": "frontend_action",
"toSide": "top",
"label": "extract one workflow at a time"
},
{
"id": "edge_tree_frontend_action",
"fromNode": "tree_navigation_page",
"fromSide": "bottom",
"toNode": "frontend_action",
"toSide": "top",
"label": "extract hooks when touched"
},
{
"id": "edge_proc_frontend_action",
"fromNode": "procedural_navigation_page",
"fromSide": "bottom",
"toNode": "frontend_action",
"toSide": "top"
},
{
"id": "edge_flowpilot_backend_action",
"fromNode": "flowpilot_engine",
"fromSide": "bottom",
"toNode": "backend_action",
"toSide": "top",
"label": "keep self-serve out"
},
{
"id": "edge_ai_backend_action",
"fromNode": "ai_sessions_endpoint",
"fromSide": "bottom",
"toNode": "backend_action",
"toSide": "top",
"label": "avoid billing logic here"
},
{
"id": "edge_sessions_backend_action",
"fromNode": "sessions_trees_endpoints",
"fromSide": "bottom",
"toNode": "backend_action",
"toSide": "top",
"label": "mount guards"
},
{
"id": "edge_frontend_selfserve",
"fromNode": "frontend_action",
"fromSide": "bottom",
"toNode": "self_serve_boundaries",
"toSide": "top"
},
{
"id": "edge_backend_selfserve",
"fromNode": "backend_action",
"fromSide": "bottom",
"toNode": "self_serve_boundaries",
"toSide": "top"
},
{
"id": "edge_infra_selfserve",
"fromNode": "infra_action",
"fromSide": "bottom",
"toNode": "avoid_refactors",
"toSide": "top",
"label": "do not refactor just because central"
},
{
"id": "edge_no_blocker_boundaries",
"fromNode": "no_blocker",
"fromSide": "right",
"toNode": "self_serve_boundaries",
"toSide": "left"
},
{
"id": "edge_boundaries_opportunistic",
"fromNode": "self_serve_boundaries",
"fromSide": "right",
"toNode": "opportunistic_refactors",
"toSide": "left"
},
{
"id": "edge_opportunistic_avoid",
"fromNode": "opportunistic_refactors",
"fromSide": "right",
"toNode": "avoid_refactors",
"toSide": "left"
}
]
}

View File

@@ -0,0 +1,458 @@
---
title: God Node Architecture Report
date: 2026-05-06
tags:
- architecture
- dependency-graph
- god-nodes
---
# God Node Architecture Report — 2026-05-06
## Summary
This is a static dependency and churn report for `backend/app` and `frontend/src`.
The main finding: ResolutionFlow has several expected infrastructure hubs, plus a smaller set of behavioral hubs that deserve care when touched. The highest-risk candidates are not the most-imported files; they are the files that combine high size, high churn, and many outbound dependencies.
Highest-risk behavioral hubs:
1. `frontend/src/pages/AssistantChatPage.tsx`
2. `frontend/src/pages/TreeNavigationPage.tsx`
3. `frontend/src/pages/ProceduralNavigationPage.tsx`
4. `backend/app/services/flowpilot_engine.py`
5. `backend/app/api/endpoints/ai_sessions.py`
6. `backend/app/api/endpoints/sessions.py`
7. `backend/app/api/endpoints/trees.py`
8. `backend/app/api/endpoints/admin.py`
Expected infrastructure hubs:
- `frontend/src/lib/utils.ts`
- `frontend/src/types/index.ts`
- `frontend/src/api/index.ts`
- `frontend/src/api/client.ts`
- `frontend/src/lib/toast.ts`
- `backend/app/core/database.py`
- `backend/app/api/deps.py`
- `backend/app/core/config.py`
- SQLAlchemy models such as `User`, `Tree`, `AISession`, and `Account`
Do not treat all high-degree nodes as bad. A utility, type barrel, API barrel, router, or ORM model can be central by design. The suspicious shape is: high outbound dependencies + high churn + large file + multiple unrelated reasons to change.
## Method
Inputs:
- Source files: `backend/app/**/*.py`, `frontend/src/**/*.ts`, `frontend/src/**/*.tsx`
- Excluded: tests, docs, migrations, build output, env files
- Static imports:
- Python: regex import extraction for `import ...` and `from ... import ...`
- TypeScript/TSX: static `import/export from` plus dynamic `import(...)`
- Churn: `git log --name-only --since='90 days ago'`
- Size: line count
Scoring used for triage, not truth:
```text
score = inbound_edges * 2
+ outbound_edges * 1.5
+ min(churn_90d, 30) * 1.2
+ min(lines_of_code / 100, 20)
```
Caveats:
- `backend/app/__init__.py` appears as a very high inbound node because static imports through `app.*` resolve through the package root in this simple parser. Ignore it as a parser artifact.
- Barrel files (`frontend/src/api/index.ts`, `frontend/src/types/index.ts`) intentionally create cycles with the modules they export. This is a known TypeScript graph artifact, not automatically a design flaw.
- Static graphs do not show runtime call volume. This report answers “where is the code structurally central?” not “what is hot in production?”
## Visual Map
Primary visualization:
- Open `docs/architecture/god-node-map-2026-05-06.canvas` in Obsidian.
- This uses Obsidian Canvas, so no community plugin is required.
- The Canvas groups nodes by interpretation instead of drawing every import edge.
The dense dependency graph is intentionally not the default view anymore. For architecture review, the useful split is:
1. Which nodes are high-risk behavioral hubs?
2. Which central nodes are expected infrastructure?
3. What should self-serve signup avoid touching?
### Risk Overview
```mermaid
flowchart LR
Work["Self-serve signup work"] --> Boundaries["Keep changes at boundaries"]
Boundaries --> BillingStore["useBillingStore"]
Boundaries --> Guards["router/dependency guards"]
Boundaries --> BillingService["BillingService"]
Assistant["AssistantChatPage.tsx\nfrontend god node"] -. avoid unrelated edits .-> Work
FlowPilot["flowpilot_engine.py\nbackend god node"] -. avoid unrelated edits .-> Work
AISessions["ai_sessions.py\ncontroller + mapper"] -. do not add billing logic .-> Work
SessionsTrees["sessions.py / trees.py\nlarge endpoint hubs"] -. mount guards, avoid handler sprawl .-> Work
Utils["utils / toast / api client / database\nexpected infrastructure"] -. do not refactor just because central .-> Work
```
### Frontend Hotspots
```mermaid
flowchart TB
Router["router.tsx\nroute hub"] --> Assistant["AssistantChatPage.tsx\nhighest risk"]
Router --> TreeNav["TreeNavigationPage.tsx"]
Router --> ProcNav["ProceduralNavigationPage.tsx"]
Router --> TreeLibrary["TreeLibraryPage.tsx"]
Router --> TreeEditor["TreeEditorPage.tsx"]
Router --> SessionDetail["SessionDetailPage.tsx"]
Assistant --> ExtractA["Extract one workflow at a time"]
TreeNav --> ExtractB["Extract orchestration hooks when touched"]
ProcNav --> ExtractB
Infra["utils.ts / toast.ts / api/client.ts / types/index.ts"]
Assistant --> Infra
TreeNav --> Infra
ProcNav --> Infra
```
### Backend Hotspots
```mermaid
flowchart TB
Deps["api/deps.py\nboundary hub"] --> DB["database + models\nexpected infrastructure"]
AISessions["api/endpoints/ai_sessions.py"] --> FlowPilot["services/flowpilot_engine.py"]
Sessions["api/endpoints/sessions.py"] --> Export["services/export_service.py"]
Trees["api/endpoints/trees.py"] --> DB
Admin["api/endpoints/admin.py"] --> DB
SelfServe["Self-serve backend"] --> Deps
SelfServe --> Billing["BillingService + subscriptions"]
SelfServe -. avoid .-> AISessions
SelfServe -. avoid .-> Sessions
SelfServe -. avoid .-> Trees
SelfServe -. avoid .-> FlowPilot
```
## Obsidian Visualization Options
Best default: use the generated Canvas file. Obsidian Canvas is a core plugin and stores diagrams as `.canvas` files, so it works without adding community plugin risk.
Optional plugins worth considering:
- Excalidraw: best if you want hand-edited architecture diagrams that feel like a whiteboard.
- Markmind: useful if you want this report as a mind map or outline-first view.
- Diagrams.net / draw.io plugin: useful for formal boxes-and-arrows diagrams, but heavier than Canvas for this use case.
Recommendation: start with Canvas. Add Excalidraw only if you want to manually sketch over the architecture map during planning sessions.
## Top Centrality Candidates
| Rank | File | In | Out | 90d churn | LOC | Classification | Read |
|---:|---|---:|---:|---:|---:|---|---|
| 1 | `frontend/src/lib/utils.ts` | 225 | 0 | 1 | 32 | Infrastructure hub | Good |
| 2 | `frontend/src/types/index.ts` | 137 | 32 | 22 | 103 | Barrel hub | Watch |
| 3 | `backend/app/core/database.py` | 110 | 2 | 2 | 47 | Infrastructure hub | Good |
| 4 | `backend/app/models/user.py` | 90 | 7 | 13 | 130 | Domain model hub | Watch |
| 5 | `frontend/src/api/index.ts` | 38 | 40 | 26 | 41 | API barrel hub | Watch |
| 6 | `frontend/src/lib/toast.ts` | 79 | 0 | 1 | 72 | Infrastructure hub | Good |
| 7 | `frontend/src/router.tsx` | 1 | 72 | 48 | 308 | Router hub | Watch |
| 8 | `backend/app/api/deps.py` | 56 | 9 | 13 | 292 | Auth/dependency hub | Watch |
| 9 | `backend/app/core/config.py` | 44 | 1 | 27 | 232 | Config hub | Good, but churny |
| 10 | `frontend/src/pages/AssistantChatPage.tsx` | 2 | 39 | 77 | 2493 | Behavioral hub | High risk |
| 11 | `backend/app/models/tree.py` | 43 | 10 | 11 | 233 | Domain model hub | Watch |
| 12 | `frontend/src/api/client.ts` | 51 | 2 | 5 | 173 | API client hub | Good |
| 13 | `frontend/src/pages/TreeNavigationPage.tsx` | 2 | 31 | 33 | 1385 | Behavioral hub | High risk |
| 14 | `frontend/src/components/ui/Button.tsx` | 43 | 2 | 6 | 65 | UI primitive | Good |
| 15 | `backend/app/models/ai_session.py` | 32 | 11 | 11 | 314 | Domain model hub | Watch |
| 16 | `frontend/src/pages/ProceduralNavigationPage.tsx` | 1 | 33 | 22 | 1021 | Behavioral hub | High risk |
| 17 | `frontend/src/pages/TreeLibraryPage.tsx` | 3 | 27 | 38 | 546 | Behavioral hub | Medium risk |
| 18 | `backend/app/models/account.py` | 29 | 11 | 8 | 70 | Domain model hub | Watch |
| 19 | `backend/app/api/endpoints/sessions.py` | 0 | 24 | 26 | 1186 | Endpoint hub | High risk |
| 20 | `frontend/src/pages/TreeEditorPage.tsx` | 2 | 20 | 28 | 928 | Behavioral hub | Medium risk |
| 21 | `frontend/src/pages/SessionDetailPage.tsx` | 2 | 21 | 28 | 623 | Behavioral hub | Medium risk |
| 22 | `backend/app/api/endpoints/trees.py` | 0 | 20 | 23 | 1332 | Endpoint hub | High risk |
| 23 | `backend/app/api/endpoints/ai_sessions.py` | 0 | 15 | 32 | 1173 | Endpoint hub | High risk |
| 24 | `backend/app/services/flowpilot_engine.py` | 1 | 17 | 20 | 1793 | Behavioral service hub | High risk |
## Findings
### 1. `AssistantChatPage.tsx` Is The Clearest Frontend God Node
Evidence:
- 2,493 LOC
- 39 outbound dependencies
- 77 changes in 90 days
- Owns routing, chat selection, magic-moment pickup state, task-lane state, upload state, facts, suggested fixes, preview state, script-builder surfaces, modals, keyboard shortcuts, local/session storage, and message rendering orchestration.
Classification: behavioral god node.
This file has too many reasons to change. It is not dangerous because many files import it; it is dangerous because it imports many things, owns many workflows, and changes constantly.
Recommended response:
- Do not do a broad refactor in isolation.
- When touching it, extract one workflow at a time behind a hook or controller:
- `useTaskLaneState`
- `usePilotPickup`
- `useSuggestedFixPreview`
- `useSessionFacts`
- `useScriptBuilderPanelState`
- Keep the page as an orchestrator, but move state machines and async effects out.
- Before major changes, add narrow regression tests around task-lane ownership and session switching.
Priority: high, opportunistic refactor.
### 2. `flowpilot_engine.py` Is A Real Backend Behavioral Hub
Evidence:
- 1,793 LOC
- 17 outbound dependencies
- 20 changes in 90 days
- Owns prompts, structured output parsing, session start, step generation, confidence, close/resolve/escalate behaviors, and likely several persistence transitions.
Classification: behavioral service hub.
This is not surprising: FlowPilot is core product logic. The risk is that prompt text, model call orchestration, persistence, and business rules live close together.
Recommended response:
- Keep this file stable during unrelated work.
- Extract only when a change naturally creates a seam:
- prompt construction
- structured output validation
- session state transition persistence
- documentation/status update generation
- Avoid routing new self-serve billing or account logic through this service.
Priority: high, but avoid speculative refactor.
### 3. AI Session Endpoint Is Acting As A Controller Plus Mapper
File: `backend/app/api/endpoints/ai_sessions.py`
Evidence:
- 1,173 LOC
- 15 outbound dependencies
- 32 changes in 90 days
- Contains endpoint handlers, quota checks, response mapping, ownership behavior, chat wiring, and PSA retry integration.
Classification: endpoint god node.
The endpoint does more than route HTTP to services. Some helper logic is fine, but the mapper and ownership rules should stay stable and test-backed.
Recommended response:
- Keep endpoint handlers thin when adding new features.
- Move reusable mapping logic such as `_build_session_detail` to a schema/service helper if it is touched again.
- Do not add subscription or onboarding behavior directly here; mount dependencies at router level where possible.
Priority: high for change discipline, medium for refactor.
### 4. Classic Session And Tree Endpoints Are Large, But Mostly Expected
Files:
- `backend/app/api/endpoints/sessions.py`
- `backend/app/api/endpoints/trees.py`
Evidence:
- `sessions.py`: 1,186 LOC, 24 outbound dependencies, 26 changes
- `trees.py`: 1,332 LOC, 20 outbound dependencies, 23 changes
Classification: endpoint hubs.
These files are not surprising in a CRUD-heavy FastAPI app, but they are large enough that behavioral additions should be routed through services or focused helpers.
Recommended response:
- For new subscription guards, mount dependencies instead of inserting repeated checks inside handlers.
- For new tree/session behavior, prefer service functions over adding more endpoint-local logic.
- Add regression tests before modifying export, sharing, ownership, or limit-check paths.
Priority: medium-high.
### 5. Frontend Page-Level Hubs Are The Main UI Risk
Files:
- `frontend/src/pages/TreeNavigationPage.tsx`
- `frontend/src/pages/ProceduralNavigationPage.tsx`
- `frontend/src/pages/TreeLibraryPage.tsx`
- `frontend/src/pages/TreeEditorPage.tsx`
- `frontend/src/pages/SessionDetailPage.tsx`
Pattern:
- High outbound dependencies
- Meaningful churn
- Page components own orchestration plus rendering
Recommended response:
- Treat page components as shells where possible.
- Extract stable workflow hooks before adding another workflow.
- Keep design updates scoped to subcomponents.
- Avoid adding global state unless the state truly spans routes.
Priority: medium, with `TreeNavigationPage.tsx` and `ProceduralNavigationPage.tsx` highest.
### 6. Auth Store Is Central But Not Yet A Problem
File: `frontend/src/store/authStore.ts`
Evidence:
- 21 inbound dependencies
- 5 outbound dependencies
- 144 LOC
- 6 changes in 90 days
Classification: central state hub.
This is a normal app hub. It becomes risky if billing, onboarding, feature gates, and auth all accumulate here. The self-serve specs choice to create `useBillingStore` instead of embedding billing state in `/auth/me` is the right architectural direction.
Recommended response:
- Keep auth store focused on identity/session/account bootstrap.
- Put billing in `useBillingStore`.
- Put onboarding wizard state in a narrow API/hook, not in auth.
Priority: watch.
### 7. Barrels Are Creating A Large Frontend Cycle
Cycle:
- 42 files under `frontend/src/api/*`
- Driven by `frontend/src/api/index.ts` exporting modules while some modules import from the barrel or share `apiClient`.
Classification: barrel cycle / tooling artifact with some real coupling risk.
This is common and not urgent. It can confuse static tools and make imports less explicit.
Recommended response:
- Prefer direct imports from concrete API modules in new code:
- Good: `import { aiSessionsApi } from '@/api/aiSessions'`
- Avoid: `import { aiSessionsApi } from '@/api'`
- Keep `api/index.ts` only for broad convenience if it remains useful.
- Do not spend time untangling old imports unless dependency tooling starts enforcing boundaries.
Priority: low.
### 8. Backend ORM Model Cycles Are Expected
Cycle:
- 17 files across account/user/tree/session/subscription/category/share models
- 5 files across AI session branch/handoff/step models
Classification: SQLAlchemy relationship cycle.
This is expected in an ORM with bidirectional relationships. It does not mean the model layer is broken.
Recommended response:
- Keep imports guarded with `TYPE_CHECKING` where possible.
- Keep model methods thin.
- Put behavior in services, not model properties beyond simple derived flags.
Priority: low.
## Ranked Action List
### Do Now
No immediate large refactor is recommended before self-serve signup work. The report does not show a blocker.
### Do During Self-Serve Work
1. Keep `useBillingStore` separate from `authStore`.
2. Mount subscription and email verification guards at router/dependency boundaries, not inside individual endpoint handlers.
3. Keep new billing service behavior out of existing `ai_sessions.py`, `sessions.py`, and `trees.py` except for dependency wiring.
4. Prefer direct frontend API imports over `@/api` barrel imports in new code.
### Do Opportunistically
1. Extract one workflow at a time from `AssistantChatPage.tsx`.
2. Extract prompt construction or structured response validation from `flowpilot_engine.py` when touched.
3. Move response mapping helpers out of `ai_sessions.py` if those helpers change again.
4. Split page-level orchestration hooks out of `TreeNavigationPage.tsx` and `ProceduralNavigationPage.tsx` as features touch them.
### Avoid
1. Do not split `utils.ts`, `toast.ts`, `api/client.ts`, or `core/database.py` just because they are central.
2. Do not refactor ORM model cycles unless they cause import/runtime issues.
3. Do not start a broad barrel-file cleanup unless tooling or build performance requires it.
## Raw Metrics Snapshot
Total analyzed files: 783
Total static import edges: 2,946
Top inbound hubs:
| File | Inbound | Outbound | 90d churn | LOC | Note |
|---|---:|---:|---:|---:|---|
| `frontend/src/lib/utils.ts` | 225 | 0 | 1 | 32 | Healthy utility hub |
| `frontend/src/types/index.ts` | 137 | 32 | 22 | 103 | Barrel hub |
| `backend/app/core/database.py` | 110 | 2 | 2 | 47 | Healthy infrastructure hub |
| `backend/app/models/user.py` | 90 | 7 | 13 | 130 | Domain model hub |
| `frontend/src/lib/toast.ts` | 79 | 0 | 1 | 72 | Healthy utility hub |
| `backend/app/api/deps.py` | 56 | 9 | 13 | 292 | Auth/dependency hub |
| `frontend/src/api/client.ts` | 51 | 2 | 5 | 173 | API infrastructure hub |
| `backend/app/core/config.py` | 44 | 1 | 27 | 232 | Config hub, high churn |
| `backend/app/models/tree.py` | 43 | 10 | 11 | 233 | Domain model hub |
| `frontend/src/components/ui/Button.tsx` | 43 | 2 | 6 | 65 | UI primitive |
Top outbound hubs:
| File | Inbound | Outbound | 90d churn | LOC | Note |
|---|---:|---:|---:|---:|---|
| `frontend/src/router.tsx` | 1 | 72 | 48 | 308 | Router hub, acceptable |
| `frontend/src/api/index.ts` | 38 | 40 | 26 | 41 | Barrel hub |
| `frontend/src/pages/AssistantChatPage.tsx` | 2 | 39 | 77 | 2493 | High-risk behavioral hub |
| `frontend/src/pages/ProceduralNavigationPage.tsx` | 1 | 33 | 22 | 1021 | High-risk behavioral hub |
| `frontend/src/pages/TreeNavigationPage.tsx` | 2 | 31 | 33 | 1385 | High-risk behavioral hub |
| `frontend/src/pages/TreeLibraryPage.tsx` | 3 | 27 | 38 | 546 | Medium-risk page hub |
| `backend/app/api/endpoints/sessions.py` | 0 | 24 | 26 | 1186 | High-risk endpoint hub |
| `backend/app/api/endpoints/admin.py` | 0 | 22 | 10 | 1430 | Admin endpoint hub |
| `frontend/src/pages/SessionDetailPage.tsx` | 2 | 21 | 28 | 623 | Medium-risk page hub |
| `backend/app/api/endpoints/auth.py` | 0 | 20 | 9 | 721 | Auth endpoint hub |
| `backend/app/api/endpoints/trees.py` | 0 | 20 | 23 | 1332 | High-risk endpoint hub |
| `frontend/src/pages/ProceduralEditorPage.tsx` | 1 | 20 | 16 | 475 | Medium-risk page hub |
| `frontend/src/pages/TreeEditorPage.tsx` | 2 | 20 | 28 | 928 | Medium-risk page hub |
Detected cycles:
| Size | Area | Interpretation |
|---:|---|---|
| 42 | `frontend/src/api/*` | Barrel/export cycle. Low urgency. |
| 17 | backend ORM models | Expected SQLAlchemy relationship cycle. Low urgency. |
| 5 | backend AI session models | Expected relationship cycle. Low urgency. |
| 2 | tree preview components | Small component cycle; inspect only if these files become troublesome. |
## How To Re-run
The current environment does not have native Python, so this report was generated with Node-based static parsing plus shell/git commands. A future repeat can use a dedicated script if this becomes a regular architecture check.
Suggested future command shape:
```bash
node scripts/architecture/god-node-report.mjs
```
If this becomes a recurring check, add:
- `scripts/architecture/god-node-report.mjs`
- `docs/architecture/god-node-report-YYYY-MM-DD.md`
- optional `docs/architecture/god-node-graph-YYYY-MM-DD.mmd`

View File

@@ -0,0 +1,523 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ResolutionFlow — Workflow Analysis</title>
<style>
:root {
--bg-page: #0e1016;
--bg-card: #1e2028;
--bg-elev: #2a2d38;
--border: #2a2d38;
--border-hover: #3a3d48;
--text-heading: #f4f5f7;
--text-primary: #d6d8df;
--text-muted: #8b8e98;
--text-dim: #5a5d68;
--accent: #60a5fa;
--accent-dim: rgba(96, 165, 250, 0.15);
--warning: #fbbf24;
--warning-dim: rgba(251, 191, 36, 0.12);
--info: #67e8f9;
--success: #34d399;
--success-dim: rgba(52, 211, 153, 0.12);
--danger: #f87171;
--danger-dim: rgba(248, 113, 113, 0.12);
--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
--sans: "IBM Plex Sans", system-ui, -apple-system, sans-serif;
--heading: "Bricolage Grotesque", "IBM Plex Sans", sans-serif;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
background: var(--bg-page);
color: var(--text-primary);
font-family: var(--sans);
font-size: 14px;
line-height: 1.65;
-webkit-font-smoothing: antialiased;
}
.container {
max-width: 880px;
margin: 0 auto;
padding: 48px 32px 80px;
}
header.page {
border-bottom: 1px solid var(--border);
padding-bottom: 24px;
margin-bottom: 32px;
}
header.page .eyebrow {
font-family: var(--mono);
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.1em;
margin-bottom: 8px;
}
header.page h1 {
font-family: var(--heading);
font-weight: 700;
font-size: 32px;
line-height: 1.15;
color: var(--text-heading);
margin: 0 0 12px;
letter-spacing: -0.015em;
}
header.page .meta {
color: var(--text-muted);
font-size: 13px;
}
header.page .meta a { color: var(--accent); text-decoration: none; }
header.page .meta a:hover { text-decoration: underline; }
h2 {
font-family: var(--heading);
font-weight: 700;
font-size: 22px;
color: var(--text-heading);
margin: 48px 0 14px;
letter-spacing: -0.01em;
}
h3 {
font-family: var(--heading);
font-weight: 600;
font-size: 17px;
color: var(--text-heading);
margin: 28px 0 8px;
letter-spacing: -0.005em;
}
p { margin: 0 0 12px; }
ul { margin: 0 0 16px; padding-left: 22px; }
ul li { margin-bottom: 6px; }
a { color: var(--accent); }
code, kbd {
font-family: var(--mono);
font-size: 12px;
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.04);
padding: 1px 6px;
border-radius: 3px;
color: var(--text-primary);
}
strong { color: var(--text-heading); font-weight: 600; }
em { color: var(--text-primary); font-style: italic; }
.tldr {
background: var(--bg-card);
border: 1px solid var(--border);
border-left: 3px solid var(--accent);
border-radius: 6px;
padding: 20px 24px;
margin-bottom: 36px;
}
.tldr h2 { margin: 0 0 8px; font-size: 14px; font-family: var(--mono); text-transform: uppercase; letter-spacing: 0.1em; color: var(--accent); }
.tldr p { margin: 0 0 10px; font-size: 15px; line-height: 1.55; }
.tldr p:last-child { margin-bottom: 0; }
.metrics {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin: 20px 0 8px;
}
.metric {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
padding: 14px 16px;
}
.metric .label {
font-family: var(--mono);
font-size: 10px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 6px;
}
.metric .value {
font-family: var(--heading);
font-weight: 700;
font-size: 26px;
color: var(--text-heading);
line-height: 1.1;
}
.metric .sub {
font-size: 11px;
color: var(--text-muted);
margin-top: 4px;
}
table.data {
width: 100%;
border-collapse: collapse;
margin: 12px 0 20px;
font-size: 13px;
}
table.data th, table.data td {
text-align: left;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
}
table.data th {
font-family: var(--mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
font-weight: 700;
background: var(--bg-card);
}
table.data td.num { font-family: var(--mono); text-align: right; }
table.data td.kind { font-family: var(--mono); font-size: 12px; }
table.data td .pill {
display: inline-block;
padding: 1px 7px;
border-radius: 3px;
font-family: var(--mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
td .pill.yes { background: var(--warning-dim); color: var(--warning); border: 1px solid rgba(251,191,36,0.3); }
td .pill.no { background: rgba(255,255,255,0.04); color: var(--text-muted); border: 1px solid var(--border); }
td .pill.maybe { background: var(--accent-dim); color: var(--accent); border: 1px solid rgba(96,165,250,0.3); }
/* Heatmap */
.heatmap {
margin: 16px 0;
border-collapse: collapse;
font-family: var(--mono);
font-size: 11px;
}
.heatmap th, .heatmap td {
border: 1px solid var(--border);
padding: 8px 6px;
text-align: center;
min-width: 44px;
color: var(--text-muted);
}
.heatmap th {
background: var(--bg-card);
color: var(--text-muted);
text-transform: lowercase;
font-weight: 600;
letter-spacing: 0.04em;
}
.heatmap td.label {
text-align: right;
background: var(--bg-card);
color: var(--text-muted);
font-weight: 600;
padding-right: 10px;
}
.heatmap td.empty { color: var(--text-dim); }
.heatmap td.diag { color: var(--text-dim); background: rgba(255,255,255,0.015); }
.heatmap td.h1 { color: var(--text-heading); background: rgba(96,165,250,0.1); }
.heatmap td.h2 { color: var(--text-heading); background: rgba(96,165,250,0.22); font-weight: 700; }
.heatmap td.h3 { color: var(--bg-page); background: var(--accent); font-weight: 700; }
.heatmap-caption { color: var(--text-muted); font-size: 12px; margin: 8px 0 16px; }
.concern {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
padding: 18px 22px;
margin: 14px 0;
}
.concern.warning { border-left: 3px solid var(--warning); }
.concern.info { border-left: 3px solid var(--accent); }
.concern.success { border-left: 3px solid var(--success); }
.concern h3 { margin-top: 0; display: flex; align-items: baseline; gap: 10px; }
.concern h3 .tag {
font-family: var(--mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 2px 7px;
border-radius: 3px;
}
.concern.warning h3 .tag { background: var(--warning-dim); color: var(--warning); }
.concern.info h3 .tag { background: var(--accent-dim); color: var(--accent); }
.concern.success h3 .tag { background: var(--success-dim); color: var(--success); }
.concern p:last-child { margin-bottom: 0; }
.caveat {
background: rgba(255,255,255,0.025);
border-left: 2px solid var(--text-dim);
padding: 12px 18px;
margin: 16px 0;
font-size: 13px;
color: var(--text-muted);
}
.caveat strong { color: var(--text-primary); }
footer.page {
margin-top: 64px;
padding-top: 24px;
border-top: 1px solid var(--border);
font-size: 12px;
color: var(--text-muted);
}
@media (max-width: 720px) {
.container { padding: 32px 20px; }
.metrics { grid-template-columns: repeat(2, 1fr); }
header.page h1 { font-size: 26px; }
.heatmap { font-size: 10px; }
.heatmap th, .heatmap td { padding: 6px 4px; min-width: 32px; }
}
</style>
</head>
<body>
<div class="container">
<header class="page">
<div class="eyebrow">Architecture review · 2026-05-13</div>
<h1>ResolutionFlow workflow analysis</h1>
<div class="meta">
Based on <a href="workflows.html">workflows.html</a> · 28 user-facing flows · 297 traced steps · 120 unique files
</div>
</header>
<div class="tldr">
<h2>Bottom line</h2>
<p>You're <strong>not bloated</strong>, and most of the "circles" in the diagram are <strong>visualization artifact, not architecture problems</strong>. Each HTTP call shows up as two steps (request + response), so a normal round-trip <em>looks</em> like a circle even though it's one unit of work.</p>
<p>Three real items worth engineering attention: <code>ai_sessions.py</code> is becoming a god endpoint, the three chat services have a confusing boundary, and the auth token tables have no physical cleanup so they accrue rows forever. Everything else looks structurally healthy.</p>
</div>
<h2>Headline numbers</h2>
<div class="metrics">
<div class="metric">
<div class="label">Avg steps / flow</div>
<div class="value">10.6</div>
<div class="sub">healthy range for multi-tenant SaaS</div>
</div>
<div class="metric">
<div class="label">Avg files / flow</div>
<div class="value">7.5</div>
<div class="sub">one file per layer, roughly</div>
</div>
<div class="metric">
<div class="label">Revisit ratio</div>
<div class="value">1.39</div>
<div class="sub">1.0 = flat; 2.0+ = chat-shaped</div>
</div>
<div class="metric">
<div class="label">"Backward" edges</div>
<div class="value">15%</div>
<div class="sub">mostly HTTP response, not real circles</div>
</div>
</div>
<h2>Why the diagrams look circular</h2>
<p>Each HTTP request and its response are encoded as <strong>two separate steps</strong>. So an API call architecturally goes <em>one direction</em>, but visually looks like a loop. Breakdown of the 44 backward-flowing edges:</p>
<table class="data">
<thead>
<tr><th>Kind</th><th class="num">Count</th><th>Real circle?</th><th>Example</th></tr>
</thead>
<tbody>
<tr>
<td class="kind">http_post / http_get response</td>
<td class="num">20</td>
<td><span class="pill no">artifact</span></td>
<td>Server returns 200 to client. Not a circle.</td>
</tr>
<tr>
<td class="kind">function_call return value</td>
<td class="num">8</td>
<td><span class="pill no">artifact</span></td>
<td><code>oauth_providers</code> returns an <code>OAuthProfile</code> to the endpoint that called it.</td>
</tr>
<tr>
<td class="kind">state_update (hook → component/page)</td>
<td class="num">8</td>
<td><span class="pill maybe">idiomatic</span></td>
<td>Hook returns updated state, page re-renders. Pure React data flow.</td>
</tr>
<tr>
<td class="kind">redirect (OAuth provider → app)</td>
<td class="num">4</td>
<td><span class="pill yes">real</span></td>
<td>Google/Microsoft sends user back to <code>/oauth/callback</code>. Architecturally required.</td>
</tr>
<tr>
<td class="kind">webhook</td>
<td class="num">1</td>
<td><span class="pill yes">real</span></td>
<td>Stripe POSTs to <code>/webhooks/stripe</code>. External system re-enters us.</td>
</tr>
<tr>
<td class="kind">navigation / external_api / other</td>
<td class="num">3</td>
<td><span class="pill yes">real</span></td>
<td>Page-to-page nav, Anthropic returning a response.</td>
</tr>
</tbody>
</table>
<p>After subtracting the request/response duality, the <em>real</em> backward edges are about <strong>3% of steps</strong>, and every one of them is in a place where the architecture demands it (React state propagation, OAuth callbacks, webhooks).</p>
<h2>What's healthy</h2>
<div class="concern success">
<h3>Clean layer discipline <span class="tag">good</span></h3>
<p>The system mostly respects layer boundaries. <code>endpoint → service</code> (34x), <code>service → external</code> (37x), <code>api_client → endpoint</code> (30x) dominate the traffic. Things flow in the expected direction.</p>
</div>
<div class="concern success">
<h3><code>flowpilot_engine</code> is the right kind of shared service <span class="tag">good</span></h3>
<p>Touched by 5 flows (start, respond, resolve, pause, abandon). That's a coordination kernel doing its job — high fan-in is correct for orchestration code.</p>
</div>
<div class="concern success">
<h3>PostgreSQL in 25/28 flows <span class="tag">good</span></h3>
<p>Star topology, not a tangle. That's what a database is supposed to look like.</p>
</div>
<h2>Layer transition heatmap</h2>
<p>How many times each layer-pair appears across all steps. Bright cells = well-traveled paths. Empty cells = layer boundaries that aren't crossed (mostly a good sign).</p>
<table class="heatmap">
<thead>
<tr>
<th></th>
<th>page</th><th>comp</th><th>hook</th><th>store</th><th>api_c</th><th>http</th><th>endp</th><th>serv</th><th>core</th><th>model</th><th>ext</th>
</tr>
</thead>
<tbody>
<tr><td class="label">page</td> <td class="diag">13</td><td class="h1">5</td> <td class="h1">6</td><td class="h1">12</td><td class="h2">17</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td>2</td></tr>
<tr><td class="label">comp</td> <td>1</td> <td class="diag">5</td><td>2</td><td class="empty">·</td><td>1</td><td class="empty">·</td><td>1</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td></tr>
<tr><td class="label">hook</td> <td class="h1">7</td><td>1</td> <td class="diag empty">·</td><td class="empty">·</td><td class="h2">11</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td></tr>
<tr><td class="label">store</td> <td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="diag">4</td><td>2</td><td class="empty">·</td><td>1</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td>1</td></tr>
<tr><td class="label">api_client</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="diag empty">·</td><td>5</td><td class="h3">30</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td>1</td></tr>
<tr><td class="label">endpoint</td> <td>3</td><td class="empty">·</td><td class="h1">9</td><td>2</td><td>4</td><td class="empty">·</td><td class="diag">1</td><td class="h3">34</td><td class="h1">8</td><td>2</td><td class="h3">29</td></tr>
<tr><td class="label">service</td> <td>1</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td>2</td><td class="empty">·</td><td>3</td><td class="diag h1">9</td><td>5</td><td>4</td><td class="h3">37</td></tr>
<tr><td class="label">core</td> <td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="diag empty">·</td><td class="empty">·</td><td>4</td></tr>
<tr><td class="label">model</td> <td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="diag empty">·</td><td>1</td></tr>
<tr><td class="label">external</td> <td>4</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td>1</td><td>1</td><td class="empty">·</td><td class="empty">·</td><td class="diag empty">·</td></tr>
<tr><td class="label">http_client</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="diag empty">·</td><td>5</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td></tr>
</tbody>
</table>
<p class="heatmap-caption">Read row → column. Diagonal = same-layer transitions. Above-diagonal = "backward" (e.g. <code>endpoint → hook</code> = HTTP response). The strong upper-right concentration (<code>endpoint → service → external</code>) is the right shape.</p>
<h2>Top coupling hot-spots</h2>
<p>Files appearing in the most flows. The first two (PostgreSQL, Anthropic) are expected; everything else is worth a glance.</p>
<table class="data">
<thead>
<tr><th class="num">Flows</th><th>File</th><th>Layer</th><th>Read</th></tr>
</thead>
<tbody>
<tr><td class="num">25</td><td><code>external:postgres</code></td><td>external</td><td>Expected. The DB is the hub.</td></tr>
<tr><td class="num">10</td><td><code>external:anthropic_api</code></td><td>external</td><td>Expected for an AI product.</td></tr>
<tr><td class="num"><strong>7</strong></td><td><code>backend/app/api/endpoints/ai_sessions.py</code></td><td>endpoint</td><td><strong>God endpoint candidate.</strong> See concern below.</td></tr>
<tr><td class="num">6</td><td><code>frontend/src/api/aiSessions.ts</code></td><td>api_client</td><td>Mirrors the god endpoint. Splits naturally if backend splits.</td></tr>
<tr><td class="num">5</td><td><code>backend/app/services/flowpilot_engine.py</code></td><td>service</td><td>Healthy coordination kernel.</td></tr>
<tr><td class="num">5</td><td><code>backend/app/api/endpoints/auth.py</code></td><td>endpoint</td><td>5 auth flows, 5 endpoints. Reasonable.</td></tr>
<tr><td class="num">5</td><td><code>frontend/src/store/authStore.ts</code></td><td>store</td><td>Centralized auth state. Correct.</td></tr>
<tr><td class="num">5</td><td><code>frontend/src/pages/FlowPilotSessionPage.tsx</code></td><td>page</td><td>Worth checking — see OAuth concern.</td></tr>
<tr><td class="num">5</td><td><code>frontend/src/hooks/useFlowPilotSession.ts</code></td><td>hook</td><td>Always co-travels with the page. Right pattern.</td></tr>
</tbody>
</table>
<h2>Things worth examining</h2>
<div class="concern warning">
<h3>1. <code>ai_sessions.py</code> is a god endpoint <span class="tag">split candidate</span></h3>
<p>Appears in 7 flows. Houses ~12 route handlers in one file: <code>create</code>, <code>respond</code>, <code>chat</code>, <code>resolve</code>, <code>escalate</code>, <code>pause</code>, <code>abandon</code>, <code>pickup</code>, <code>list</code>, <code>get</code>, plus the <code>/chat</code> + <code>/respond</code> overload. It's the highest-coupled non-DB node.</p>
<p>Suggested seam:</p>
<ul>
<li><code>session_lifecycle.py</code> — create, resolve, escalate, pause, abandon, pickup</li>
<li><code>session_messaging.py</code> — chat, respond</li>
</ul>
<p>Frontend <code>aiSessions.ts</code> would split along the same line. Net change: clearer ownership, no functional impact.</p>
</div>
<div class="concern warning">
<h3>2. Three chat services with a confusing boundary <span class="tag">name vs reality</span></h3>
<p>Three files exist with overlapping responsibilities:</p>
<ul>
<li><code>backend/app/services/unified_chat_service.py</code> — chat session handling, marker parsing</li>
<li><code>backend/app/services/assistant_chat_service.py</code><code>_call_ai</code> infrastructure (Anthropic with caching, MCP, vision)</li>
<li><code>backend/app/core/ai_chat_service.py</code> — flow-builder chat for editors (separate domain)</li>
</ul>
<p>The <code>PROJECT_CONTEXT.md</code> note says <code>assistant_chat_service</code> was "removed except for retention settings," but the trace shows <code>unified_chat_service.send_chat_message</code> still calls into it for <code>_call_ai</code>. So the file is load-bearing infrastructure, not retention scaffolding.</p>
<p>Two paths forward:</p>
<ul>
<li>Rename <code>assistant_chat_service.py</code><code>ai_call_utils.py</code> (or fold the <code>_call_ai</code> function into <code>core/ai_provider.py</code> where the provider abstraction already lives).</li>
<li>Update <code>PROJECT_CONTEXT.md</code> to match reality.</li>
</ul>
<p>Either way the confusing seam goes away.</p>
</div>
<div class="concern warning">
<h3>3. OAuth login is the most "circular" real flow <span class="tag">overloaded callback</span></h3>
<p>19 steps, 4 backward edges, 3 self-loops — by far the most complex auth flow. Some complexity is unavoidable (provider redirect = 2 boundary crossings). But 3 self-loops on <code>OAuthCallbackPage</code> suggest the page is doing too much local state shuffling: CSRF state validation, code exchange, invite-code stash retrieval, JWT storage, navigation, welcome-banner logic.</p>
<p>Worth a look: move OAuth state handling into either <code>authStore</code> (which would centralize all auth state in one place) or a <code>useOAuthCallback</code> hook. The page itself should be mostly declarative.</p>
</div>
<div class="concern warning">
<h3>4. Three auth-token tables grow without bound <span class="tag">add cleanup</span></h3>
<p>Auth writes to <code>refresh_tokens</code>, <code>password_reset_tokens</code>, <code>email_verification_tokens</code>, and <code>oauth_identities</code>. Each table is individually justified (different lifecycles, different lookup patterns, JTI rotation for refresh) — <strong>this is not bloat in the code</strong>. But the cleanup story is missing.</p>
<p>Verified directly: <code>retention_cleanup.py</code> only sweeps <code>AssistantChat</code>. <code>scheduler.py</code> only has one other cleanup job, for <code>AIConversation</code>. The auth endpoint code in <code>auth.py</code> <em>revokes</em> tokens (<code>UPDATE … SET revoked_at = now()</code>) but never <em>deletes</em> them. So:</p>
<ul>
<li><code>refresh_tokens</code> — revoked rows stay forever. One row per login + one per refresh rotation.</li>
<li><code>password_reset_tokens</code> — one row per forgot-password request, no cleanup at all.</li>
<li><code>email_verification_tokens</code> — one row per signup (and per re-send), no cleanup.</li>
<li><code>oauth_identities</code> — correctly persistent; this is a permanent FK from user to provider, not a cleanup target.</li>
</ul>
<p>Suggested fix: add a daily APScheduler job in <code>retention_cleanup.py</code> (or a sibling) that hard-deletes rows where <code>revoked_at &lt; now() - INTERVAL '30 days'</code> for <code>refresh_tokens</code>, and <code>expires_at &lt; now() - INTERVAL '7 days'</code> for the two single-use token tables. Pattern matches the existing <code>cleanup_expired_chats</code> shape and the <code>_cleanup_expired_ai_conversations</code> job in <code>scheduler.py</code>.</p>
<p class="heatmap-caption">Earlier draft of this concern pointed to <code>retention_cleanup.py</code> as the place to <em>verify</em> existing cleanup. That was wrong — no such cleanup exists. Corrected after direct check.</p>
</div>
<h2>Things <em>not</em> to worry about</h2>
<div class="concern success">
<h3>Hook ↔ page state loops in session flows</h3>
<p>That's just React. <code>useFlowPilotSession</code> and <code>FlowPilotSessionPage</code> always travel together because the hook <em>is</em> that page's controller — they're maximally coupled by design, which is the right pattern.</p>
</div>
<div class="concern success">
<h3>Low "work percentage" on simple flows</h3>
<p>"Pause &amp; leave" comes out at 11% real work, 89% plumbing. That's correct — pause is structurally just <code>PATCH status='paused'</code>. There's no work to do beyond plumbing. The metric undersells simple flows.</p>
</div>
<div class="concern success">
<h3>The 25-flow PostgreSQL hub</h3>
<p>Star topology, not a tangle. A database serving every flow is the architectural ideal.</p>
</div>
<h2>Caveats on this analysis</h2>
<div class="caveat">
<strong>Work vs plumbing heuristic undersells reality.</strong> It counts <code>http_post</code> as plumbing even when it carries the actual payload. Work percentages should be read as <em>roughly 2x</em> the displayed value.
</div>
<div class="caveat">
<strong>Only user-facing flows are traced.</strong> Background work (knowledge flywheel scheduler, retention cleanup, PSA retry scheduler, MCP turn routing) isn't in here — and that's exactly where bloat tends to hide because nobody watches it. A follow-up trace of the background jobs would close the loop.
</div>
<div class="caveat">
<strong>~6 of 297 steps marked <code>unverified</code></strong> (mostly knowledge-flywheel-created proposals). They're included in the totals but the conclusions don't depend on them.
</div>
<div class="caveat">
<strong>"Backward edge" includes HTTP responses.</strong> An HTTP round-trip looks like one forward step (request) plus one backward step (response). That alone accounts for the majority of the 15% backward share. The interesting backward edges are the ~3% that aren't request/response duality.
</div>
<footer class="page">
Generated from <code>workflows.json</code> · 28 user-facing flows · 297 steps · 120 files · ResolutionFlow 2026-05-13
</footer>
</div>
</body>
</html>

View File

@@ -0,0 +1,807 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ResolutionFlow — Workflow Diagram</title>
<style>
:root {
--bg-sidebar: #0e1016;
--bg-page: #16181f;
--bg-card: #1e2028;
--bg-elev: #2a2d38;
--border: #2a2d38;
--border-hover: #3a3d48;
--text-heading: #f4f5f7;
--text-primary: #d6d8df;
--text-muted: #8b8e98;
--text-dim: #5a5d68;
--accent: #60a5fa;
--accent-dim: rgba(96, 165, 250, 0.15);
--warning: #fbbf24;
--info: #67e8f9;
--success: #34d399;
--danger: #f87171;
--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
--sans: "IBM Plex Sans", system-ui, -apple-system, sans-serif;
--heading: "Bricolage Grotesque", "IBM Plex Sans", sans-serif;
/* layer colors */
--layer-page: #a78bfa;
--layer-component: #60a5fa;
--layer-hook: #38bdf8;
--layer-store: #22d3ee;
--layer-api_client: #34d399;
--layer-http_client: #6ee7b7;
--layer-endpoint: #fbbf24;
--layer-service: #fb923c;
--layer-core: #f87171;
--layer-model: #ec4899;
--layer-db: #c084fc;
--layer-external: #94a3b8;
--layer-unknown: #6b7280;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; height: 100%; }
body {
background: var(--bg-sidebar);
color: var(--text-primary);
font-family: var(--sans);
font-size: 13px;
line-height: 1.5;
overflow: hidden;
}
.app {
display: grid;
grid-template-columns: 280px 1fr 380px;
grid-template-rows: 52px 1fr;
height: 100vh;
}
header {
grid-column: 1 / -1;
background: var(--bg-sidebar);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 16px;
gap: 16px;
}
header h1 {
margin: 0;
font-family: var(--heading);
font-weight: 700;
font-size: 16px;
color: var(--text-heading);
letter-spacing: -0.01em;
}
header .badge {
font-family: var(--mono);
font-size: 11px;
color: var(--text-muted);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 4px;
padding: 2px 8px;
}
header .spacer { flex: 1; }
header .meta { font-size: 11px; color: var(--text-muted); }
header a { color: var(--accent); text-decoration: none; }
.sidebar {
background: var(--bg-sidebar);
border-right: 1px solid var(--border);
overflow-y: auto;
padding: 12px 0;
}
.sidebar .group {
padding: 8px 16px 4px;
font-family: var(--heading);
font-size: 10px;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
margin-top: 8px;
}
.sidebar .group:first-child { margin-top: 0; }
.sidebar button.flow-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
background: transparent;
border: 0;
border-left: 2px solid transparent;
color: var(--text-primary);
font-family: var(--sans);
font-size: 13px;
text-align: left;
padding: 7px 14px 7px 14px;
cursor: pointer;
transition: background-color 0.12s, color 0.12s, border-color 0.12s;
}
.sidebar button.flow-item:hover {
background: var(--bg-card);
color: var(--text-heading);
}
.sidebar button.flow-item.active {
background: var(--accent-dim);
border-left-color: var(--accent);
color: var(--text-heading);
}
.sidebar button.flow-item .count {
font-family: var(--mono);
font-size: 10px;
color: var(--text-dim);
}
.sidebar button.flow-item.active .count { color: var(--accent); }
.canvas-wrap {
background: var(--bg-page);
overflow: auto;
position: relative;
}
.canvas-wrap .placeholder {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--text-muted);
font-size: 14px;
text-align: center;
padding: 24px;
}
.canvas-wrap .placeholder h2 {
font-family: var(--heading);
color: var(--text-heading);
margin: 0 0 8px;
}
.canvas-wrap .placeholder p { max-width: 420px; margin: 4px 0; }
svg.graph { display: block; background: var(--bg-page); }
.layer-header {
fill: var(--text-muted);
font-family: var(--heading);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.column-band { fill: rgba(255,255,255,0.012); }
.node rect {
fill: var(--bg-card);
stroke: var(--border-hover);
stroke-width: 1;
rx: 6;
}
.node.in-flow rect { stroke-width: 1.5; }
.node text.label {
fill: var(--text-primary);
font-family: var(--mono);
font-size: 11px;
dominant-baseline: middle;
}
.node text.sublabel {
fill: var(--text-dim);
font-family: var(--sans);
font-size: 10px;
dominant-baseline: middle;
}
.node .layer-pill {
rx: 2;
height: 4;
}
.edge {
fill: none;
stroke: #4a5061;
color: #4a5061;
stroke-width: 1.25;
opacity: 0.55;
transition: opacity 0.18s, stroke-width 0.18s, stroke 0.18s, color 0.18s;
}
.edge.has-active { opacity: 0.15; }
.edge.highlight {
stroke-width: 2.5;
opacity: 1;
stroke: var(--warning);
color: var(--warning);
}
.edge-num {
cursor: pointer;
transition: opacity 0.18s;
}
.edge-num.dim { opacity: 0.35; }
.edge-num-bg {
fill: var(--bg-card);
stroke: var(--accent);
stroke-width: 1.25;
transition: fill 0.18s, stroke 0.18s, r 0.18s;
}
.edge-num.highlight .edge-num-bg {
fill: var(--warning);
stroke: var(--warning);
}
.edge-num-text {
fill: var(--accent);
font-family: var(--mono);
font-size: 10px;
font-weight: 700;
text-anchor: middle;
dominant-baseline: central;
pointer-events: none;
transition: fill 0.18s;
}
.edge-num.highlight .edge-num-text {
fill: var(--bg-sidebar);
}
.self-indicator {
fill: var(--text-dim);
font-family: var(--mono);
font-size: 9px;
}
.self-indicator.highlight { fill: var(--warning); }
.panel {
background: var(--bg-sidebar);
border-left: 1px solid var(--border);
overflow-y: auto;
padding: 16px;
}
.panel h2 {
font-family: var(--heading);
font-size: 15px;
margin: 0 0 4px;
color: var(--text-heading);
}
.panel .desc {
color: var(--text-muted);
font-size: 12px;
margin: 0 0 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border);
}
.step {
display: grid;
grid-template-columns: 24px 1fr;
gap: 10px;
padding: 10px 8px;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.12s;
margin-bottom: 2px;
}
.step:hover { background: var(--bg-card); }
.step.active { background: var(--accent-dim); }
.step .num {
background: var(--bg-card);
border: 1px solid var(--border-hover);
border-radius: 50%;
width: 22px; height: 22px;
display: flex; align-items: center; justify-content: center;
font-family: var(--mono);
font-size: 11px;
font-weight: 700;
color: var(--text-primary);
}
.step.active .num { background: var(--accent); color: var(--bg-sidebar); border-color: var(--accent); }
.step .body { min-width: 0; }
.step .from-to {
font-family: var(--mono);
font-size: 10px;
color: var(--text-muted);
margin-bottom: 3px;
word-break: break-all;
}
.step .from-to .arrow { color: var(--accent); }
.step .label {
color: var(--text-primary);
font-size: 12px;
font-weight: 500;
margin-bottom: 3px;
}
.step .passes {
color: var(--text-muted);
font-family: var(--mono);
font-size: 10px;
white-space: pre-wrap;
word-break: break-word;
}
.step .passes::before { content: "passes: "; color: var(--text-dim); }
.step .meta {
display: inline-flex; gap: 6px;
margin-top: 4px;
font-family: var(--mono);
font-size: 10px;
}
.step .via-tag {
background: var(--bg-card);
border: 1px solid var(--border);
color: var(--text-muted);
padding: 1px 6px;
border-radius: 3px;
}
.step .unverified {
background: rgba(251, 191, 36, 0.1);
border: 1px solid rgba(251, 191, 36, 0.3);
color: var(--warning);
padding: 1px 6px;
border-radius: 3px;
}
/* Legend */
.legend {
position: absolute;
top: 12px;
right: 12px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 10px;
font-size: 10px;
color: var(--text-muted);
display: flex;
flex-direction: column;
gap: 4px;
pointer-events: none;
}
.legend .row { display: flex; align-items: center; gap: 6px; }
.legend .dot { width: 8px; height: 8px; border-radius: 2px; }
/* Scrollbars */
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: var(--bg-sidebar); }
::-webkit-scrollbar-thumb { background: var(--border-hover); border-radius: 5px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-dim); }
</style>
</head>
<body>
<div class="app">
<header>
<h1>ResolutionFlow Workflows</h1>
<span class="badge" id="counts">loading…</span>
<span class="spacer"></span>
<span class="meta">Click a flow to trace its path. Node-per-file granularity.</span>
</header>
<aside class="sidebar" id="sidebar"></aside>
<main class="canvas-wrap" id="canvas">
<div class="placeholder" id="placeholder">
<h2>Pick a flow</h2>
<p>Each flow is an ordered trace of how a user action moves through the codebase — page → component → API client → endpoint → service → DB/external.</p>
<p>Nodes are <strong>individual files</strong>. Numbered arrows show data flow direction; click a step in the right panel to highlight it.</p>
</div>
</main>
<aside class="panel" id="panel">
<h2>Steps</h2>
<p class="desc">Select a flow on the left.</p>
</aside>
</div>
<script>
const LAYER_ORDER = [
"page", "component", "hook", "store",
"api_client", "http_client", "endpoint",
"service", "core", "model", "external"
];
const LAYER_LABEL = {
page: "Page", component: "Component", hook: "Hook", store: "Store",
api_client: "API Client", http_client: "HTTP Client", endpoint: "Endpoint",
service: "Service", core: "Core", model: "Model", external: "External"
};
const LAYER_COLOR = (l) => getComputedStyle(document.documentElement).getPropertyValue(`--layer-${l}`).trim() || "#888";
const COLUMN_WIDTH = 260;
const COLUMN_PADDING = 28;
const NODE_HEIGHT = 44;
const NODE_WIDTH = 220;
const NODE_GAP = 12;
const HEADER_HEIGHT = 36;
const CANVAS_PADDING_TOP = 24;
const CANVAS_PADDING_BOTTOM = 40;
let DATA = null;
let nodeById = {};
let activeFlowId = null;
let activeStepIndex = null;
fetch("workflows.json").then(r => r.json()).then(d => { DATA = d; init(); }).catch(err => {
document.getElementById("placeholder").innerHTML = `<h2>Couldn't load workflows.json</h2><p>${err}</p><p>Serve this directory with a static server, then open workflows.html — opening directly via file:// may be blocked by CORS.</p>`;
});
function init() {
for (const n of DATA.nodes) nodeById[n.id] = n;
document.getElementById("counts").textContent = `${DATA.nodes.length} files · ${DATA.flows.length} flows`;
renderSidebar();
}
function renderSidebar() {
const sb = document.getElementById("sidebar");
const groups = {};
for (const f of DATA.flows) (groups[f.group] = groups[f.group] || []).push(f);
const groupOrder = ["Auth & Access", "Sessions & FlowPilot", "Flow Authoring", "Integrations", "Team & Billing", "Tools"];
const ordered = groupOrder.filter(g => groups[g]).concat(Object.keys(groups).filter(g => !groupOrder.includes(g)));
let html = "";
for (const g of ordered) {
html += `<div class="group">${escapeHtml(g)}</div>`;
for (const f of groups[g]) {
html += `<button class="flow-item" data-flow="${escapeHtml(f.id)}">
<span>${escapeHtml(f.label)}</span>
<span class="count">${f.steps.length}</span>
</button>`;
}
}
sb.innerHTML = html;
sb.querySelectorAll("button.flow-item").forEach(btn => {
btn.addEventListener("click", () => selectFlow(btn.dataset.flow));
});
}
function selectFlow(flowId) {
activeFlowId = flowId;
activeStepIndex = null;
document.querySelectorAll(".sidebar .flow-item").forEach(b => b.classList.toggle("active", b.dataset.flow === flowId));
const flow = DATA.flows.find(f => f.id === flowId);
renderGraph(flow);
renderPanel(flow);
}
// Compute layout: each flow's nodes positioned in layer columns.
function layoutFlow(flow) {
// Collect distinct nodes referenced by the flow (in step order)
const seen = new Set();
const nodes = [];
for (const s of flow.steps) {
for (const ep of [s.from, s.to]) {
if (!seen.has(ep) && nodeById[ep]) { seen.add(ep); nodes.push(nodeById[ep]); }
}
}
// Group by layer, preserve first-appearance order within layer
const byLayer = {};
for (const n of nodes) (byLayer[n.layer] = byLayer[n.layer] || []).push(n);
const activeLayers = LAYER_ORDER.filter(l => byLayer[l] && byLayer[l].length);
const positions = {};
let maxRows = 0;
activeLayers.forEach((layer, colIdx) => {
const col = byLayer[layer];
col.forEach((node, rowIdx) => {
const x = COLUMN_PADDING + colIdx * COLUMN_WIDTH;
const y = CANVAS_PADDING_TOP + HEADER_HEIGHT + rowIdx * (NODE_HEIGHT + NODE_GAP);
positions[node.id] = { x, y, w: NODE_WIDTH, h: NODE_HEIGHT, node, layer, col: colIdx, row: rowIdx };
});
maxRows = Math.max(maxRows, col.length);
});
const width = COLUMN_PADDING * 2 + activeLayers.length * COLUMN_WIDTH;
const height = CANVAS_PADDING_TOP + HEADER_HEIGHT + maxRows * (NODE_HEIGHT + NODE_GAP) + CANVAS_PADDING_BOTTOM;
return { positions, activeLayers, width, height };
}
function renderGraph(flow) {
const canvas = document.getElementById("canvas");
const placeholder = document.getElementById("placeholder");
if (placeholder) placeholder.style.display = "none";
const layout = layoutFlow(flow);
const svg = createSvg(layout.width, layout.height);
// Column band backgrounds
layout.activeLayers.forEach((layer, colIdx) => {
const x = COLUMN_PADDING + colIdx * COLUMN_WIDTH - 10;
svg.appendChild(rect(x, CANVAS_PADDING_TOP - 8, NODE_WIDTH + 20, layout.height - CANVAS_PADDING_TOP, "column-band"));
const headerEl = text(x + NODE_WIDTH / 2 + 10, CANVAS_PADDING_TOP + 12, LAYER_LABEL[layer] || layer, "layer-header");
headerEl.setAttribute("text-anchor", "middle");
svg.appendChild(headerEl);
});
// Dedupe: group steps by (from, to). One curve per unique pair.
// Track separate counts of mutual pairs (A→B and B→A both exist) so we offset their curves.
const edgeGroups = new Map();
const selfSteps = new Map(); // node id → [step indexes] for from===to (state updates)
flow.steps.forEach((step, idx) => {
if (step.from === step.to) {
const arr = selfSteps.get(step.from) || [];
arr.push(idx);
selfSteps.set(step.from, arr);
return;
}
const key = step.from + ">>" + step.to;
const grp = edgeGroups.get(key) || { from: step.from, to: step.to, steps: [] };
grp.steps.push(idx);
edgeGroups.set(key, grp);
});
// Detect mutual pairs (both A→B and B→A present) so we curve them apart
const mutualPairs = new Set();
for (const [key, grp] of edgeGroups) {
const reverseKey = grp.to + ">>" + grp.from;
if (edgeGroups.has(reverseKey)) mutualPairs.add(key);
}
const edgesGroup = group("edges");
svg.appendChild(edgesGroup);
const badgesGroup = group("badges");
// (badges added later — drawn over nodes)
for (const [key, grp] of edgeGroups) {
const fromPos = layout.positions[grp.from];
const toPos = layout.positions[grp.to];
if (!fromPos || !toPos) continue;
const isMutual = mutualPairs.has(key);
drawEdgeGroup(edgesGroup, badgesGroup, fromPos, toPos, grp, isMutual, flow.steps);
}
// Nodes
const nodeGroup = group("nodes");
svg.appendChild(nodeGroup);
for (const id in layout.positions) {
const pos = layout.positions[id];
drawNode(nodeGroup, pos, selfSteps.get(id) || []);
}
// Append badges last so they sit above nodes
svg.appendChild(badgesGroup);
canvas.innerHTML = "";
canvas.appendChild(svg);
svg.addEventListener("click", (e) => {
// Click on empty SVG background clears highlight
if (e.target === svg || e.target.classList.contains("column-band")) clearStepHighlight();
});
// Legend
const legend = document.createElement("div");
legend.className = "legend";
legend.innerHTML = `
<div class="row"><strong style="color:var(--text-primary)">Layers</strong></div>
${layout.activeLayers.map(l => `<div class="row"><span class="dot" style="background:${LAYER_COLOR(l)}"></span>${LAYER_LABEL[l]||l}</div>`).join("")}
`;
canvas.appendChild(legend);
}
function drawEdgeGroup(edgeLayer, badgeLayer, from, to, grp, isMutual, allSteps) {
const forward = to.col >= from.col;
// Anchor on the side of the node that fits the direction.
let fromX, fromY, toX, toY;
if (forward) {
fromX = from.x + from.w; toX = to.x;
} else {
// Backward: exit from left of source, enter right of target
fromX = from.x; toX = to.x + to.w;
}
fromY = from.y + from.h / 2;
toY = to.y + to.h / 2;
// Offset mutual pairs perpendicularly so the two arrows don't sit on top of each other.
// Forward edges get a small upward offset; backward gets downward (or vice versa) when mutual.
let perpOffset = 0;
if (isMutual) perpOffset = forward ? -18 : 18;
// Bezier control points
const dx = toX - fromX;
const dy = toY - fromY;
let c1x, c1y, c2x, c2y;
if (forward) {
const horiz = Math.max(50, Math.abs(dx) * 0.4);
c1x = fromX + horiz;
c2x = toX - horiz;
c1y = fromY + perpOffset;
c2y = toY + perpOffset;
} else {
// Backward — arc out to the side, then back. We use a wide horizontal sweep.
const sweep = 80 + Math.abs(dy) * 0.15;
c1x = fromX - sweep;
c2x = toX + sweep;
c1y = fromY + perpOffset;
c2y = toY + perpOffset;
}
const pathD = `M ${fromX} ${fromY} C ${c1x} ${c1y}, ${c2x} ${c2y}, ${toX} ${toY}`;
const stepNums = grp.steps.map(i => i + 1).join(",");
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", pathD);
path.setAttribute("class", "edge");
path.setAttribute("data-steps", stepNums);
path.setAttribute("marker-end", "url(#arrow)");
edgeLayer.appendChild(path);
// Step-number badges placed along the curve at evenly spaced t-values.
const k = grp.steps.length;
const SPREAD = Math.min(0.18, 0.55 / Math.max(1, k));
grp.steps.forEach((stepIdx, i) => {
// t centered around 0.5 — for k=1, t=0.5; for k=2 t=0.41, 0.59; etc.
const t = 0.5 + (i - (k - 1) / 2) * SPREAD;
const p = cubicAt(t, [fromX, fromY], [c1x, c1y], [c2x, c2y], [toX, toY]);
const numG = document.createElementNS("http://www.w3.org/2000/svg", "g");
numG.setAttribute("data-step", stepIdx + 1);
numG.setAttribute("class", "edge-num");
numG.addEventListener("click", (e) => { e.stopPropagation(); highlightStep(stepIdx); });
const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
circle.setAttribute("cx", p.x);
circle.setAttribute("cy", p.y);
circle.setAttribute("r", 9);
circle.setAttribute("class", "edge-num-bg");
numG.appendChild(circle);
const numText = document.createElementNS("http://www.w3.org/2000/svg", "text");
numText.setAttribute("x", p.x);
numText.setAttribute("y", p.y);
numText.setAttribute("class", "edge-num-text");
numText.textContent = stepIdx + 1;
numG.appendChild(numText);
const step = allSteps[stepIdx];
const title = document.createElementNS("http://www.w3.org/2000/svg", "title");
title.textContent = `${stepIdx + 1}. ${step.label}\n${step.via}\npasses: ${step.passes}`;
numG.appendChild(title);
badgeLayer.appendChild(numG);
});
}
function cubicAt(t, P0, P1, P2, P3) {
const mt = 1 - t;
const x = mt*mt*mt*P0[0] + 3*mt*mt*t*P1[0] + 3*mt*t*t*P2[0] + t*t*t*P3[0];
const y = mt*mt*mt*P0[1] + 3*mt*mt*t*P1[1] + 3*mt*t*t*P2[1] + t*t*t*P3[1];
return { x, y };
}
function drawNode(parent, pos, selfStepIdxs) {
const g = document.createElementNS("http://www.w3.org/2000/svg", "g");
g.setAttribute("class", "node in-flow");
g.setAttribute("transform", `translate(${pos.x}, ${pos.y})`);
const r = rect(0, 0, pos.w, pos.h);
g.appendChild(r);
// Layer-color top pill
const pill = rect(0, 0, pos.w, 4, "layer-pill");
pill.setAttribute("fill", LAYER_COLOR(pos.layer));
pill.setAttribute("rx", 6);
g.appendChild(pill);
const label = text(10, pos.h / 2 - 7, pos.node.label, "label");
g.appendChild(label);
const fp = pos.node.file;
let sub = fp;
if (sub.startsWith("external:")) sub = sub.replace("external:", "ext · ");
else if (sub.length > 32) {
const parts = sub.split("/");
sub = parts.slice(-3).join("/");
if (sub.length > 32) sub = "…/" + parts[parts.length - 1];
}
const subEl = text(10, pos.h / 2 + 8, sub, "sublabel");
g.appendChild(subEl);
// Self-loop steps (from === to, typically state_update) — render as a small "↻ N" indicator
// in the upper-right of the node, clickable to highlight that step.
if (selfStepIdxs.length) {
const xRight = pos.w - 8;
selfStepIdxs.slice(0, 3).forEach((stepIdx, i) => {
const sg = document.createElementNS("http://www.w3.org/2000/svg", "g");
sg.setAttribute("class", "edge-num self-step");
sg.setAttribute("data-step", stepIdx + 1);
sg.addEventListener("click", (e) => { e.stopPropagation(); highlightStep(stepIdx); });
const cx = xRight - i * 18;
const cy = 14;
const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
circle.setAttribute("cx", cx);
circle.setAttribute("cy", cy);
circle.setAttribute("r", 8);
circle.setAttribute("class", "edge-num-bg");
sg.appendChild(circle);
const t = document.createElementNS("http://www.w3.org/2000/svg", "text");
t.setAttribute("x", cx);
t.setAttribute("y", cy);
t.setAttribute("class", "edge-num-text");
t.textContent = stepIdx + 1;
sg.appendChild(t);
const title = document.createElementNS("http://www.w3.org/2000/svg", "title");
title.textContent = `Step ${stepIdx + 1} (self · stays on this node)`;
sg.appendChild(title);
g.appendChild(sg);
});
}
// Hover tooltip
const title = document.createElementNS("http://www.w3.org/2000/svg", "title");
title.textContent = `${pos.node.label}\n${pos.node.file}\n${pos.node.description || ""}`;
g.appendChild(title);
parent.appendChild(g);
}
function renderPanel(flow) {
const p = document.getElementById("panel");
let html = `<h2>${escapeHtml(flow.label)}</h2>`;
if (flow.description) html += `<p class="desc">${escapeHtml(flow.description)}</p>`;
flow.steps.forEach((step, idx) => {
const fromNode = nodeById[step.from], toNode = nodeById[step.to];
const fromLabel = fromNode ? fromNode.label : step.from;
const toLabel = toNode ? toNode.label : step.to;
html += `<div class="step" data-step="${idx}">
<div class="num">${idx + 1}</div>
<div class="body">
<div class="from-to">${escapeHtml(fromLabel)} <span class="arrow">→</span> ${escapeHtml(toLabel)}</div>
<div class="label">${escapeHtml(step.label || "(unlabeled)")}</div>
${step.passes ? `<div class="passes">${escapeHtml(step.passes)}</div>` : ""}
<div class="meta">
<span class="via-tag">${escapeHtml(step.via)}</span>
${step.unverified ? `<span class="unverified">unverified</span>` : ""}
</div>
</div>
</div>`;
});
p.innerHTML = html;
p.querySelectorAll(".step").forEach(el => {
el.addEventListener("click", () => highlightStep(+el.dataset.step));
});
p.scrollTop = 0;
}
function highlightStep(idx) {
activeStepIndex = idx;
const stepNum = idx + 1;
document.querySelectorAll(".panel .step").forEach((el, i) => el.classList.toggle("active", i === idx));
// Edges: a path can carry multiple steps (comma-separated). Highlight if this stepNum is in its list.
document.querySelectorAll("svg.graph .edge").forEach(e => {
const nums = (e.getAttribute("data-steps") || "").split(",").map(Number);
const isOn = nums.includes(stepNum);
e.classList.toggle("highlight", isOn);
e.classList.toggle("has-active", !isOn);
});
// Step-number badges: highlight the one matching this step; others stay visible but dimmed.
document.querySelectorAll("svg.graph .edge-num").forEach(g => {
const n = +g.getAttribute("data-step");
g.classList.toggle("highlight", n === stepNum);
g.classList.toggle("dim", n !== stepNum);
});
const active = document.querySelector(".panel .step.active");
if (active && typeof active.scrollIntoView === "function") {
active.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
}
function clearStepHighlight() {
activeStepIndex = null;
document.querySelectorAll(".panel .step.active").forEach(el => el.classList.remove("active"));
document.querySelectorAll("svg.graph .edge").forEach(e => { e.classList.remove("highlight", "has-active"); });
document.querySelectorAll("svg.graph .edge-num").forEach(g => { g.classList.remove("highlight", "dim"); });
}
/* ---------- SVG helpers ---------- */
function createSvg(w, h) {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("class", "graph");
svg.setAttribute("width", w);
svg.setAttribute("height", h);
svg.setAttribute("viewBox", `0 0 ${w} ${h}`);
// Arrow marker
const defs = document.createElementNS("http://www.w3.org/2000/svg", "defs");
defs.innerHTML = `
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="currentColor" />
</marker>`;
svg.appendChild(defs);
return svg;
}
function rect(x, y, w, h, cls) {
const r = document.createElementNS("http://www.w3.org/2000/svg", "rect");
r.setAttribute("x", x); r.setAttribute("y", y);
r.setAttribute("width", w); r.setAttribute("height", h);
if (cls) r.setAttribute("class", cls);
return r;
}
function text(x, y, str, cls) {
const t = document.createElementNS("http://www.w3.org/2000/svg", "text");
t.setAttribute("x", x); t.setAttribute("y", y);
if (cls) t.setAttribute("class", cls);
t.textContent = str;
return t;
}
function group(cls) {
const g = document.createElementNS("http://www.w3.org/2000/svg", "g");
if (cls) g.setAttribute("class", cls);
return g;
}
function escapeHtml(s) {
if (s == null) return "";
return String(s).replace(/[&<>"']/g, c => ({ "&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;" }[c]));
}
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,266 @@
# Public Landing Routing Refactor
**Date:** 2026-05-13
**Status:** Planned — pending execution
**Author:** session handoff
**Driver:** Stripe activation review — Stripe's compliance crawler cannot view `resolutionflow.com`
## Problem
The bare apex URL `https://resolutionflow.com/` serves a Vite SPA shell
(`<div id="root"></div>` and a module script — see [`frontend/index.html`](../../frontend/index.html))
and the React Router config in [`frontend/src/router.tsx`](../../frontend/src/router.tsx)
mounts `/` behind `<ProtectedRoute>`. The public marketing landing page lives
at `/landing`. For unauthenticated visitors, the flow is:
1. Browser fetches `/` → empty HTML shell.
2. JS executes, auth store hydrates as not-authenticated.
3. `ProtectedRoute` client-side `<Navigate to="/landing">`.
Stripe (and many automated compliance crawlers) fetch the apex without
executing JS, or don't reliably wait through a client-side redirect chain.
They see no business content, no terms link, no pricing — and decline review.
## Goal
Make `/` serve the public landing page directly so the apex URL renders
marketing content immediately. Move the authenticated dashboard index
(currently `QuickStartPage` at `/`) to `/home`. All other authenticated
child routes (`/trees`, `/pilot`, `/admin/*`, `/account/*`, etc.) stay
where they are — only the index page and the route grouping change.
This is the architectural fix. If Stripe's reviewer still cannot see the
site after this lands (i.e. their crawler executes zero JS), the documented
next escalation is server-side prerendering of public routes via
`vite-plugin-ssg` — captured below under Follow-ups.
## Approach
### Router restructure ([`frontend/src/router.tsx`](../../frontend/src/router.tsx))
Use a react-router *layout route* (no `path`, just an `element`) for the
authenticated tree so children carry absolute paths and don't all need
renaming:
```tsx
// Public
{ path: '/', element: page(PublicLanding), errorElement: <RouteError /> },
// Stale-bookmark redirect — keep for one release, delete in a follow-up
{ path: '/landing', element: <Navigate to="/" replace /> },
// Authenticated app — layout route
{
element: <ProtectedRoute><AppLayout /></ProtectedRoute>,
errorElement: <RouteError />,
children: [
{ path: '/home', element: page(QuickStartPage) },
{ path: '/trees', element: page(TreeLibraryPage) },
{ path: '/my-trees', element: page(MyTreesPage) },
// …all other existing children, unchanged (admin/*, account/*, pilot/*, …)
],
},
```
### `PublicLanding` wrapper (no-flicker authed redirect)
Authenticated users hitting `/` should not see marketing. Use a thin
router-level wrapper so `LandingPage` stays a pure marketing component
and there's no frame-flash before redirect:
```tsx
function PublicLanding() {
const isAuthed = useAuthStore(s => s.isAuthenticated);
return isAuthed ? <Navigate to="/home" replace /> : <LandingPage />;
}
```
### Auth gate ([`frontend/src/components/layout/ProtectedRoute.tsx:25`](../../frontend/src/components/layout/ProtectedRoute.tsx#L25))
`<Navigate to="/landing" state={{ from: location }} replace />`
`<Navigate to="/" state={{ from: location }} replace />`.
The `state.from` preservation stays.
### Reference updates (21 sites)
**Post-login / post-onboarding destinations**
| File | Line | Change |
|---|---|---|
| [`frontend/src/pages/OAuthCallbackPage.tsx`](../../frontend/src/pages/OAuthCallbackPage.tsx#L114) | 114 | `let dest = '/'``'/home'`; `'/?welcome=teammate'``'/home?welcome=teammate'` |
| [`frontend/src/pages/welcome/WelcomeStep1.tsx`](../../frontend/src/pages/welcome/WelcomeStep1.tsx#L88) | 88 | `navigate('/')``navigate('/home')` |
| [`frontend/src/pages/welcome/WelcomeStep2.tsx`](../../frontend/src/pages/welcome/WelcomeStep2.tsx#L72) | 72 | same |
| [`frontend/src/pages/welcome/WelcomeStep3.tsx`](../../frontend/src/pages/welcome/WelcomeStep3.tsx#L194) | 194 | same |
| [`frontend/src/pages/AssistantChatPage.tsx`](../../frontend/src/pages/AssistantChatPage.tsx#L2419) | 2419 | same |
**Authenticated chrome (logo, mobile nav)**
| File | Line | Change |
|---|---|---|
| [`frontend/src/components/layout/TopBar.tsx`](../../frontend/src/components/layout/TopBar.tsx#L66) | 66 | logo `to="/"``to="/home"` |
| [`frontend/src/components/layout/AppLayout.tsx`](../../frontend/src/components/layout/AppLayout.tsx#L60) | 60 | mobile nav `path: '/'``'/home'` |
| [`frontend/src/components/layout/AppLayout.tsx`](../../frontend/src/components/layout/AppLayout.tsx#L107) | 107 | logo `to="/"``to="/home"` |
**Dashboard onboarding (has in-progress edits — layer carefully)**
| File | Line | Change |
|---|---|---|
| [`frontend/src/components/dashboard/SetupChecklist.tsx`](../../frontend/src/components/dashboard/SetupChecklist.tsx#L54) | 54 | `path: '/'``'/home'` |
| [`frontend/src/components/dashboard/NextStepCard.tsx`](../../frontend/src/components/dashboard/NextStepCard.tsx#L82) | 82 | `ctaPath: '/'``'/home'` |
These two files already have uncommitted edits for the "Start a session"
pulse/scroll onboarding fix from earlier this session. Layer onto whatever's
there — don't overwrite.
**Public page back-links**
| File | Line | Change |
|---|---|---|
| [`frontend/src/pages/TermsPage.tsx`](../../frontend/src/pages/TermsPage.tsx#L10) | 10 | `to="/landing"``to="/"` |
| [`frontend/src/pages/PoliciesPage.tsx`](../../frontend/src/pages/PoliciesPage.tsx#L10) | 10 | same |
| [`frontend/src/pages/PrivacyPage.tsx`](../../frontend/src/pages/PrivacyPage.tsx#L10) | 10 | same |
| [`frontend/src/pages/ContactPage.tsx`](../../frontend/src/pages/ContactPage.tsx#L10) | 10 | same |
| [`frontend/src/pages/PromotionsPage.tsx`](../../frontend/src/pages/PromotionsPage.tsx#L10) | 10 | same |
| [`frontend/src/pages/PublicTemplatesPage.tsx`](../../frontend/src/pages/PublicTemplatesPage.tsx#L171) | 171, 409 | same |
### robots.txt + sitemap.xml ([`frontend/public/`](../../frontend/public/))
Neither file exists today. Create both.
**`frontend/public/robots.txt`**
```
User-agent: *
Allow: /
Allow: /terms
Allow: /policies
Allow: /privacy
Allow: /contact
Allow: /contact-sales
Allow: /pricing
Allow: /promotions
Allow: /templates
Disallow: /home
Disallow: /trees/
Disallow: /my-trees
Disallow: /pilot/
Disallow: /admin/
Disallow: /account/
Disallow: /script-builder
Disallow: /scripts
Disallow: /sessions
Disallow: /analytics
Disallow: /escalations
Disallow: /queue
Disallow: /review-queue
Disallow: /network-diagrams
Disallow: /kb-accelerator
Disallow: /step-library
Disallow: /tickets
Disallow: /shares
Disallow: /feedback
Disallow: /welcome
Disallow: /flow-assist
Disallow: /dev/
Disallow: /flows/
Disallow: /guides
Sitemap: https://resolutionflow.com/sitemap.xml
```
**`frontend/public/sitemap.xml`** — entries for `/`, `/pricing`,
`/contact-sales`, `/contact`, `/templates`, `/terms`, `/privacy`,
`/policies`, `/promotions`. Standard `<urlset>` schema with `<loc>` and
`<lastmod>` of `2026-05-13`.
### Open Graph + Twitter cards
[`frontend/src/components/common/PageMeta.tsx`](../../frontend/src/components/common/PageMeta.tsx)
already emits `og:title/description/type/site_name` and
`twitter:card=summary/title/description`. Gaps:
1. **No `og:url`** in `PageMeta` — add a `url` prop that defaults to
`window.location.href` (or take a canonical override).
2. **`twitter:card` is always `summary`** — when an `ogImage` is passed,
emit `summary_large_image` instead.
3. **`LandingPage` doesn't pass `ogImage`** — currently no social preview
image at all. Need a 1200×630 asset. Acceptable to ship a placeholder
(logo on existing landing gradient) and flag for design polish.
### Tests
**Update**
| File | Change |
|---|---|
| [`frontend/src/components/layout/__tests__/AppLayout.test.tsx`](../../frontend/src/components/layout/__tests__/AppLayout.test.tsx) | `initialEntries={['/']}``['/home']` |
| [`frontend/src/pages/__tests__/LandingPage.test.tsx`](../../frontend/src/pages/__tests__/LandingPage.test.tsx) | Keep `['/']` — now correct |
**Add**
`frontend/src/components/layout/__tests__/ProtectedRoute.test.tsx` (or
extend existing) — unauthenticated visit to `/home` should:
- Redirect to `/` (not `/landing`).
- Preserve original location in `state.from` so post-login flow can return
the user to their intended destination.
## Out of scope / non-issues verified
- **Service worker / PWA cache invalidation.** `vite.config.ts` has no
`vite-plugin-pwa`, no `injectManifest`, no SW registration anywhere in
`frontend/src`. The "PWA Icons" comment in `index.html` is iOS
apple-touch-icon only. Vite's content-hashed bundles + browser HTTP cache
handle invalidation. Flagged during review; no action needed.
- **Backend redirects / CORS / OAuth.** Grep of `backend/` shows no
hard-coded `/landing` or root-path redirects. OAuth callbacks render
client-side via [`OAuthCallbackPage.tsx`](../../frontend/src/pages/OAuthCallbackPage.tsx).
No backend changes required.
## Manual follow-ups (not code changes)
- **PostHog dashboard audit.** [`frontend/src/main.tsx:20`](../../frontend/src/main.tsx#L20)
sets `capture_pageview: true`, so `$pageview` auto-fires for every URL.
After this ships, `$current_url ends with /` shifts meaning from
"authenticated dashboard visit" to "anonymous marketing visit." Any
saved PostHog insight or funnel keyed on `/` will silently mis-interpret.
No in-code filters on `'/'` exist (grepped `lib/analytics.ts` and the
wider tree). Sweep PostHog dashboards in the PostHog UI before merging
this PR and update filters as needed.
- **OG image asset.** Placeholder is acceptable to unblock Stripe; design
polish can follow.
## Follow-ups (deferred — future PRs)
- **Stripe SSR escalation.** If Stripe's reviewer still cannot see the
site after this lands (i.e. their crawler executes zero JavaScript), the
next step is server-side prerendering of public routes. Cheapest path:
`vite-plugin-ssg` for static HTML output of `/`, `/pricing`, `/terms`,
`/privacy`, `/policies`, `/contact`, `/contact-sales`, `/promotions`,
`/templates`. Keeps the SPA architecture for the authed app. Larger
move (only if needed): split marketing to a separate Astro/Next-static
project at the apex and move the SPA to `app.resolutionflow.com`.
Do not pre-optimize for this. Capture as a decision in
[`.ai/DECISIONS.md`](../../.ai/DECISIONS.md) when this PR lands.
- **Delete `/landing` redirect alias** after one release cycle.
## Rollout / sequencing
1. Router restructure + `PublicLanding` wrapper.
2. 21 reference updates (post-login, chrome, dashboard onboarding, public
page back-links).
3. `ProtectedRoute` redirect target flip.
4. `robots.txt`, `sitemap.xml`.
5. `PageMeta` enhancements (`og:url`, `summary_large_image` toggle).
6. OG image asset, wired into `LandingPage`.
7. Test updates + new `ProtectedRoute` test.
8. Manual: PostHog dashboard sweep.
9. `.ai/DECISIONS.md` entry noting SSR-prerender as next-escalation path.
10. Single PR, single deploy.
## Risk
Necessary but not necessarily sufficient for Stripe's crawler. If their
bot executes zero JS, even a `/`-routed `LandingPage.tsx` is invisible —
Vite still client-renders. The Follow-ups section above captures the
escalation path.

View File

@@ -0,0 +1,477 @@
# Tutorial: Build a Contact page
By the end of this tutorial, ResolutionFlow will have a working `/contact` page. A visitor can land on it, fill out a form, and see a thank-you state when it submits. You'll touch the router, build a page component, style it with the design system, manage form state, and link to it from the landing footer.
This is a **tutorial**, not a reference. It's one concrete path that's known to work. The point is to learn how the pieces fit together by actually building something. Don't substitute steps. After you finish, you'll be ready to read the code with confidence.
**Estimated time:** 3045 minutes.
---
## What you'll know by the end
- Where new pages live in the codebase
- How the router lazy-loads page components
- How public pages differ from in-app pages
- How to apply the design system without inventing chrome
- How to wire form state, validation, and submit
- How to verify your work and ship a clean commit
---
## Before you start
You need:
- The frontend container running (`docker ps` should show `resolutionflow_frontend` listening on 5173). Vite hot-module-reload is what makes this tutorial pleasant. Every file save shows up in the browser within a second.
- An editor open at the repo root.
- A logged-out browser tab pointed at the dev server. The contact page is public, so you don't need an account to visit it. (If you've been logged in, open a private window or sign out.)
> Quick sanity check: navigate to `/landing` in the browser. If you see the marketing page, you're set up correctly. If you see anything else, fix that first.
---
## Step 1: Decide where the page lives
Two parts of the app could host a "contact" page: the **public marketing layer** (`/landing`, `/privacy`, `/terms`) or the **in-app shell** (`/account`, `/sessions`, etc.). The right answer depends on the audience.
A contact page is for visitors who *aren't* logged in: prospects, leads, support requests from people without accounts. So it belongs at the public layer, parallel to `/privacy` and `/terms`. No app shell, no sidebar, just a simple centered page.
**Decision:** route it at `/contact`, no auth required, model it after `frontend/src/pages/PrivacyPage.tsx` for layout.
---
## Step 2: Create the page component
Create a new file at `frontend/src/pages/ContactPage.tsx`. Start with the smallest possible skeleton so we can confirm the route works before adding form complexity.
```tsx
import { Link } from 'react-router-dom'
import { PageMeta } from '@/components/common/PageMeta'
export default function ContactPage() {
return (
<>
<PageMeta title="Contact" description="Get in touch with ResolutionFlow" />
<div className="min-h-screen bg-background text-foreground">
<div className="mx-auto max-w-xl px-6 py-16">
<Link
to="/landing"
className="mb-8 inline-block text-sm text-muted-foreground hover:text-foreground"
>
&larr; Back to home
</Link>
<h1 className="text-3xl font-bold font-heading mb-3">Contact</h1>
<p className="text-muted-foreground">
Send us a note and we'll get back to you within one business day.
</p>
</div>
</div>
</>
)
}
```
A few things worth pointing out:
- **`PageMeta`** sets the document title and description. Every page should have one. It's how you keep tab titles informative without scattering `<Helmet>` calls everywhere.
- **`min-h-screen bg-background`** ensures the page fills the viewport with the brand background color. Critical for public pages that don't sit inside an app layout.
- **`mx-auto max-w-xl`** caps line length around 6575 characters of body text, per the shared design laws. `max-w-xl` is ~36rem; for the form we'll keep at this width.
- **`font-heading`** maps to the heading font defined in `frontend/src/index.css`. Use it on H1s, not body text.
Save the file. Nothing visible happens yet: we haven't told the router that `/contact` exists.
---
## Step 3: Wire up the route
Open `frontend/src/router.tsx`. Near the top of the file, you'll see a list of `lazyWithRetry` imports for every page. Add yours, alphabetized in the public-page group:
```tsx
const ContactPage = lazyWithRetry(() => import('@/pages/ContactPage'))
```
`lazyWithRetry` is a thin wrapper around React's `lazy()` that retries once if the chunk fails to load (which can happen during a deploy). Use it for everything; never plain `lazy()`.
Now scroll down to the `sentryCreateBrowserRouter` array and add a route entry next to the other public ones (`/landing`, `/privacy`, `/terms`):
```tsx
{
path: '/contact',
element: page(ContactPage),
errorElement: <RouteError />,
},
```
The `page()` helper wraps the component in `<ErrorBoundary>` and `<Suspense fallback={<PageLoader />}>`. That gives you a graceful loader while the chunk loads and an error boundary if something throws. The `errorElement: <RouteError />` handles router-level errors (e.g., a 404 thrown deeper in the tree).
Save. Vite reloads. Navigate to `http://your-dev-host:5173/contact` (or whatever URL serves the dev frontend). You should see the heading, the description, and the back-to-home link.
> If you see a blank page or an error, check the browser console first. The two common mistakes here are: (1) wrong import path, (2) forgetting `export default`. Fix and re-save.
---
## Step 4: Add the form
Now we add the actual contact form. Replace the body of the page (everything inside `max-w-xl`) with the form scaffolding. Keep imports for now; we'll add more in the next step.
```tsx
<div className="mx-auto max-w-xl px-6 py-16">
<Link
to="/landing"
className="mb-8 inline-block text-sm text-muted-foreground hover:text-foreground"
>
&larr; Back to home
</Link>
<h1 className="text-3xl font-bold font-heading mb-3">Contact</h1>
<p className="text-muted-foreground mb-10">
Send us a note and we'll get back to you within one business day.
</p>
<form className="space-y-5">
<div>
<label htmlFor="contact-name" className="block text-sm font-medium text-foreground">
Name
</label>
<input
id="contact-name"
type="text"
required
className="mt-1 block w-full rounded-lg border border-border bg-card px-3 py-2 text-foreground placeholder:text-muted-foreground focus:border-primary/30 focus:outline-hidden focus:ring-1 focus:ring-primary/20"
/>
</div>
<div>
<label htmlFor="contact-email" className="block text-sm font-medium text-foreground">
Email
</label>
<input
id="contact-email"
type="email"
required
className="mt-1 block w-full rounded-lg border border-border bg-card px-3 py-2 text-foreground placeholder:text-muted-foreground focus:border-primary/30 focus:outline-hidden focus:ring-1 focus:ring-primary/20"
/>
</div>
<div>
<label htmlFor="contact-message" className="block text-sm font-medium text-foreground">
Message
</label>
<textarea
id="contact-message"
rows={6}
required
className="mt-1 block w-full rounded-lg border border-border bg-card px-3 py-2 text-foreground placeholder:text-muted-foreground focus:border-primary/30 focus:outline-hidden focus:ring-1 focus:ring-primary/20"
/>
</div>
<button
type="submit"
className="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-white hover:brightness-110 active:scale-[0.98]"
>
Send message
</button>
</form>
</div>
```
Notice what we **did not** do:
- No outer card wrapper (`rounded-2xl border bg-card p-6`). The page background and the centered `max-w-xl` container are enough structure. Wrapping a single form in a card adds chrome that says nothing. Per `PRODUCT.md`: *"Cards are the lazy answer."*
- No icons next to labels. The labels carry the meaning; icons would be decoration.
- No fancy gradient on the submit button. The accent color is reserved for ≤5% of the UI; one solid button is the pattern.
- No nested borders or shadows.
Save. The form renders. The fields are real HTML inputs: they accept focus, browser autofill works, validation messages appear if you submit empty.
> If your form fields look unstyled, check that the `className` strings copied without line breaks. Tailwind compiles class strings literally; a stray newline inside the quotes breaks every utility on that line.
The `inputClass` you see here is duplicated three times. That's intentional for the tutorial; repetition makes it easy to read. In real code you'd extract a constant once you have three matching calls. Look at `frontend/src/pages/account/ProfileSettingsPage.tsx` for the project's existing convention.
---
## Step 5: Manage form state
Right now the inputs are uncontrolled (the browser owns their values) and submitting reloads the page. We need React state so we can read the values, validate them, and prevent the default submit.
At the top of the file, add `useState`:
```tsx
import { useState } from 'react'
```
Inside the component, above the `return`, add three pieces of state and a submit handler:
```tsx
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [message, setMessage] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim() || !email.trim() || !message.trim()) return
setIsSubmitting(true)
try {
// Replaced with a real API call in Step 7.
await new Promise((resolve) => setTimeout(resolve, 600))
// Success handling lands in Step 6.
} finally {
setIsSubmitting(false)
}
}
```
Then wire the inputs and the form:
```tsx
<form className="space-y-5" onSubmit={handleSubmit}>
<div>
<label htmlFor="contact-name" /* ... */>Name</label>
<input
id="contact-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
required
className="..."
/>
</div>
{/* ... same pattern for email and message ... */}
<button
type="submit"
disabled={isSubmitting}
className="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-white hover:brightness-110 active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? 'Sending…' : 'Send message'}
</button>
</form>
```
What changed:
- **`value` + `onChange`** makes each input a controlled component. React owns the truth; the input mirrors it.
- **`e.preventDefault()`** stops the browser's default form submit (which would do a full page reload).
- **`isSubmitting`** disables the button during the in-flight request and swaps the label. Users get immediate feedback that something happened.
- **The trim() guards** catch empty submissions even when the browser's `required` attribute is bypassed (e.g., autofill anomalies).
Save. Try typing in the fields. Click Send message. The button briefly says "Sending…" then re-enables. Nothing user-visible happens after that yet. That's the next step.
---
## Step 6: Show a success state
When the submit succeeds, the form should disappear and a confirmation should take its place. That's both a clearer signal and a stronger feeling than a toast that vanishes after three seconds.
Add one more piece of state:
```tsx
const [submitted, setSubmitted] = useState(false)
```
Update the submit handler so it flips `submitted` on success:
```tsx
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim() || !email.trim() || !message.trim()) return
setIsSubmitting(true)
try {
await new Promise((resolve) => setTimeout(resolve, 600))
setSubmitted(true)
} finally {
setIsSubmitting(false)
}
}
```
Now branch the JSX so the form renders only when `!submitted`:
```tsx
{submitted ? (
<div className="rounded-lg border border-border bg-card/50 p-6">
<h2 className="text-lg font-semibold text-foreground">Message sent</h2>
<p className="mt-2 text-sm text-muted-foreground">
Thanks, {name.trim()}. We'll reply at{' '}
<span className="text-foreground">{email.trim()}</span> within one business day.
</p>
<button
type="button"
onClick={() => {
setName('')
setEmail('')
setMessage('')
setSubmitted(false)
}}
className="mt-4 text-sm text-muted-foreground transition-colors hover:text-foreground"
>
Send another message
</button>
</div>
) : (
<form className="space-y-5" onSubmit={handleSubmit}>
{/* ...form contents... */}
</form>
)}
```
A few teaching moments here:
- **The success state is a single bordered region**, not a confetti card with a check icon. PRODUCT.md's tone is "competent, no fluff."
- **It echoes the user's name and email back** so they know the right address received their message. This is a small touch that builds trust.
- **There's a "Send another message" affordance** that resets the form. Don't trap users in success. Give them a way back.
Save. Submit the form. The fields disappear and the confirmation appears. Click "Send another message" and you're back to the empty form.
---
## Step 7: Wire it to a real API endpoint
So far the submit is a mock 600ms delay. To make it real, we need three things: an API endpoint, a frontend client function, and updated error handling.
The backend endpoint setup is its own tutorial; for now we'll add the frontend client and call a not-yet-existing path, so the call fails gracefully with a toast. When the backend lands, you change one line of your client and you're done.
Create `frontend/src/api/contact.ts`:
```ts
import { apiClient } from './client'
export const contactApi = {
submit: (data: { name: string; email: string; message: string }) =>
apiClient.post('/contact', data).then((r) => r.data),
}
```
That's the whole pattern. `apiClient` is a pre-configured Axios instance from `frontend/src/api/client.ts` with the base URL, auth, and error interceptors already wired. Every API module in `frontend/src/api/` follows this same shape. Read `frontend/src/api/betaFeedback.ts` to see another minimal example.
Now in `ContactPage.tsx`, swap the mock for a real call. Add to imports:
```tsx
import { contactApi } from '@/api/contact'
import { toast } from '@/lib/toast'
```
Update the submit handler:
```tsx
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim() || !email.trim() || !message.trim()) return
setIsSubmitting(true)
try {
await contactApi.submit({
name: name.trim(),
email: email.trim(),
message: message.trim(),
})
setSubmitted(true)
} catch (err) {
console.error('Failed to send contact message:', err)
toast.error("We couldn't send your message. Please try again.")
} finally {
setIsSubmitting(false)
}
}
```
What this gets you:
- Backend errors (500, network failure, etc.) show a toast and keep the form filled. The user can retry without retyping.
- The success path only fires if the API call succeeds, with no false positives.
- `toast` comes from `@/lib/toast`, the project's wrapper around Sonner. It's themed and consistent with every other toast in the app.
Save. Submit the form. Because there's no `/contact` backend endpoint yet, the call will fail and you'll see an error toast. That's correct behavior. The frontend is doing exactly what it should. When someone implements the backend, no frontend change is required.
---
## Step 8: Link from the landing page
A page that nobody can reach isn't a page. Open `frontend/src/pages/LandingPage.tsx` and find the `<footer>` section near the bottom (search for `landing-footer`). Add a `Contact` link next to the existing Privacy and Terms links. The exact markup depends on the surrounding code, but the pattern looks like:
```tsx
<Link to="/contact" className="...">
Contact
</Link>
```
Match the styling of the adjacent links. Don't invent a new visual treatment. Consistency is what makes a footer feel like a footer.
Save. Reload `/landing`. The Contact link appears in the footer. Click it. The contact page loads.
---
## Step 9: Verify your work
You're not done until the toolchain agrees with you. Run all three from the repo root:
```bash
docker exec resolutionflow_frontend npx tsc --noEmit
docker exec resolutionflow_frontend npx eslint src/pages/ContactPage.tsx src/api/contact.ts
docker exec resolutionflow_frontend npx vite build
```
All three should pass with no errors. (Vite may print pre-existing chunk-size warnings; those are unrelated to your change.)
Then go through the page in the browser one more time:
- [ ] Empty submit attempts are blocked by the browser (`required` attribute) and by your `trim()` guard
- [ ] Filling all three fields and submitting shows "Sending…" briefly, then either a success state or an error toast (depending on whether the backend exists)
- [ ] The "Send another message" button on the success state clears the form and brings the inputs back
- [ ] The back-to-home link returns you to `/landing`
- [ ] The footer link from `/landing` brings you to `/contact`
If any of those don't work, fix them before continuing. Don't ship a tutorial-shaped bug.
---
## Step 10: Commit
The project's commit conventions live in `CLAUDE.md`. Follow them:
```bash
git add frontend/src/pages/ContactPage.tsx \
frontend/src/api/contact.ts \
frontend/src/router.tsx \
frontend/src/pages/LandingPage.tsx
git commit -m "feat(contact): add public Contact page with submit form
Add /contact at the public marketing layer (parallel to /privacy,
/terms). Single-column form with controlled inputs, success state
that echoes the submitter's name and email, error toast on submit
failure. Backend endpoint POST /contact is referenced but not yet
implemented; submits will toast-error until it lands.
Linked from the landing page footer.
"
```
If the project requires a co-author trailer (check `CLAUDE.md`), add it. Don't push directly to `main` if it's a protected branch; branch first, push, open a PR.
---
## What you learned
You touched every layer of a public-facing page:
- **Routing** (`router.tsx` + `lazyWithRetry` + `page()`)
- **Page composition** (`PageMeta` + layout primitives)
- **Design system tokens** (`bg-background`, `text-foreground`, `border-border`, `bg-primary`)
- **Form state** (controlled inputs, submit guards, in-flight feedback)
- **API clients** (`frontend/src/api/`, `apiClient`)
- **Error UX** (toast on failure, success state on… success)
- **Verification** (tsc, eslint, build, manual browser pass)
The pattern transfers. An in-app page (under `/account`, `/sessions`, etc.) is the same set of moves with one difference: it sits inside the app shell instead of standing alone, so the route is nested and you skip the `min-h-screen bg-background` outer wrapper.
---
## Where to go next
- **Read** `frontend/src/pages/account/ProfileSettingsPage.tsx` for the in-app form convention with shared `inputClass` and a save-changes pattern.
- **Read** `PRODUCT.md` and `DESIGN-SYSTEM.md` end-to-end. They're short and they're the source of truth for "is this design right?"
- **Try** building a second page on your own. Pick a simple one like a `/changelog` route that just lists releases. Apply what you learned without rereading this tutorial.
- **Skim** `frontend/src/pages/account/IntegrationsPage.tsx` once you're comfortable with the basics; it's a real working complex page that exercises forms, API state, optimistic updates, and modals together.