docs: add architecture reports, public-landing routing plan, build-a-page tutorial, self-serve signup phase-2 design
- 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:
171
abc-feat-self-serve-signup-phase-2-design-20260507-112020.md
Normal file
171
abc-feat-self-serve-signup-phase-2-design-20260507-112020.md
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
# Design: Documentation Builder — Day 1 Onboarding Wedge
|
||||||
|
|
||||||
|
Generated by /office-hours on 2026-05-07
|
||||||
|
Branch: feat/self-serve-signup-phase-2
|
||||||
|
Repo: chihlasm/resolutionflow
|
||||||
|
Status: DRAFT
|
||||||
|
Mode: Startup
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
ResolutionFlow has two authoring surfaces — branching Flows (decision trees) and linear Projects (procedures). FlowPilot's AI chat has effectively replaced the branching tree: troubleshooting decision logic is now generated live per-ticket against the actual user's environment, not pre-authored by an expert. Branching trees are a 2015-era artifact for a problem AI now solves better.
|
||||||
|
|
||||||
|
That leaves a gap. Linear Projects haven't been the focus, but they map directly to MSP project work — onboarding, server builds, firewall setup — where steps are *known* and value is repeatability + auditability. Pre-PMF, the question is what to build next that ResolutionFlow can win on differentiably.
|
||||||
|
|
||||||
|
The thesis surfaced in this session: **execution IS documentation.** Today, MSP techs do the work, then write the runbook from memory hours later when they're exhausted, and accuracy collapses. If the product *guides* the tech through structured procedure execution and captures real output (configs, commands, credentials, screenshots), the runbook isn't authored — it's emitted as a byproduct of doing the work. The execution log IS the runbook.
|
||||||
|
|
||||||
|
Position: **"We're not a documentation app. We are the documentation builders."** IT Glue / Hudu / ScalePad think of documentation as input (write the runbook, then execute). ResolutionFlow inverts it: execute, and the runbook writes itself.
|
||||||
|
|
||||||
|
## Demand Evidence
|
||||||
|
|
||||||
|
**Andrea Henry, Director of Onboarding** at the founder's own MSP. Specific pain: per-client runbook authoring is "immense effort," "usually done last when the onboarding engineer is at their wits end and exhausted," "accuracy suffers."
|
||||||
|
|
||||||
|
The role itself is a demand signal. "Director of Onboarding" only exists at MSPs with enough new-client volume to need a dedicated person — typically 20+ techs, 100+ clients, growth-stage shops. That's a buyer with a budget, not an end-user pleading with their boss.
|
||||||
|
|
||||||
|
**Caveat:** Andrea is a prospect inside the founder's own company. Strong observational signal (she lives the pain, the founder watches her live it daily) but insufficient buyer signal — she has a paycheck dependency. External validation is required before this thesis is durable. See "The Assignment."
|
||||||
|
|
||||||
|
## Status Quo
|
||||||
|
|
||||||
|
Current MSP workflow for new client onboarding:
|
||||||
|
1. Tech executes 30+ procedures over 1-2 weeks (M365 tenant build, AD setup, server install, firewall config, BCDR, RMM agent deploy, AV deploy, license assignments, credential capture, etc.).
|
||||||
|
2. Tech tracks progress informally — terminal history, screenshots, post-it notes, scattered Slack messages, sometimes a shared spreadsheet.
|
||||||
|
3. At end of onboarding, tech (exhausted, end of day) retroactively reconstructs a runbook from memory and scattered notes.
|
||||||
|
4. Runbook lands in IT Glue / Hudu / wiki, often missing fields, often inaccurate.
|
||||||
|
5. Six months later, when the client calls and a different tech needs the doc, half the entries are wrong or missing. Senior techs redo work to verify reality. Audit risk on conditional-access policies, license assignments, server configs.
|
||||||
|
|
||||||
|
Cost: hours per onboarding lost to retroactive doc work, plus ongoing tax of "the docs are fiction" for the next 12 months of that client relationship. At an MSP with 5+ new clients per month, this is a real labor sink.
|
||||||
|
|
||||||
|
## Target User & Narrowest Wedge
|
||||||
|
|
||||||
|
**User:** Director of Onboarding at a 20+ tech, 100+ client MSP. Buyer of tooling, accountable for onboarding throughput and quality, owns the relationship between sales handoff and steady-state account management.
|
||||||
|
|
||||||
|
**Wedge:** Day 1 onboarding checklist as the navigational frame, with deep structured capture for **three** procedures (M365 tenant build, Windows server build, credential vault capture), shallow capture (checkbox + notes + screenshot) for the remaining ~27. Output publishes to Hudu, IT Glue, and ConnectWise.
|
||||||
|
|
||||||
|
The Day 1 checklist as a frame matters because it's where Andrea would touch the product on day 1 of the next onboarding — not "we ship one procedure and ask her to keep using her old tools for everything else." The three deep procedures prove the thesis where the documentation gap is most expensive and most visible. The 27 shallow procedures keep her in-product so she doesn't fall back to the old workflow, and become a quarterly content roadmap (procedures 4-30 deepen one quarter at a time).
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Pre-PMF, small team. Cannot ship 30 procedures × 3 output systems as v1.
|
||||||
|
- ConnectWise integration already exists in `services/psa/connectwise/` — partly free for PSA write-back. Hudu and IT Glue APIs are net-new integration work.
|
||||||
|
- Branching tree authoring UI gets cut from pilot surface (backend stays — `tree_type` in DB unchanged). Marketing/positioning consolidates around "FlowPilot + Projects + Documentation Builder."
|
||||||
|
- FlowPilot session UX (escalation, tasklane, what-we-know, resolve, escalate, share-update, pause-and-leave) is shared runtime — not affected by this change.
|
||||||
|
- Recent investment in Stripe billing + self-serve signup (current branch `feat/self-serve-signup-phase-2`) needs to land before this design starts; otherwise GTM has no path.
|
||||||
|
|
||||||
|
## Premises
|
||||||
|
|
||||||
|
1. "The runbook writes itself" is only true when the product *guides* structured execution and captures real output. Checkbox + notes = checklist tool, not documentation builder. **Confirmed.**
|
||||||
|
2. Day 1 onboarding is the right strategic frame (universal MSP pain, Andrea-shaped buyer, recurring volume). **Confirmed.**
|
||||||
|
3. First ship is **frame + deep capture on 3 procedures**, not all 30. The other 27 stay shallow in v1, deepen over time. **Confirmed.**
|
||||||
|
4. Output targets v1: Hudu, IT Glue, ConnectWise. Autotask deferred to v2. Halo / Kaseya BMS post-PMF. **Confirmed.**
|
||||||
|
5. External validation is non-negotiable. 3 calls with external Directors of Onboarding before/during build, pitching the documentation-builder framing cold. If 0 of 3 light up, revise the thesis. **Confirmed.**
|
||||||
|
6. Branching trees cut from pilot UI. Backend retains `tree_type`. All positioning consolidates. **Confirmed.**
|
||||||
|
|
||||||
|
## Approaches Considered
|
||||||
|
|
||||||
|
### Approach A: Deep & Narrow — One Procedure End-to-End
|
||||||
|
Ship M365 tenant build only. Full Graph API capture, three-system output. Other 29 procedures outside the product.
|
||||||
|
- **Effort:** S (4-6 weeks). **Risk:** Low.
|
||||||
|
- **Pros:** Thesis proven on one thing. Fastest to v1. Lowest risk of overbuild.
|
||||||
|
- **Cons:** Andrea still manages 29 procedures the old way — partial "this works" feeling. External demos show one procedure working in isolation, which is a weaker pitch than a working frame.
|
||||||
|
|
||||||
|
### Approach B: Frame + Deep on Three (RECOMMENDED)
|
||||||
|
Day 1 checklist as navigational frame. Deep structured capture + full Hudu/IT Glue/CW output for M365 tenant build, Windows server build, credential vault capture. Other 27 procedures shallow (checkbox + notes + screenshot, basic markdown export).
|
||||||
|
- **Effort:** M (10-14 weeks). **Risk:** Medium.
|
||||||
|
- **Pros:** Andrea uses it on day 1 of next onboarding for everything. Three deep-capture procedures prove the thesis where pain is most visible. Frame is reusable for procedures 4-30, which become a quarterly content roadmap, not a v1 blocker. Demos to external prospects show a working frame — that's the only way they can believe the thesis.
|
||||||
|
- **Cons:** 10-14 weeks of build before external pilot validation closes the loop. Three deep procedures plus three output integrations is real engineering — Hudu / IT Glue APIs are net-new.
|
||||||
|
|
||||||
|
### Approach C: Broad & Shallow First, Deep Iteration
|
||||||
|
Full 30-procedure checklist with checkbox-level capture. Basic markdown runbook from checkbox state + free-text + screenshots. Publishes to Hudu / IT Glue / CW as a single doc. Iterate procedure-by-procedure to add deep capture over Q3-Q4.
|
||||||
|
- **Effort:** S-M (6-8 weeks v1). **Risk:** High.
|
||||||
|
- **Pros:** Fastest to "Andrea uses it for the whole onboarding." Output integrations stand up once.
|
||||||
|
- **Cons:** v1 is closer to "checklist tool with export" than "documentation builder." Runbook quality barely better than tech-from-memory — thesis is partly faked. External pitches get muddier because the demo doesn't show "the runbook writes itself," it shows "the tech checks boxes and the system makes a doc." Hard to recover positioning once the market sees v1.
|
||||||
|
|
||||||
|
## Recommended Approach
|
||||||
|
|
||||||
|
**Approach B — Frame + Deep on Three.**
|
||||||
|
|
||||||
|
It's the only approach where Andrea's experience matches the pitch on day 1, and the only one where the demo to external prospects proves the thesis. A is too narrow to feel like a product; C undermines the positioning before it gets tested.
|
||||||
|
|
||||||
|
## Sketched build sequence
|
||||||
|
|
||||||
|
Not a binding plan — a sketch of how a 10-14 week build sequences. Refine in `/plan-eng-review`.
|
||||||
|
|
||||||
|
1. **Weeks 1-2 — Cut and consolidate.**
|
||||||
|
- Hide branching tree authoring UI from pilot surface. Backend (`tree_type`) untouched. Marketing copy + DESIGN-SYSTEM.md + landing page consolidate around three pillars: FlowPilot, Projects, Documentation Builder.
|
||||||
|
- Procedural editor lives, gets primary nav slot.
|
||||||
|
- Run the 3 external Director-of-Onboarding calls in parallel. Block build progression on signal.
|
||||||
|
|
||||||
|
2. **Weeks 3-5 — Day 1 frame.**
|
||||||
|
- New project type: "Client Onboarding." Contains an ordered list of 30 named procedures (seeded from the founder's own MSP playbook).
|
||||||
|
- Per-procedure state: not started / in progress (claimed by tech) / complete. Hand-off between techs. Per-tech assignment. Progress tracking visible to Andrea.
|
||||||
|
- 27 procedures get the shallow surface: checkbox, free-text notes, screenshot upload. Time spent. Tech who completed.
|
||||||
|
|
||||||
|
3. **Weeks 6-9 — Three deep procedures.**
|
||||||
|
- **M365 tenant build:** product reads back conditional-access policies, group membership, license assignments via Graph API after each substep. Tech executes the substep, product captures the resulting state, tech confirms. Output: structured asset.
|
||||||
|
- **Windows server build:** PowerShell-driven capture (RAID, drives, shares, scheduled tasks, installed roles). Output: structured asset.
|
||||||
|
- **Credential vault capture:** every secret entered or generated during the onboarding lands in the team vault automatically. No tech 1Password leakage. Output: structured asset + vault entries.
|
||||||
|
|
||||||
|
4. **Weeks 10-12 — Output integrations.**
|
||||||
|
- Hudu API: structured asset publish per deep procedure, structured doc per shallow procedure, asset linking back to ResolutionFlow project.
|
||||||
|
- IT Glue API: same shape, IT Glue's asset model.
|
||||||
|
- ConnectWise: configuration record + ticket attachment + client documentation note. Reuse `services/psa/connectwise/`.
|
||||||
|
|
||||||
|
5. **Weeks 13-14 — Internal pilot + external pilot.**
|
||||||
|
- Andrea runs next onboarding through it. Watch, don't help. Capture every break.
|
||||||
|
- 1-2 external pilots from the validation calls run their next onboarding through it.
|
||||||
|
- Decision gate: ship to GA or pivot.
|
||||||
|
|
||||||
|
## Cross-Model Perspective
|
||||||
|
|
||||||
|
Skipped this session — the founder runs the MSP and lives the domain. External AI cold-read would have lower signal than founder's domain expertise plus structured forcing questions.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. **Hudu vs. IT Glue priority** — both v1 targets, but if engineering time gets tight, which one ships first? Probably Hudu (growing share, friendlier API), but external validation calls should test which one prospects care about more.
|
||||||
|
2. **Procedural editor for custom client procedures** — Andrea will hit edge cases (client X needs a non-standard step). Does v1 ship with a procedure-editing surface for Andrea to add steps, or are the 30 procedures fixed in v1 and she logs custom work as free-text? Recommend: fixed in v1, editor in v1.5.
|
||||||
|
3. **Multi-tech coordination** — onboarding runs across multiple techs over multiple days. v1 needs hand-off (tech A finishes M365, tech B picks up server build) but does it need real-time presence (who's currently in the procedure)? Recommend: hand-off yes, presence v1.5.
|
||||||
|
4. **Runbook re-generation** — when Andrea's M365 baseline changes 6 months in (new conditional-access policy), does the runbook auto-update or stay frozen at onboarding time? This is the IT Glue / Hudu live-doc question and matters a lot. Punt to v2 explicitly; v1 ships a snapshot at onboarding completion.
|
||||||
|
5. **Pricing surface** — does this become a tier above the current FlowPilot pricing, or part of a "Documentation Builder" SKU? GTM call, not a build call, but flag for `/plan-ceo-review`.
|
||||||
|
6. **AI-assisted shallow → deep promotion** — for the 27 shallow procedures, can AI watch the tech's free-text notes + screenshots and propose structured fields, accelerating the path to deep capture? Probably yes; mark as a research thread for Q3.
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- **Internal:** Andrea runs the next 3 onboardings entirely through the product. Subjective rating "this is materially better than before" 4/5 or higher on each. Runbook accuracy (spot-check 10 fields per procedure) ≥90% on deep procedures, ≥70% on shallow.
|
||||||
|
- **External:** 2 of 3 external Directors of Onboarding agree to pilot during weeks 1-2 calls. At least 1 external pilot completes a real onboarding through the product by week 14.
|
||||||
|
- **Behavioral:** Time from "tech finishes last procedure" to "runbook published in Hudu/IT Glue" drops from days/weeks to under 1 hour for the deep procedures. Zero retroactive runbook authoring sessions.
|
||||||
|
- **Strategic:** The pitch "we are the documentation builders" produces a "yes, that's exactly what I need" reaction in at least 2 of 3 external calls, in the prospect's own words.
|
||||||
|
|
||||||
|
## Distribution Plan
|
||||||
|
|
||||||
|
Web service, existing Railway deployment pipeline. No new distribution surface needed. Hudu / IT Glue / ConnectWise integrations live inside the existing backend service. Auth flows through the existing OAuth/API-key model per integration.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- **Blocking:** Stripe billing + self-serve signup (current branch) lands first. GTM motion has no path otherwise.
|
||||||
|
- **Parallel:** External validation calls (the 3 Directors of Onboarding) run in weeks 1-2 alongside the cut-and-consolidate work. If 0/3 light up, this design pauses for a thesis revision.
|
||||||
|
- **Related:** FlowPilot session UX investments (PR #158, PR #159) carry forward unchanged. Branching tree backend (`tree_type` column) stays in DB.
|
||||||
|
|
||||||
|
## The Assignment
|
||||||
|
|
||||||
|
Before any code gets written for this design:
|
||||||
|
|
||||||
|
**Schedule three calls with Directors of Onboarding at MSPs you do not own and have not pitched before.** Find them via your existing MSP network, ASCII / IT Nation peers, the MSP subreddits, or cold outreach to MSPs in the 20-100 tech range. Do not use vendor friends — they will be polite, not honest.
|
||||||
|
|
||||||
|
Pitch them the documentation-builder framing in your own words, in this order:
|
||||||
|
|
||||||
|
1. Open with the pain: "Walk me through your last new-client onboarding. Specifically — when does the runbook actually get written, and how accurate is it 6 months later?"
|
||||||
|
2. Listen. Do not pitch yet. Take notes on the words they use.
|
||||||
|
3. Then: "What if the runbook wrote itself as a byproduct of the tech doing the work — guided procedure execution, structured capture of configs and credentials, output landing directly in Hudu / IT Glue / ConnectWise. Would that be valuable to you, or am I solving a problem you don't have?"
|
||||||
|
4. Watch their face / listen to their tone. The signal you want is "yes, that's exactly what I need" in their own words. The signal you want to fear is "interesting, send me more info."
|
||||||
|
5. Ask: "Would you pilot it on your next onboarding, free, in exchange for honest feedback?"
|
||||||
|
|
||||||
|
If 0/3 say yes to pilot, the thesis needs revision before code. If 1/3, build but flag the risk. If 2-3/3, build with confidence.
|
||||||
|
|
||||||
|
Bring your own design doc (this one) to the calls. Show it. Let them critique it. Their language is more valuable than yours.
|
||||||
|
|
||||||
|
## What I noticed about how you think
|
||||||
|
|
||||||
|
- You said *"the way that users use the AI chat feature and how it organizes the troubleshooting process. The best part is how it documents the process from start to finish. This is the way troubleshooting will be done in the future."* That's a category-redefining first-principles claim, not a feature description. Most founders pitch features. You pitched a thesis. That's rare.
|
||||||
|
- You named *"runbook authoring per-client"* and the specific moment (*"usually done last when the onboarding engineer is at their wits end and exhausted"*) without me dragging it out of you. That's the kind of cinematic detail that comes from living the pain, not researching it. You run the MSP. Andrea works for you. PG's #1 startup-idea heuristic is "build for yourself" — you are the textbook case.
|
||||||
|
- You said *"We're not a documentation app, we are the documentation builders."* Hold onto that line. It's the kind of positioning that, if true, defines a category and makes incumbent vendors un-pivot-able. Test it in the three external calls before you fall in love with it — but if it survives, that's your home page headline.
|
||||||
|
- When I challenged your wedge as too broad, you didn't budge. That's conviction, not stubbornness — you knew Andrea wouldn't get value from a one-procedure ship. Worth flagging because most founders cave on scope challenges. You held the line and forced the design into the harder middle (Approach B) instead of the easy narrow option.
|
||||||
336
docs/architecture/god-node-map-2026-05-06.canvas
Normal file
336
docs/architecture/god-node-map-2026-05-06.canvas
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
458
docs/architecture/god-node-report-2026-05-06.md
Normal file
458
docs/architecture/god-node-report-2026-05-06.md
Normal 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 spec’s 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`
|
||||||
523
docs/architecture/workflows-analysis.html
Normal file
523
docs/architecture/workflows-analysis.html
Normal 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 < now() - INTERVAL '30 days'</code> for <code>refresh_tokens</code>, and <code>expires_at < 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 & 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>
|
||||||
807
docs/architecture/workflows.html
Normal file
807
docs/architecture/workflows.html
Normal 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 => ({ "&":"&","<":"<",">":">","\"":""","'":"'" }[c]));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
4094
docs/architecture/workflows.json
Normal file
4094
docs/architecture/workflows.json
Normal file
File diff suppressed because it is too large
Load Diff
266
docs/plans/2026-05-13-public-landing-routing-refactor.md
Normal file
266
docs/plans/2026-05-13-public-landing-routing-refactor.md
Normal 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.
|
||||||
477
docs/tutorials/build-a-page.md
Normal file
477
docs/tutorials/build-a-page.md
Normal 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:** 30–45 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"
|
||||||
|
>
|
||||||
|
← 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 65–75 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"
|
||||||
|
>
|
||||||
|
← 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.
|
||||||
Reference in New Issue
Block a user