19 Commits

Author SHA1 Message Date
5c38fb8904 docs(decisions): record plan-tier taxonomy centralization decision (Option B)
All checks were successful
Mirror to GitHub / mirror (push) Successful in 5s
CI / frontend (pull_request) Successful in 6m55s
CI / e2e (pull_request) Successful in 10m27s
CI / backend (pull_request) Successful in 11m42s
Captures the 2026-05-29 decision to derive admin plan dropdown + validation
from the plan_limits table rather than hand-duplicating the allow-list across
6+ sites. Triggered by the prod "AI sessions down" report that traced to the
admin dropdown still offering the dead 'team' slug. Adds the matching backlog
entry to TODO.md with duplication sites enumerated.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 11:25:28 -04:00
23dbcec86e docs(plan): L1 AI decision-tree builder — Phase 2A implementation plan
19 TDD tasks from the approved spec: 3 migrations (ai_build kind, account
categories, FlowProposal l1_session_id), ai_tree_builder (constrained node
gen + validation + normalize), match_or_build orchestrator (match-first,
gate-on-build), session-service ai_build start/advance, flywheel capture on
resolve, engineer escalation notification, category settings API, and the
frontend walker/dispatch/settings/escalations surfaces + e2e.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 03:16:10 -04:00
f62712d11c docs(spec): resolve 6 Codex review findings on L1 AI tree builder spec
- Blocker: FlowProposal can't link an l1_walk_session (source_session_id is
  NOT NULL FK→ai_sessions, UI links /pilot). Add nullable l1_session_id +
  exactly-one CHECK + read-only walked-path link for L1-sourced proposals.
- High: flow_matching_engine matches published flows only; scope match pass
  to flows, defer proposal-matching.
- High: notification system is FlowPilot-shaped; enumerate the 3 changes for
  l1.session.escalated (VALID_EVENTS, link+body builder, explicit engineer
  recipients). Engineer-visible surface is the primary handoff.
- Medium: match before category gate so authored flows aren't blocked.
- Medium: define normalize_walked_path → valid tree with root id, unexplored
  branches as needs_review stubs.
- Medium: category write auth needs owner/admin, not engineer; add
  require_account_owner_or_admin dep.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 03:04:49 -04:00
5b58702b20 docs(spec): L1 AI decision-tree builder — Phase 2A design
Brainstormed design for real-time AI tree building when no KB/flow matches.
Overrides the original "no empty-KB build" rule: build from generic L1
knowledge under a layered safety model (classification gate, constrained
generation, per-node validation with a hard floor, standing disclaimer).
Approach C — dedicated ai_tree_builder + match_or_build orchestrator,
reusing flow_matching_engine and the knowledge_flywheel proposal pipeline.

Scope: streaming node-by-node builder, admin-configurable categories,
flywheel capture of resolved trees, minimum escalation handoff (notify +
engineer surface). KB ingestion/connectors, PSA reassign, escalation
package, and AI chat handoff deferred to later phases.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 01:22:37 -04:00
57d28ac08e Merge PR (#189) feat(l1): L1 workspace Phase 1 (internal-only) into main
All checks were successful
CI / frontend (push) Successful in 6m57s
Mirror to GitHub / mirror (push) Successful in 6s
CI / e2e (push) Successful in 10m39s
CI / backend (push) Successful in 12m0s
Phase 1 ships internal-only. Escalation handoff, AI tree builder, KB connectors deferred to Phase 2A (spec in progress). All checks green incl. e2e on 890cb80.
2026-05-29 05:18:47 +00:00
890cb80bef fix(l1): confine L1 techs to their surface + accessible rail nav labels
All checks were successful
Mirror to GitHub / mirror (push) Successful in 5s
CI / frontend (pull_request) Successful in 7m2s
CI / e2e (pull_request) Successful in 10m27s
CI / backend (pull_request) Successful in 12m0s
Two regressions surfaced by running the L1 e2e suite against current main
(which carries PR #174's /home routing migration):

1. L1 post-login redirect keyed off `pathname === '/'`, but the authed index
   moved to /home in #174 — so L1 users landed on the engineer dashboard
   instead of /l1. Replace the ad-hoc '/' and /pilot|/assistant checks with a
   single allowlist: l1_tech users may only reach /l1*, /guides, /account,
   /change-password; everything else (incl. /home, /pilot, /trees/*,
   /escalations) bounces to /l1. Runs before the requiredRole check so L1
   users never trip the engineer-route role logic.

2. Rail nav Links exposed only the truncated shortLabel as their accessible
   name (title= is not an accessible-name source when visible text exists), so
   the "L1 Workspace" coverage-engineer link was unreachable by role+name. Add
   aria-label={item.label} for an accurate accessible name on every rail link.

Fixes all 3 failing cases in e2e/l1-workspace.spec.ts. tsc + eslint clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 01:06:02 -04:00
aca1360164 fix(l1): replace any casts with structural error types (eslint)
Some checks failed
Mirror to GitHub / mirror (push) Successful in 5s
CI / e2e (pull_request) Failing after 6m33s
CI / frontend (pull_request) Successful in 6m57s
CI / backend (pull_request) Successful in 12m1s
Frontend CI failed on @typescript-eslint/no-explicit-any in three L1
post-review fix sites. Replace `(err as any).response...` with the
codebase's established structural cast
`(err as { response?: { data?: { detail?: string } } })`, matching
TicketPickerModal / FolderEditModal / ProceduralEditorPage. The
AccountSettingsPage 402 handler gets the fuller seat-limit detail shape.

tsc clean, eslint clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 00:48:14 -04:00
4c83cebfca Merge branch 'main' into feat/l1-workspace
Some checks failed
Mirror to GitHub / mirror (push) Successful in 4s
CI / frontend (pull_request) Failing after 1m52s
CI / e2e (pull_request) Failing after 6m6s
CI / backend (pull_request) Successful in 12m15s
# Conflicts:
#	frontend/src/router.tsx
2026-05-29 00:24:54 -04:00
1d92893573 Merge pull request 'feat(ai): robust response extraction + structured-output foundation (flag-gated)' (#188) from feat/ai-structured-outputs into main
All checks were successful
CI / frontend (push) Successful in 6m59s
Mirror to GitHub / mirror (push) Successful in 6s
CI / e2e (push) Successful in 10m32s
CI / backend (push) Successful in 12m16s
Backend boot verified in local PR env. AI_KB_CONVERT_STRUCTURED_OUTPUT flag remains False by default; behavior on prod unchanged until staging-validated flip.
2026-05-29 04:23:28 +00:00
5bfbc2c096 Merge pull request 'feat(landing): redesign hero + editorial layout with Atkinson Hyperlegible' (#187) from feat/landing-redesign into main
Some checks failed
CI / frontend (push) Has been cancelled
CI / e2e (push) Has been cancelled
CI / backend (push) Has been cancelled
Mirror to GitHub / mirror (push) Has been cancelled
Visually approved in local PR env. 1 commit, frontend-only, fully reversible.
2026-05-29 04:23:27 +00:00
02db15f118 docs(decisions): scope structured outputs to flat-array JSON (close 3c)
Some checks failed
Mirror to GitHub / mirror (push) Failing after 6s
CI / frontend (pull_request) Successful in 7m12s
CI / backend (pull_request) Successful in 11m51s
CI / e2e (pull_request) Successful in 10m7s
Record the 3c finding: Anthropic structured outputs apply only to flat-array
generate_json outputs (kb_conversion). ai_fix and knowledge_flywheel flow-gen
emit recursive/nested decision trees that the "no recursive schemas" limit
excludes; their fence-strippers stay. Documents the deferred kb-only
_try_repair_json removal pending staging validation of the
AI_KB_CONVERT_STRUCTURED_OUTPUT flag.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 21:48:49 -04:00
60b1e654f8 feat(landing): redesign hero + editorial layout with Atkinson Hyperlegible
All checks were successful
Mirror to GitHub / mirror (push) Successful in 7s
CI / frontend (pull_request) Successful in 7m6s
CI / e2e (pull_request) Successful in 10m32s
CI / backend (pull_request) Successful in 11m54s
Recover and commit the landing-page redesign that had been sitting
uncommitted in the working tree: refreshed dark palette (adjusted
--lp-bg-alt, electric-blue accent), Atkinson Hyperlegible Next display
+ body type, and editorial hero/section layout in LandingPage.tsx, with
the matching font preload in index.html.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 21:48:49 -04:00
3fde3369c8 chore: gitignore core dumps (core.<pid>)
Stop crashed-process core dumps (core.144926, etc.) from showing up as
untracked noise / being committed by accident.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 21:48:49 -04:00
067574ad6a feat(ai): robust response extraction + structured-output foundation
Harden the Anthropic provider and lay the groundwork for schema-constrained
JSON, optimizing the existing claude-sonnet-4-6 / claude-haiku-4-5 usage
(no model changes).

ai_provider.py:
- _extract_text_from_response replaces fragile response.content[0].text:
  skips non-text leading blocks (e.g. thinking), returns the first text
  block, logs an anthropic.stop_reason warning on max_tokens/refusal
  (truncation now observable), and raises ValueError on a no-text response.
- generate_json gains an optional `schema` param. Anthropic wires it to
  output_config.format (structured outputs); schema=None preserves the exact
  prior call for every existing caller. Gemini accepts-and-ignores it.

kb_conversion_service.py:
- TROUBLESHOOTING_SCHEMA / PROCEDURAL_SCHEMA + _schema_for_target_type(),
  modelled as a strict superset of every field the prompts emit.
- convert_document passes the schema only when the new
  AI_KB_CONVERT_STRUCTURED_OUTPUT setting is True (default False). The
  _try_repair_json fallback stays as belt-and-suspenders.

Tests: 14 provider + 7 schema, TDD (red-green). Live constrained-decoding
smoke-test still required before enabling the flag in production.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 21:48:49 -04:00
93ce0490e0 Merge pull request 'feat(routing): serve public landing at / and move authed index to /home' (#174) from feat/public-landing-routing-refactor into main
All checks were successful
CI / frontend (push) Successful in 6m45s
Mirror to GitHub / mirror (push) Successful in 5s
CI / e2e (push) Successful in 10m14s
CI / backend (push) Successful in 10m52s
2026-05-15 05:18:37 +00:00
f9f98b1a65 fix(routing): finish /home migration in WelcomeStep3 + VerifyEmailPage
All checks were successful
Mirror to GitHub / mirror (push) Successful in 5s
CI / frontend (pull_request) Successful in 6m42s
CI / e2e (pull_request) Successful in 10m12s
CI / backend (pull_request) Successful in 10m46s
The original public-landing routing refactor migrated WelcomeRouter,
WelcomeStep1, and WelcomeStep2 post-onboarding redirects to /home, but
left four sites still pointing at the old / + query-string destinations:

  - WelcomeStep3 `completeWizardAndExit` (Send invites)
  - WelcomeStep3 `handleSkipStep` (Skip)
  - VerifyEmailPage post-verify auto-redirect (`setTimeout`)
  - VerifyEmailPage success-state "Go to dashboard" Link

These all worked by accident because PublicLanding redirects authed
users from / to /home — so users still landed on the dashboard, but
through an unnecessary mount-and-redirect flicker, and the
`?welcome=true` / `?verified=1` query markers got dropped on the way.

Drop both query markers — neither is read anywhere in the codebase
(grepped frontend/src; the dashboard's onboarding UX is driven by
`getOnboardingStatus`, not URL state). Carrying dead URL params
just invites future "is this load-bearing?" investigations.

Test stubs in WelcomeStep3.test.tsx and VerifyEmailPage.test.tsx
moved from `<Route path="/">` to `<Route path="/home">` so the
assertions verify the new destination instead of accidentally matching
the old one (the previous stubs masked the partial migration).

Out of scope: AcceptInvitePage and OAuthCallbackPage still use
`?welcome=teammate`, but that one carries an explicit "decoded by the
dashboard in Task 41" annotation and may be wired up later, so left
untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 00:34:23 -04:00
86163a69aa test(welcome): align Router/Step1/Step2 stub routes with /home destination
Some checks failed
Mirror to GitHub / mirror (push) Failing after 5m5s
CI / frontend (pull_request) Successful in 6m24s
CI / backend (pull_request) Successful in 10m19s
CI / e2e (pull_request) Successful in 9m51s
Post-refactor, WelcomeRouter and the Step1/Step2 "Skip-the-rest" handlers
navigate to /home, but the MemoryRouter test stubs still mounted the
"dashboard" marker at /. Update the stub routes (and matching it() titles)
so the assertions resolve.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:25:50 -04:00
13f527c4ad test(e2e): align auth + public smoke tests with new / and /home routing
Some checks failed
Mirror to GitHub / mirror (push) Successful in 5s
CI / frontend (pull_request) Failing after 2m4s
CI / e2e (pull_request) Successful in 10m8s
CI / backend (pull_request) Successful in 10m27s
Playwright specs still asserted the pre-refactor URLs and failed on CI:
- auth.spec.ts expected post-login to land at `/`; now `/home`.
- public.spec.ts expected unauth redirect to `/landing`; now `/`.
- public.spec.ts's landing-loads test navigated to `/landing` (a stale-
  bookmark redirect); point it directly at `/`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:35:44 -04:00
05646465b8 feat(routing): serve public landing at / and move authed index to /home
Some checks failed
Mirror to GitHub / mirror (push) Successful in 5s
CI / e2e (pull_request) Failing after 5m32s
CI / frontend (pull_request) Failing after 5m34s
CI / backend (pull_request) Successful in 10m19s
Stripe's compliance crawler fetches the apex URL without executing JS and
declined live-mode review when `https://resolutionflow.com/` returned the
empty SPA shell that redirected to /landing client-side. Restructure the
router so / serves LandingPage directly:

- `/` → new `PublicLanding` wrapper (LandingPage for anon; Navigate to
  /home for authed users so there's no marketing-frame flicker).
- Authed tree converted to a path-less layout route with absolute child
  paths. QuickStartPage moves to `/home`; all other children
  (`/trees`, `/pilot`, `/admin/*`, `/account/*`, etc.) keep their URLs.
- `/landing` kept as a one-release stale-bookmark redirect to /.
- `ProtectedRoute` unauth redirect flipped /landing → /; `state.from`
  preserved for post-login return.

Reference updates:
- Post-login / post-onboarding destinations → /home: OAuthCallbackPage
  (incl. `?welcome=teammate` query), WelcomeStep1/2/3 dismiss-rest,
  AssistantChatPage post-escalate, WelcomeRouter completion/dismiss
  redirects, VerifyEmailPage's three "Go to dashboard" links.
- Authed chrome → /home: TopBar logo, AppLayout mobile nav + drawer
  logo, CommandPalette Dashboard entry.
- Dashboard onboarding → /home: NextStepCard `ran_session.ctaPath`,
  SetupChecklist `ran_session.path`, SessionHistoryPage empty-state CTA.
- Public back-links → /: TermsPage, PrivacyPage, PoliciesPage,
  ContactPage, PromotionsPage, PublicTemplatesPage (header + footer).
  SharedSessionPage's `to="/"` left as-is — now correctly lands anon
  visitors on the public landing.

Crawlability:
- New `frontend/public/robots.txt` allowlisting public pages and
  disallowing the authed app.
- New `frontend/public/sitemap.xml` for /, /pricing, /contact-sales,
  /contact, /templates, /terms, /privacy, /policies, /promotions.
- `PageMeta` gains an `og:url` (defaults to `window.location.href`) and
  flips `twitter:card` to `summary_large_image` when an `ogImage` is
  passed.

Tests:
- `AppLayout.test.tsx` updated to mount at `/home`.
- New `ProtectedRoute.test.tsx` asserts unauthenticated `/home`
  redirects to `/` (not `/landing`) and preserves origin in `state.from`.

If Stripe's crawler still cannot see the site after this (zero-JS
crawler), the documented next escalation is server-side prerendering of
public routes via `vite-plugin-ssg`. Out of scope here.

Plan: docs/plans/2026-05-13-public-landing-routing-refactor.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 01:58:10 -04:00
50 changed files with 3519 additions and 432 deletions

View File

@@ -13,6 +13,44 @@
---
## 2026-05-29 — Single source of truth for plan-tier taxonomy (derive admin UI + validation from `plan_limits`)
**Context:** A prod report ("AI sessions aren't working") traced to the owner account having no paid plan (AI is plan-gated), compounded by a real bug: the admin "Change Plan" dropdown ([`AccountDetailPage.tsx:443-445`](../frontend/src/pages/admin/AccountDetailPage.tsx)) still offered the dead `team` slug (renamed to `enterprise` in migration `4ce3e594cb87`, 2026-05-07) and omitted `starter`/`enterprise`. Selecting "Team" 400s against the hardcoded allow-list in [`admin.py:994`](../backend/app/api/endpoints/admin.py#L994). The dropdown was missed during the 2026-05-07 taxonomy reconciliation because the allowed-plan list is hand-duplicated across ≥6 backend + frontend sites. Second taxonomy-drift incident.
**Decision:** Option B — make `plan_limits` the single source of truth: admin dropdown + pricing/checkout derive plan options from a plans endpoint (filter `is_public`, order by `sort_order`, label from `display_name`), and backend validation checks against actual `plan_limits` rows rather than a hardcoded tuple. Implementation deferred (active work is on another branch); fully specced in [TODO.md](TODO.md). A trivial dropdown-options fix may land first to unblock the admin tool.
**Rejected:** Option A (patch only the `AccountDetailPage` dropdown). Fixes the symptom but leaves the duplication that has now caused two drift incidents — and there is no outage forcing a minimal diff (bug is admin-only and was already worked around via direct Pro assignment). Conflicts with the repo principle "prefer correct architecture over minimal diff."
**Consequences:** New plan tiers become a data change (a `plan_limits` row) instead of a multi-file code edit; UI and validation can no longer drift from the catalog. Requires a public-plans read endpoint (or extending billing state) consumed by the admin UI + pricing page. The `'team'` visibility string (`Tree.visibility` / `StepLibrary.visibility`) is a separate domain and is explicitly out of scope.
---
## 2026-05-28 — Scope Anthropic structured outputs to flat-array JSON only
**Context:** Optimizing the existing Claude API usage (no model change). The Anthropic path in `generate_json` (`ai_provider.py`) had no equivalent to the Gemini path's `response_mime_type="application/json"` — it prompted for JSON and relied on downstream defenses: `_strip_markdown_fences` (ai_fix), `parse_llm_json` (knowledge_flywheel), and `_try_repair_json` (kb_conversion, which balances unclosed braces on truncated output). Anthropic structured outputs (`output_config.format` with a JSON schema) guarantee valid, parseable JSON and would eliminate those band-aids. The question was which of the four `generate_json` call sites can adopt it.
Structured outputs has hard schema limits: **no recursive schemas**, and **every object must set `additionalProperties: false`** (so the schema must enumerate exactly the fields the model emits — a superset is impossible, an omission makes a field unproducible). Tracing the call sites against those limits:
- **kb_conversion** → output is `{title, description, nodes: [...]}` / `{...steps[], intake_form[]}`**flat arrays**, references by `next_node_id`/id, no nesting. Expressible.
- **ai_fix** → returns a fixed *node that is itself a subtree*; `_find_node_by_id` recurses `node["children"]` and the prompt requires decision nodes to have ≥2 children. **Recursive, arbitrary depth.**
- **knowledge_flywheel flow-gen** → emits `tree_structure`, a decision-tree root with nested `children`/`options`, persisted as an opaque blob.
- **knowledge_flywheel enhancement** → flat `new_nodes[] + modified_options[]`; expressible but low-frequency and only fence-stripped.
**Decision:** Apply structured outputs to **flat-array outputs only** — i.e. `kb_conversion`. Wired via an optional `schema=` param on `AIProvider.generate_json` (`None` = legacy prompt-only behavior; Anthropic maps it to `output_config.format`, Gemini ignores it), with the two KB schemas + `_schema_for_target_type()` in `kb_conversion_service.py`, gated behind `settings.AI_KB_CONVERT_STRUCTURED_OUTPUT` (default **False**) pending a live constrained-decoding smoke-test in staging. The robustness fixes that motivated the work — `_extract_text_from_response` (skip non-text blocks, log `max_tokens`/`refusal`, raise on no-text) — live in the shared provider, so **all four** callers already benefit regardless of schema adoption.
**Rejected:**
- **Forcing schemas on ai_fix / flow-gen.** Their outputs are recursive/nested decision trees; a bounded-depth schema would reject valid deeper trees and break generation. Wrong architecture for marginal/zero benefit (flow-gen's tree is stored as a blob, never schema-validated downstream).
- **Wiring the flywheel enhancement site.** Flat and technically expressible, but low call frequency and only fence-stripping today — marginal benefit against the risk of a blind (un-live-tested) `additionalProperties: false` schema.
- **Deleting the fence-strip / repair helpers now.** `_strip_markdown_fences` / `parse_llm_json` must stay — they protect the recursive paths that can't use schemas. Only `_try_repair_json` (kb-only) becomes removable, and only *after* the flag is validated in staging.
**Consequences:**
- Structured outputs is the tool for flat JSON; recursive decision-tree outputs are excluded by design. New flat-JSON `generate_json` callers can opt in via `schema=`; recursive ones should not.
- `AI_KB_CONVERT_STRUCTURED_OUTPUT` must be smoke-tested against the live model (both target types) before production enablement. Open risk: whether Anthropic accepts optional (non-`required`) fields — if not, the schemas need every field in `required` with nullable types. The flag makes this fully reversible.
- Deferred cleanup: once the flag is validated, remove only `_try_repair_json` from the kb_conversion Anthropic path; leave the fence-strippers.
- Work lives on branch `feat/ai-structured-outputs` (commits `84a02a5`, `1388357`), based on `design/l1-workspace`.
---
## 2026-05-13 — Session expiration policy: 3d idle / 14d absolute defaults + per-account override
**Context:** User report: "I login to ResolutionFlow and never have to log back in." Investigation found refresh tokens at `REFRESH_TOKEN_EXPIRE_DAYS=7` with JTI rotation (`security.py:36`) — every `/auth/refresh` minted a fresh 7-day window. Net effect: a sliding 7-day session with no absolute cap. Visit once a week, logged in forever. Acceptable for pilot but not for MSP buyers whose SOC2 / cyber-insurance auditors require enforced session timeouts. Required for the same Phase O launch readiness as the other gates already in flight.

View File

@@ -23,3 +23,5 @@ None selected. Pick from the backlog below or `03-DEVELOPMENT-ROADMAP.md`.
- [ ] **`bg-card-hover` Tailwind class doesn't resolve.** [`frontend/src/components/layout/CommandPalette.tsx:450-451`](../frontend/src/components/layout/CommandPalette.tsx) uses `bg-card-hover` as a Tailwind utility, but Tailwind v4 generates `bg-{token}` from `--color-{token}` — and the token in [`frontend/src/index.css:15`](../frontend/src/index.css) is `--color-bg-card-hover`, which generates `bg-bg-card-hover`, not `bg-card-hover`. So those classes silently produce nothing. Other call sites (KnowledgeBaseCards, TeamSummary, ProposalBanner) use the explicit `hover:bg-[var(--color-bg-card-hover)]` form which works. Fix: change the CommandPalette classes to the explicit-var form, OR add a `--color-card-hover` semantic mapping in index.css alongside `--color-card`. Surfaced 2026-05-01 during impeccable polish sweep.
- [ ] **`ConcludeSessionModal` paused/escalated step forces single-artifact choice — should allow multi-select.** [`frontend/src/components/assistant/ConcludeSessionModal.tsx`](../frontend/src/components/assistant/ConcludeSessionModal.tsx) ~lines 430-474 ("Paused/Escalated: status update options"). Today the engineer clicks ONE of Ticket Notes / Client Update / Email Draft, the buttons disappear, and the result replaces them. Real MSP escalations almost always need at least two: technical notes for the next engineer's PSA AND a non-technical client update. Same for pause (client update + ticket notes for context when resuming). Recommended shape: multi-select with smart defaults — three checkboxes (`☑ Ticket Notes ☑ Client Update ☐ Email Draft`); for `escalated` pre-check Ticket Notes + Client Update; for `paused` pre-check Client Update only. One "Generate" button fires all selected in parallel via existing `aiSessionsApi.generateStatusUpdate(...)` (already supports the three `audience` values: `ticket_notes`, `client_update`, `email_draft`). Each result renders in its own card with its own Copy / Post-to-PSA / Send-Email action. Surfaced 2026-05-01. Feature work, not polish — touches streaming wiring for parallel calls.
- [ ] **Centralize plan-tier taxonomy — derive admin plan dropdown (and validation) from `plan_limits`, not hardcoded lists.** Chose **Option B** over a one-line patch (see [DECISIONS.md](DECISIONS.md) 2026-05-29). *Surfaced by a prod bug (2026-05-28):* the admin "Change Plan" dropdown at [`AccountDetailPage.tsx:443-445`](../frontend/src/pages/admin/AccountDetailPage.tsx) still offered `free / pro / team` — the dead `team` slug (renamed to `enterprise` in migration `4ce3e594cb87`, 2026-05-07) and missing `starter`/`enterprise`. Selecting "Team" sends `{plan:"team"}` to `PUT /admin/accounts/{id}/subscription/plan`, which 400s on `if data.plan not in ("free","pro","starter","enterprise")` ([admin.py:994](../backend/app/api/endpoints/admin.py#L994), duplicated at [:975](../backend/app/api/endpoints/admin.py#L975)). The 400 detail was swallowed by a generic `toast.error('Failed to update plan')` ([AccountDetailPage.tsx:196](../frontend/src/pages/admin/AccountDetailPage.tsx)), so it presented as "AI sessions are down" (real cause: owner account had no paid plan; AI is plan-gated). **Root cause of the root cause:** the allowed-plan list is hand-duplicated across ≥6 sites and drifted (2nd such incident). **Duplication sites to consolidate:** backend [`admin.py:975`](../backend/app/api/endpoints/admin.py#L975) + [`:994`](../backend/app/api/endpoints/admin.py#L994) (tuple, twice), [`schemas/admin.py:128`](../backend/app/schemas/admin.py) (`AdminAccountCreate.plan` Literal), frontend `AccountDetailPage.tsx` dropdown, `AccountsPage.tsx` create-account dropdown, `types/admin.ts` + `types/account.ts` + `types/billing.ts`, `hooks/useSubscription.ts` (`isPaidPlan`), `components/subscription/CheckoutButton.tsx` (`planLabels`). **Source of truth:** the `plan_limits` table (rows: free/starter/pro/enterprise) — `PlanLimitWithBillingResponse` already exposes `is_public` + `sort_order` + `display_name` for ordering/labels. **End state (B):** admin dropdown + pricing/checkout derive options from a plans endpoint backed by `plan_limits` (filter `is_public`, order by `sort_order`, label from `display_name`); backend validation checks against actual `plan_limits` rows instead of a hardcoded tuple. **Trivial first commit (land anytime to unblock the admin tool):** fix the `AccountDetailPage` dropdown to `Free / Starter / Pro / Enterprise` and surface the backend error detail in the toast. ⚠️ The `'team'` string in `Tree.visibility` / `StepLibrary.visibility` is a *separate domain* (shared-with-account) — do NOT touch it.

4
.gitignore vendored
View File

@@ -237,6 +237,10 @@ package.json
package-lock.json
.worktrees/
.gstack/
# Core dumps from crashed processes (e.g. core.12345)
core.[0-9]*
**/core.[0-9]*
.gitnexus
# graphify knowledge graph outputs

View File

@@ -147,6 +147,40 @@ def build_anthropic_chat_messages(
return messages
def _extract_text_from_response(response: Any, model: str) -> str:
"""Return the first text block's text from an Anthropic message response.
Robustness over the naive ``response.content[0].text``:
- Skips non-text leading blocks (e.g. ``thinking``) and returns the first
block whose ``type == "text"``. Indexing ``content[0]`` blindly throws or
returns garbage the moment a non-text block leads the response.
- Surfaces truncation/refusal: when ``stop_reason`` is ``max_tokens`` or
``refusal``, emits a structured warning so silent output corruption
(truncated JSON, empty refusals) is observable rather than handed
downstream to be guessed at.
- Raises ``ValueError`` when no text block is present (e.g. a bare refusal)
instead of returning a non-text block's attributes.
"""
stop_reason = getattr(response, "stop_reason", None)
if stop_reason in ("max_tokens", "refusal"):
logger.warning(
"anthropic.stop_reason",
extra={
"event": "anthropic.stop_reason",
"model": model,
"stop_reason": stop_reason,
},
)
for block in response.content:
if getattr(block, "type", None) == "text":
return block.text
raise ValueError(
f"Anthropic response contained no text block (stop_reason={stop_reason!r})"
)
def _log_anthropic_cache_usage(usage: Any, model: str) -> None:
"""Emit a structured log line capturing cache_read / cache_creation tokens."""
cache_read = getattr(usage, "cache_read_input_tokens", 0) or 0
@@ -176,6 +210,7 @@ class AIProvider(ABC):
system_prompt: str | list[SystemBlock],
messages: list[dict[str, Any]],
max_tokens: int = 4096,
schema: dict[str, Any] | None = None,
) -> tuple[str, int, int]:
"""Generate a JSON response from the AI model.
@@ -185,6 +220,15 @@ class AIProvider(ABC):
Anthropic prompt caching per module-docstring policy.
messages: List of message dicts with "role" and "content" keys.
max_tokens: Maximum output tokens.
schema: Optional JSON Schema constraining the response shape.
When provided, the Anthropic backend uses structured outputs
(`output_config.format`) to guarantee valid, parseable JSON —
no markdown fences, no truncated-brace repair. Must satisfy the
structured-output schema limits (every object needs
`additionalProperties: false`; no recursion; numeric/string
constraints are stripped). `None` preserves the legacy
prompt-only behavior. The Gemini backend currently ignores this
argument (it already requests `application/json`).
Returns:
Tuple of (response_text, input_tokens, output_tokens).
@@ -231,7 +275,11 @@ class GeminiProvider(AIProvider):
system_prompt: str | list[SystemBlock],
messages: list[dict[str, Any]],
max_tokens: int = 4096,
schema: dict[str, Any] | None = None,
) -> tuple[str, int, int]:
# `schema` is accepted for interface parity but ignored: Gemini already
# constrains output via response_mime_type="application/json" below.
# Mapping JSON Schema -> Gemini response_schema is deferred.
from google import genai
from google.genai import types as genai_types
@@ -362,18 +410,28 @@ class AnthropicProvider(AIProvider):
system_prompt: str | list[SystemBlock],
messages: list[dict[str, Any]],
max_tokens: int = 4096,
schema: dict[str, Any] | None = None,
) -> tuple[str, int, int]:
client = _get_anthropic_client(self._api_key, self._timeout)
normalized_system = _normalize_system_for_anthropic(system_prompt)
response = await client.messages.create(
model=self._model,
max_tokens=max_tokens,
system=normalized_system,
messages=messages,
)
create_kwargs: dict[str, Any] = {
"model": self._model,
"max_tokens": max_tokens,
"system": normalized_system,
"messages": messages,
}
if schema is not None:
# Structured outputs: constrain the response to valid JSON matching
# the schema (Sonnet 4.6 / Haiku 4.5). Removes the need for
# markdown-fence stripping and truncated-JSON repair downstream.
create_kwargs["output_config"] = {
"format": {"type": "json_schema", "schema": schema}
}
text = response.content[0].text
response = await client.messages.create(**create_kwargs)
text = _extract_text_from_response(response, self._model)
input_tokens = response.usage.input_tokens
output_tokens = response.usage.output_tokens

View File

@@ -155,6 +155,12 @@ class Settings(BaseSettings):
AI_CONVERSATION_TTL_HOURS: int = 24
AI_MAX_CALLS_PER_FLOW: int = 10
AI_REQUEST_TIMEOUT_SECONDS: int = 120
# When True, KB conversion constrains the Anthropic response with a JSON
# schema (structured outputs) instead of relying on prompt-only JSON +
# downstream fence-stripping / brace-repair. Default OFF: enable in staging
# and smoke-test constrained decoding against the live model before turning
# it on in production. Only affects the Anthropic backend.
AI_KB_CONVERT_STRUCTURED_OUTPUT: bool = False
# AI Provider selection
AI_PROVIDER: str = "anthropic" # "gemini" or "anthropic"
GOOGLE_AI_API_KEY: Optional[str] = None

View File

@@ -202,6 +202,115 @@ the engineer attached, NOT from this schema):
9. Return ONLY valid JSON — no markdown fences, no explanation text."""
# ── Structured-output schemas ──
#
# These constrain the model's JSON via Anthropic structured outputs
# (output_config.format) so the response is guaranteed valid and parseable —
# no markdown fences, no truncated-brace repair. They must be a SUPERSET of
# every field the corresponding system prompt instructs the model to emit:
# additionalProperties is False everywhere, so any field the prompt asks for
# but the schema omits would be impossible to produce.
#
# `type`/`field_type` are intentionally left as plain strings (no enum): the
# downstream parser already normalizes/tolerates the type values, and an enum
# risks constraining the model away from a value the prompt would yield.
_TROUBLESHOOTING_OPTION_SCHEMA: dict[str, Any] = {
"type": "object",
"properties": {
"label": {"type": "string"},
"next_node_id": {"type": "string"},
},
"required": ["label", "next_node_id"],
"additionalProperties": False,
}
_TROUBLESHOOTING_NODE_SCHEMA: dict[str, Any] = {
"type": "object",
"properties": {
"id": {"type": "string"},
"type": {"type": "string"},
"question": {"type": "string"},
"options": {"type": "array", "items": _TROUBLESHOOTING_OPTION_SCHEMA},
"next_node_id": {"type": "string"},
"confidence": {"type": "number"},
"source_excerpt": {"type": "string"},
},
# Only the universal fields are required. `question`/`options`/`next_node_id`
# vary by node type and stay optional so a resolution node need not carry
# options and an action node need not carry a question.
"required": ["id", "type", "confidence", "source_excerpt"],
"additionalProperties": False,
}
TROUBLESHOOTING_SCHEMA: dict[str, Any] = {
"type": "object",
"properties": {
"title": {"type": "string"},
"description": {"type": "string"},
"nodes": {"type": "array", "items": _TROUBLESHOOTING_NODE_SCHEMA},
},
"required": ["title", "description", "nodes"],
"additionalProperties": False,
}
_PROCEDURAL_STEP_SCHEMA: dict[str, Any] = {
"type": "object",
"properties": {
"id": {"type": "string"},
"type": {"type": "string"},
"content": {"type": "string"},
"confidence": {"type": "number"},
"source_excerpt": {"type": "string"},
},
"required": ["id", "type", "content", "confidence", "source_excerpt"],
"additionalProperties": False,
}
_PROCEDURAL_INTAKE_SCHEMA: dict[str, Any] = {
"type": "object",
"properties": {
"variable_name": {"type": "string"},
"label": {"type": "string"},
"field_type": {"type": "string"},
"required": {"type": "boolean"},
"display_order": {"type": "integer"},
},
"required": [
"variable_name",
"label",
"field_type",
"required",
"display_order",
],
"additionalProperties": False,
}
PROCEDURAL_SCHEMA: dict[str, Any] = {
"type": "object",
"properties": {
"title": {"type": "string"},
"description": {"type": "string"},
"steps": {"type": "array", "items": _PROCEDURAL_STEP_SCHEMA},
"intake_form": {"type": "array", "items": _PROCEDURAL_INTAKE_SCHEMA},
},
"required": ["title", "description", "steps", "intake_form"],
"additionalProperties": False,
}
def _schema_for_target_type(target_type: str) -> dict[str, Any]:
"""Return the structured-output schema for a KB conversion target type.
Mirrors the prompt selection in ``convert_document``: only
``"troubleshooting"`` uses the decision-tree schema; everything else is
treated as a procedural flow.
"""
if target_type == "troubleshooting":
return TROUBLESHOOTING_SCHEMA
return PROCEDURAL_SCHEMA
def _build_user_message(
source_text: str,
source_metadata: dict[str, Any] | None,
@@ -404,6 +513,16 @@ async def convert_document(
model = settings.get_model_for_action("kb_convert")
provider = get_ai_provider(model=model)
# Structured outputs (flagged): constrain the response to a JSON schema so
# the model can't emit fences or truncated JSON. Falls back to prompt-only
# JSON (schema=None) when disabled; the parse path below stays intact either
# way as a belt-and-suspenders fallback.
schema = (
_schema_for_target_type(kb_import.target_type)
if settings.AI_KB_CONVERT_STRUCTURED_OUTPUT
else None
)
try:
raw_text, input_tokens, output_tokens = await provider.generate_json(
system_prompt=[
@@ -414,6 +533,7 @@ async def convert_document(
],
messages=[{"role": "user", "content": user_message}],
max_tokens=16384,
schema=schema,
)
except Exception as e:
logger.error("AI conversion failed for kb_import=%s: %s", kb_import.id, e)

View File

@@ -96,7 +96,8 @@ class TestAnthropicProvider:
)
mock_response = MagicMock()
mock_response.content = [MagicMock(text='{"result": "ok"}')]
mock_response.content = [MagicMock(type="text", text='{"result": "ok"}')]
mock_response.stop_reason = "end_turn"
mock_response.usage = MagicMock(input_tokens=100, output_tokens=50)
mock_client = AsyncMock()
@@ -120,6 +121,170 @@ class TestAnthropicProvider:
messages=[{"role": "user", "content": "Hello"}],
)
@pytest.mark.asyncio
async def test_generate_json_skips_non_text_blocks(self):
"""A leading non-text block (e.g. thinking) is skipped; the first
text block's text is returned instead of content[0].text."""
from app.core import ai_provider
ai_provider._anthropic_clients.clear()
provider = AnthropicProvider(
api_key="skip-key", model="claude-sonnet-4-6", timeout=31
)
thinking_block = MagicMock(type="thinking", thinking="hmm...")
text_block = MagicMock(type="text", text='{"ok": 1}')
mock_response = MagicMock()
mock_response.content = [thinking_block, text_block]
mock_response.stop_reason = "end_turn"
mock_response.usage = MagicMock(input_tokens=10, output_tokens=5)
mock_client = AsyncMock()
mock_client.messages.create = AsyncMock(return_value=mock_response)
with patch("anthropic.AsyncAnthropic", return_value=mock_client):
text, _, _ = await provider.generate_json(
system_prompt="You are a helper.",
messages=[{"role": "user", "content": "Hi"}],
)
assert text == '{"ok": 1}'
@pytest.mark.asyncio
async def test_generate_json_raises_when_no_text_block(self):
"""A response with no text block (e.g. a bare refusal) raises a clear
error instead of returning a non-text block's attributes."""
from app.core import ai_provider
ai_provider._anthropic_clients.clear()
provider = AnthropicProvider(
api_key="empty-key", model="claude-sonnet-4-6", timeout=32
)
mock_response = MagicMock()
mock_response.content = [MagicMock(type="thinking", thinking="...")]
mock_response.stop_reason = "refusal"
mock_response.usage = MagicMock(input_tokens=10, output_tokens=0)
mock_client = AsyncMock()
mock_client.messages.create = AsyncMock(return_value=mock_response)
with patch("anthropic.AsyncAnthropic", return_value=mock_client):
with pytest.raises(ValueError, match="no text block"):
await provider.generate_json(
system_prompt="You are a helper.",
messages=[{"role": "user", "content": "Hi"}],
)
@pytest.mark.asyncio
async def test_generate_json_logs_warning_on_truncation(self, caplog):
"""When stop_reason is max_tokens, a warning is logged (truncation
signal) and the partial text is still returned."""
import logging
from app.core import ai_provider
ai_provider._anthropic_clients.clear()
provider = AnthropicProvider(
api_key="trunc-key", model="claude-sonnet-4-6", timeout=33
)
text_block = MagicMock(type="text", text='{"partial": tr')
mock_response = MagicMock()
mock_response.content = [text_block]
mock_response.stop_reason = "max_tokens"
mock_response.usage = MagicMock(input_tokens=10, output_tokens=4096)
mock_client = AsyncMock()
mock_client.messages.create = AsyncMock(return_value=mock_response)
with patch("anthropic.AsyncAnthropic", return_value=mock_client):
with caplog.at_level(logging.WARNING, logger="app.core.ai_provider"):
text, _, _ = await provider.generate_json(
system_prompt="You are a helper.",
messages=[{"role": "user", "content": "Hi"}],
)
assert text == '{"partial": tr'
truncation_records = [
r for r in caplog.records if getattr(r, "stop_reason", None) == "max_tokens"
]
assert truncation_records, "expected a warning record for max_tokens truncation"
@pytest.mark.asyncio
async def test_generate_json_passes_output_config_when_schema_given(self):
"""When a JSON schema is supplied, it is forwarded as
output_config.format so the API constrains the response shape."""
from app.core import ai_provider
ai_provider._anthropic_clients.clear()
provider = AnthropicProvider(
api_key="schema-key", model="claude-sonnet-4-6", timeout=34
)
mock_response = MagicMock()
mock_response.content = [MagicMock(type="text", text='{"title": "x"}')]
mock_response.stop_reason = "end_turn"
mock_response.usage = MagicMock(input_tokens=10, output_tokens=5)
mock_client = AsyncMock()
mock_client.messages.create = AsyncMock(return_value=mock_response)
schema = {
"type": "object",
"properties": {"title": {"type": "string"}},
"required": ["title"],
"additionalProperties": False,
}
with patch("anthropic.AsyncAnthropic", return_value=mock_client):
await provider.generate_json(
system_prompt="You are a helper.",
messages=[{"role": "user", "content": "Hi"}],
max_tokens=512,
schema=schema,
)
mock_client.messages.create.assert_called_once_with(
model="claude-sonnet-4-6",
max_tokens=512,
system="You are a helper.",
messages=[{"role": "user", "content": "Hi"}],
output_config={"format": {"type": "json_schema", "schema": schema}},
)
@pytest.mark.asyncio
async def test_generate_json_no_output_config_when_schema_none(self):
"""With no schema, output_config is not sent (backward compatible)."""
from app.core import ai_provider
ai_provider._anthropic_clients.clear()
provider = AnthropicProvider(
api_key="noschema-key", model="claude-sonnet-4-6", timeout=35
)
mock_response = MagicMock()
mock_response.content = [MagicMock(type="text", text="{}")]
mock_response.stop_reason = "end_turn"
mock_response.usage = MagicMock(input_tokens=1, output_tokens=1)
mock_client = AsyncMock()
mock_client.messages.create = AsyncMock(return_value=mock_response)
with patch("anthropic.AsyncAnthropic", return_value=mock_client):
await provider.generate_json(
system_prompt="You are a helper.",
messages=[{"role": "user", "content": "Hi"}],
)
_, call_kwargs = mock_client.messages.create.call_args
assert "output_config" not in call_kwargs
class TestGeminiProvider:
"""Tests for GeminiProvider.generate_json."""
@@ -174,6 +339,48 @@ class TestGeminiProvider:
mock_client.aio.models.generate_content.assert_called_once()
@pytest.mark.asyncio
async def test_generate_json_accepts_and_ignores_schema(self):
"""Gemini accepts the schema kwarg (interface parity) and still
returns JSON; it does not error on the param."""
provider = GeminiProvider(api_key="test-key", model="gemini-2.5-flash")
mock_usage = MagicMock()
mock_usage.prompt_token_count = 5
mock_usage.candidates_token_count = 3
mock_response = MagicMock()
mock_response.text = '{"answer": 1}'
mock_response.usage_metadata = mock_usage
mock_client = MagicMock()
mock_client.aio.models.generate_content = AsyncMock(return_value=mock_response)
mock_genai_module = MagicMock()
mock_genai_module.Client.return_value = mock_client
mock_types = MagicMock()
mock_types.Content.side_effect = lambda **kw: kw
mock_types.Part.side_effect = lambda **kw: kw
mock_types.GenerateContentConfig.side_effect = lambda **kw: kw
mock_google = MagicMock()
mock_google.genai = mock_genai_module
mock_genai_module.types = mock_types
with patch.dict(sys.modules, {
"google": mock_google,
"google.genai": mock_genai_module,
"google.genai.types": mock_types,
}):
text, _, _ = await provider.generate_json(
system_prompt="Generate JSON.",
messages=[{"role": "user", "content": "data"}],
schema={"type": "object"},
)
assert text == '{"answer": 1}'
@pytest.mark.asyncio
async def test_generate_json_handles_none_usage(self):
"""Token counts default to 0 when usage_metadata attributes are None."""

View File

@@ -0,0 +1,104 @@
"""Tests for the structured-output JSON schemas used by KB conversion.
These validate that the schemas are well-formed against the Anthropic
structured-output limits (every object carries additionalProperties: false,
`required` is a subset of declared properties, no numeric/length constraints)
and that the target_type -> schema selector returns the right shape. They do
NOT exercise the live API — constrained decoding must be smoke-tested against
a real model before AI_KB_CONVERT_STRUCTURED_OUTPUT is enabled in production.
"""
from app.core.kb_conversion_service import (
PROCEDURAL_SCHEMA,
TROUBLESHOOTING_SCHEMA,
_schema_for_target_type,
)
# Constraints disallowed by Anthropic structured outputs (must be absent so the
# API does not reject the schema or silently strip them).
_DISALLOWED_KEYS = {
"minimum",
"maximum",
"multipleOf",
"minLength",
"maxLength",
"minItems",
"maxItems",
}
def _assert_well_formed(schema: dict) -> None:
"""Recursively assert a JSON schema obeys the structured-output limits."""
if schema.get("type") == "object":
assert schema.get("additionalProperties") is False, (
f"object schema missing additionalProperties: false: {schema}"
)
props = schema.get("properties", {})
required = set(schema.get("required", []))
assert required <= set(props), (
f"required keys not all declared as properties: {required - set(props)}"
)
for sub in props.values():
_assert_well_formed(sub)
elif schema.get("type") == "array":
_assert_well_formed(schema["items"])
assert not (_DISALLOWED_KEYS & set(schema)), (
f"schema uses unsupported constraint(s): {_DISALLOWED_KEYS & set(schema)}"
)
class TestStructuredOutputSchemas:
def test_troubleshooting_schema_is_well_formed(self):
_assert_well_formed(TROUBLESHOOTING_SCHEMA)
def test_procedural_schema_is_well_formed(self):
_assert_well_formed(PROCEDURAL_SCHEMA)
def test_troubleshooting_schema_top_level_shape(self):
props = TROUBLESHOOTING_SCHEMA["properties"]
assert set(props) >= {"title", "description", "nodes"}
node = props["nodes"]["items"]
# Every field the troubleshooting prompt may emit must be modelled,
# else additionalProperties: false makes them impossible to produce.
assert set(node["properties"]) >= {
"id",
"type",
"question",
"options",
"next_node_id",
"confidence",
"source_excerpt",
}
def test_procedural_schema_top_level_shape(self):
props = PROCEDURAL_SCHEMA["properties"]
assert set(props) >= {"title", "description", "steps", "intake_form"}
step = props["steps"]["items"]
assert set(step["properties"]) >= {
"id",
"type",
"content",
"confidence",
"source_excerpt",
}
intake = props["intake_form"]["items"]
assert set(intake["properties"]) >= {
"variable_name",
"label",
"field_type",
"required",
"display_order",
}
class TestSchemaSelector:
def test_returns_troubleshooting_schema(self):
assert _schema_for_target_type("troubleshooting") is TROUBLESHOOTING_SCHEMA
def test_returns_procedural_schema_for_procedural(self):
assert _schema_for_target_type("procedural") is PROCEDURAL_SCHEMA
def test_defaults_to_procedural_for_unknown(self):
# convert_document treats any non-"troubleshooting" target as procedural.
assert _schema_for_target_type("something-else") is PROCEDURAL_SCHEMA

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,266 @@
# L1 AI Decision-Tree Builder — Phase 2A Design
**Status:** Draft for review
**Date:** 2026-05-29
**Author:** previous session (brainstorming)
**Predecessor:** [`2026-05-28-l1-workspace-design.md`](2026-05-28-l1-workspace-design.md) (full L1 vision), [`2026-05-28-l1-workspace-phase-1-acceptance.md`](2026-05-28-l1-workspace-phase-1-acceptance.md) (what shipped in Phase 1)
---
## 1. Goal
When an L1 tech describes a problem and there is **no matching authored flow or AI draft**, the platform builds a yes/no decision tree **in real time from the model's general L1 knowledge** and walks the tech through it node by node. Scoped to L1-appropriate troubleshooting: simple yes/no questions and reversible step-by-step instructions. Successful trees are captured as outcome-validated drafts for engineer review, compounding the account's knowledge base from real resolutions.
This **overrides** the original spec's "no empty-KB build" rule (§8.1 of the predecessor), which aborted to a degradation screen when no KB existed. Instead of aborting, we build from generic knowledge under a layered safety model.
KB grounding (RAG over ingested documents) is **explicitly deferred to Phase 2B** — Phase 2A builds from generic knowledge only, plus matching against already-authored flows.
## 2. Scope
**In scope (Phase 2A):**
- `match_or_build` orchestrator inserted at L1 intake (match-first, build-on-miss).
- `ai_tree_builder` service: node-by-node ("streaming") tree generation, constrained + escalate-early.
- Admin-configurable L1 category allowlist (Account Owner/Admin control panel).
- Standing AI-disclaimer banner on AI-built walks.
- Flywheel capture: resolved AI trees become outcome-validated `FlowProposal`s.
- Minimum escalation handoff: engineer bell-badge notification + an engineer-visible "escalated from L1" surface.
**Deferred:**
- KB document ingestion + connectors (IT Glue, Hudu, SharePoint/OneDrive) — Phase 2B.
- RAG grounding of the builder on ingested KB — Phase 2B.
- PSA ticket reassign on escalation, escalation-package generation, AI chat handoff — later phase.
- `BuildAbortedNoKB` screen from the original spec — **dropped** (superseded by build-from-generic).
## 3. Architecture (Approach C)
Dedicated builder for the constrained node generation; reuse existing rails for matching and capture.
**New services:**
| File | Responsibility |
|---|---|
| `backend/app/services/match_or_build.py` | Orchestrator. `match_or_build(account_id, problem_text, ticket_ref, *, force_build=False) -> MatchOrBuildResult`. Classify → category gate → match pass → build/suggest/out-of-scope decision. |
| `backend/app/services/ai_tree_builder.py` | Node-by-node generation. `generate_next_node(problem_text, category, walked_path) -> TreeNode`. Reuses `get_ai_provider` + `generate_json` + `parse_llm_json`. Owns the constrained system prompt and per-node validation. |
| `backend/app/services/l1_category_service.py` | Read/write an account's enabled L1 categories; expose the default allowlist and the always-forbidden hard floor. |
**Reused as-is:**
- `flow_matching_engine.find_matches()` — semantic + keyword + recency match pass.
- `knowledge_flywheel` proposal-creation + dedupe (`_find_similar_pending_proposal`) — outcome-validated capture.
- `notification_service` — engineer escalation notification.
- Phase 1 `L1WalkTreeVariant` walker — its stubbed synthetic-step UI is replaced by real AI node rendering.
**Intake decision flow:**
Order matters: **match first, gate only the build path.** The category allowlist exists to bound *generic AI building* for safety — it must not block a human-authored flow that already exists for that problem. So matching against published flows runs before any category check; the category gate applies only when we fall through to building.
```
POST /l1/intake (problem_statement, customer_*, force_build?)
→ match_or_build(account_id, problem_text, problem_domain, ticket_ref, force_build):
1. if not force_build:
hits = flow_matching_engine.find_matches(problem_text, problem_domain, account_id)
best = max(hits, default=None) # published flows (Trees) only
if best and best.score >= MATCH_THRESHOLD:
return {outcome: 'matched', flow_id, session_kind: 'flow'}
if best and best.score >= SUGGEST_THRESHOLD:
return {outcome: 'suggest', near_miss, can_build: true}
2. category = classify(problem_text) # new — only on build path
3. if category not in account.enabled_l1_categories:
return {outcome: 'out_of_scope', category}
4. return {outcome: 'build', session_kind: 'ai_build', category}
```
**Match scope (Finding 2):** `flow_matching_engine.find_matches()` matches **published flows (`trees`) only** — it returns `{tree_id, tree_name, score, ...}` and has no notion of `FlowProposal`s. Phase 2A therefore matches against published flows only; the `matched` outcome is always `session_kind: 'flow'`. This is sufficient because the flywheel promotes good AI drafts to published flows (§6), which then become matchable on future intakes. Matching against not-yet-promoted proposals is a deferred enhancement (would require extending the engine), noted in §13.
Frontend dispatches on `outcome`:
- `matched` → start a `flow` walk (Phase 1 path).
- `suggest` → inline prompt ("Found a similar flow — use it, or build new?"); "Build new" re-calls intake with `force_build=true` (which skips the match pass and runs the category gate before building).
- `out_of_scope` → inline prompt offering ad-hoc walk or escalate-without-walk (Phase 1 paths).
- `build` → create an `ai_build` session, navigate to the walker, fetch the first node.
## 4. The streaming build & node schema
`ai_tree_builder.generate_next_node()` is called with the problem statement, the resolved category, and the **full walked path so far**. It returns exactly one node. Passing the whole path every call is what keeps independently-generated nodes coherent and lets the model decide when it has exhausted safe steps.
**Node shape (`proposed_flow_data` node, also the live `walked_path` entry):**
```json
// question — yes/no branch; both branches regenerate
{ "node_type": "question", "id": "n3", "text": "Is the printer showing a 'ready' status light?",
"yes_next": "generate", "no_next": "generate" }
// instruction — a single safe, reversible action; advances on acknowledgement
{ "node_type": "instruction", "id": "n4", "text": "Unplug the printer for 30 seconds, then power it back on.",
"next": "generate" }
// resolved — terminal success
{ "node_type": "resolved", "id": "n7", "text": "Printer is back online and printing test pages." }
// escalate — terminal handoff (escalate-early safety valve)
{ "node_type": "escalate", "id": "n7", "reason_category": "exhausted_safe_steps",
"text": "This looks like a driver-level fault beyond L1 scope — escalating to engineering." }
```
`"generate"` is a sentinel meaning "call `generate_next_node` again with the new answer appended." The first node is fetched synchronously on `ai_build` session creation (intake). Each subsequent node is fetched when the tech answers/acknowledges — target latency ~24s per node; show a per-node "Thinking through the next step…" affordance.
**Endpoint:** `POST /l1/sessions/{id}/next-node` body `{node_id, answer?: 'yes'|'no', acknowledged?: true, note?}`. Appends the answered node to `walked_path`, then generates and returns the next node (or a terminal node). Replaces the Phase 1 synthetic stepping in `L1WalkTreeVariant`.
## 5. Safety model (layered)
**Layer 1 — classification gate (build path only).** Runs only after the match pass misses (§3) — a human-authored flow is never blocked by category settings. `classify(problem_text)` maps the problem to a category via a lightweight model call (low token budget, returns one category key from the enabled set or `unknown`); on model failure it falls back to keyword matching against category aliases. If the result is not in the account's enabled set (or is `unknown`), intake returns `out_of_scope` (offer adhoc/escalate); no build happens.
**Layer 2 — constrained generation.** The `ai_tree_builder` system prompt restricts output to:
- Safe, reversible, observe-or-restart-class steps only (toggle/restart/reconnect/re-enter, check-status questions).
- A **hard floor of always-forbidden actions** (see §5.1) that NO category may unlock.
- An explicit instruction to emit an `escalate` node — never guess — once it runs out of in-scope safe steps.
**Layer 3 — per-node validation.** Server-side, every generated node is checked before being returned:
- Reject (and regenerate once, then escalate) nodes whose text matches forbidden-action patterns (§5.1).
- Enforce a **depth cap** (default `L1_BUILD_MAX_DEPTH = 12`): once the walked path hits the cap, force an `escalate` node.
- Validate node JSON shape (Pydantic); malformed → regenerate once, then escalate.
**Layer 4 — standing disclaimer.** Persistent banner on every `ai_build` walk:
> *"These are high-confidence troubleshooting steps, but they come from outside your organization's knowledge base — review them before acting. When in doubt, escalate early."*
### 5.1 Hard floor — always forbidden (admins cannot enable)
Regardless of enabled categories, the builder must never produce steps that:
- Modify the Windows registry, system files, or boot configuration.
- Delete, format, or repartition data/disks; remove user profiles or mailboxes.
- Change credentials, MFA, security/firewall/AV settings, or disable protections.
- Run scripts/commands with elevated/admin privileges.
- Touch domain controllers, DNS, DHCP, or production server config.
- Make purchases, license changes, or anything with billing impact.
*(This list is a product decision — review and edit during spec review.)*
### 5.2 Default enabled category allowlist (admin-editable)
Ships enabled by default; Account Owners/Admins toggle per account:
`password_reset`, `account_lockout`, `printer`, `email_outlook_client`, `wifi_network_basics`, `vpn_connect`, `teams_zoom_av`, `browser_cache_cookies`, `peripheral_reconnect`, `os_restart_update`.
*(This list is a product decision — review and edit during spec review.)*
### 5.3 Tunables
| Setting | Default | Notes |
|---|---|---|
| `MATCH_THRESHOLD` | 0.75 | Carried from predecessor spec §8.1. |
| `SUGGEST_THRESHOLD` | 0.60 | Carried from predecessor spec §8.1. |
| `L1_BUILD_MAX_DEPTH` | 12 | Force escalate beyond this many nodes. |
| `get_model_for_action('l1_realtime_build')` | Sonnet | Latency-sensitive; benchmark Sonnet vs Opus during plan. |
| Per-node max_tokens | 1024 | One node is small. |
## 6. Flywheel capture
On `resolve` of an `ai_build` session (`l1_session_service.resolve` extension):
1. **Normalize** the `walked_path` into a complete, valid `tree_structure` (§6.1) — approval requires a dict with a real `id` (see Finding 5 / `_create_tree_from_proposal`).
2. Create a `FlowProposal`: `source='ai_realtime_l1'`, `validated_by_outcome=true`, `proposed_flow_data={tree_structure, match_keywords}`, `l1_session_id=<this session>` (NOT `source_session_id` — see §6.2 / Finding 1), `linked_ticket_id/kind=<session ticket>`, `problem_domain=<category>`, `status='pending'`.
3. Run the existing `_find_similar_pending_proposal` dedupe — merge (bump supporting count) if a near-duplicate pending proposal exists, else insert.
4. Emit the existing `proposal.pending` notification to the review queue.
Engineers promote good proposals to authored flows in the existing review queue. Promoted flows are then found by `flow_matching_engine` on future intakes → the KB compounds. `source='ai_realtime_l1'` rows surface in the existing queue (badge them "AI · outcome-validated").
### 6.1 Tree normalization (Finding 5)
The live `walked_path` holds only traversed nodes, and `"generate"` is a runtime sentinel, not a real edge — that is not a valid tree and would fail the `_create_tree_from_proposal` guard (`tree_structure` must be a dict with an `id`). At resolve time, `ai_tree_builder.normalize_walked_path(walked_path) -> tree_structure` produces a complete object:
- Assign stable string `id`s to every node; the first node becomes the root and `tree_structure.id` = root id.
- `question` nodes: the **traversed** branch (`yes`/`no` the tech actually chose) points to the next traversed node; the **untraversed** branch points to a terminal `{node_type: 'needs_review', text: 'Branch not explored during the originating call'}` stub.
- `instruction` nodes point to the next traversed node.
- The traversal ends at the real terminal node (`resolved` or `escalate`).
This yields a structurally valid, reviewable tree: engineers fill in the `needs_review` branches when promoting. (Trees are `tree_type='troubleshooting'`.)
### 6.2 FlowProposal L1 source linkage (Finding 1 — Blocker)
`FlowProposal.source_session_id` is currently `nullable=False` FK → `ai_sessions`, and the review UI (`ProposalDetail.tsx`) links the "Source Session" to `/pilot/{source_session_id}` (a FlowPilot chat surface). An L1 `ai_build` session is an `l1_walk_session`, not an `ai_session`, so it cannot populate `source_session_id`. Changes:
- **Model/migration:** add `FlowProposal.l1_session_id` (nullable FK → `l1_walk_sessions.id`, `ondelete=SET NULL`, indexed). Make `source_session_id` **nullable**. Add CHECK `((source_session_id IS NOT NULL) <> (l1_session_id IS NOT NULL))` — exactly one source set.
- **Review UI:** when `l1_session_id` is set (source `ai_realtime_l1`), render the "Source" block as a read-only walked-path summary (problem statement + the resolved path) instead of a `/pilot/...` link. Existing ai_session-sourced proposals are unchanged.
- **Tree promotion:** `_create_tree_from_proposal` sets `Tree.source_session_id` from the proposal — for L1-sourced proposals leave it NULL (confirm `Tree.source_session_id` is nullable; if not, include in the migration).
## 7. Minimum escalation handoff
On `escalate` (terminal node reached, or the L1 hits the Escalate modal during an `ai_build` walk) — extends `l1_session_service.escalate`. **The engineer-visible surface is the primary, dependency-free handoff; the bell-badge notification is a thin addition that requires three specific extensions to the FlowPilot-shaped notification system (Finding 3).**
1. **Engineer-visible surface (primary).** Escalated L1 sessions appear in an engineer-facing list — extend the existing `/escalations` queue (`EscalationQueuePage`) with an "L1 escalations" section, backed by a new `GET /l1/escalations`. Each row: problem statement, walked-path summary, who escalated, when, reason category. Pollable; no dependency on the notification subsystem.
2. **Bell-badge notification (Finding 3 — three explicit changes).** The notification system is currently FlowPilot-specific:
- `VALID_EVENTS` (`backend/app/schemas/notification.py`) has no `l1.session.escalated`. **Add it** to the set (and to the default `events_enabled` map).
- `_build_notification_link` (`notification_service.py`) only knows `session.escalated → /pilot/{session_id}?pickup=true`. **Add** `l1.session.escalated → /escalations` and **add** a body template for the new event. The existing `session.escalated` event must NOT be reused — an L1 escalation has no ai_session and no `/pilot` pickup flow.
- Default recipients (`_resolve_recipients`, ~line 184) are owner/admin/team_admin only — ordinary **engineers are excluded**. Since L1 escalations must reach engineers who can pick them up, the call **must pass explicit `target_user_ids`** = the account's active `engineer`-role users (plus owner/admin), not rely on the default set.
**Still deferred** (documented, not built): PSA ticket reassign, escalation-package markdown generation, AI chat handoff/session creation.
## 8. Data model & migrations
**Migration 1 — `ai_build` session kind.**
- Extend `l1_walk_sessions` `ck_l1_walk_sessions_session_kind` CHECK to include `'ai_build'`.
- Extend `ck_l1_walk_sessions_target_consistency`: for `ai_build`, both `flow_id` and `flow_proposal_id` are NULL (same as `adhoc`).
**Migration 2 — account L1 category settings.**
- Add `accounts.enabled_l1_categories` `JSONB NOT NULL DEFAULT '<default allowlist>'::jsonb` (list of category keys). RLS already covers `accounts`.
**Migration 3 — FlowProposal L1 source linkage (Finding 1).**
- Add `flow_proposals.l1_session_id` nullable FK → `l1_walk_sessions.id` (`ondelete=SET NULL`, indexed).
- Make `flow_proposals.source_session_id` **nullable** (was `NOT NULL`).
- Add CHECK `((source_session_id IS NOT NULL) <> (l1_session_id IS NOT NULL))` — exactly one source.
- Confirm `trees.source_session_id` is nullable (L1-promoted trees leave it NULL); if not, drop its NOT NULL here.
No new tables — live build state rides on the existing `l1_walk_sessions.walked_path`; persisted trees ride on `FlowProposal.proposed_flow_data`.
## 9. API surface
| Method | Path | Notes | Auth |
|---|---|---|---|
| POST | `/l1/intake` | **Extended**: now runs `match_or_build`; response carries `outcome` (`matched`/`suggest`/`out_of_scope`/`build`). | `require_l1_or_coverage` |
| POST | `/l1/sessions/{id}/next-node` | **New**: record answer/ack on current node, generate + return next node (or terminal). | `require_l1_or_coverage` |
| GET | `/accounts/me/l1-categories` | **New**: list enabled + available categories + hard-floor (read-only) list. | `require_l1_or_above` (read) |
| PATCH | `/accounts/me/l1-categories` | **New**: set enabled categories. | `require_account_owner_or_admin` (Finding 6) |
| GET | `/l1/escalations` | **New** (or extend `/escalations`): engineer-visible escalated-from-L1 list. | `require_engineer_or_admin` |
**Finding 6 — new auth dep.** The category control is an owner/admin setting, but `require_engineer_or_admin` also admits `engineer`. No existing dep matches "owner or account-admin" (`require_account_owner` is owner-only; `require_admin` is super-admin-only). Add `require_account_owner_or_admin` to `deps.py`: allow `super_admin` bypass, then `account_role in ('owner', 'admin')`, else 403. Use it for the PATCH.
## 10. Frontend
- `L1WalkTreeVariant` — replace synthetic stepping with real node rendering driven by `/next-node`; render `question` (yes/no), `instruction` (acknowledge), `resolved`/`escalate` (terminal). Per-node loading affordance. Disclaimer banner mounted for `ai_build` sessions.
- `L1Dashboard` intake handler — dispatch on `match_or_build` `outcome` (suggest prompt, out-of-scope prompt, build → walker).
- New admin settings panel (under `/account`) — toggle enabled L1 categories; show hard-floor list as read-only "always excluded."
- Engineer escalations surface — "L1 escalations" section/list.
## 11. Testing strategy
**Backend unit:**
- `ai_tree_builder.generate_next_node` — returns valid node per type; escalate-early when path is deep / model signals exhaustion; regenerate-then-escalate on malformed/forbidden output; depth cap forces escalate.
- Per-node validation — forbidden-action patterns rejected; hard-floor enforced even if a category is enabled.
- `match_or_build` — all four outcomes at threshold boundaries (`score == MATCH_THRESHOLD`, `== SUGGEST_THRESHOLD`); **match runs before the category gate** (a matched published flow is returned even when its category is disabled — Finding 4); `force_build` skips match but still applies the category gate; `out_of_scope` only on the build path when category disabled/unknown.
- `classify` — known categories map correctly; unknown → out_of_scope.
- `normalize_walked_path` (Finding 5) — produces a dict with a root `id`; untraversed `question` branches become `needs_review` stubs; output passes the `_create_tree_from_proposal` validity guard.
- Flywheel capture — resolve creates `ai_realtime_l1` proposal with `l1_session_id` set and `source_session_id` NULL (Finding 1); CHECK accepts exactly-one-source; dedupe merges near-duplicate.
- Escalation handoff — `l1.session.escalated` accepted by the notification schema (Finding 3); link resolves to `/escalations`; explicit engineer `target_user_ids` receive it; escalated session appears in `GET /l1/escalations`.
**Backend integration:**
- Full intake→build→resolve creates an outcome-validated proposal.
- Intake→build→escalate notifies engineers and surfaces in the escalations list.
- Migrations roundtrip; `ai_build` CHECK + target-consistency hold.
**Frontend e2e (extend `l1-workspace.spec.ts`):**
- L1 intake with no match → AI build → answer nodes → resolve → proposal created.
- L1 build → escalate node → escalate handoff.
- Admin toggles a category off → that problem class returns out-of-scope.
**AI quality (plan-time):** small eval set of common L1 problems; assert trees stay in-scope, reach resolution or escalate cleanly, never emit hard-floor actions. Benchmark Sonnet vs Opus for the model-tier decision.
## 12. Risks & open questions
- **Hallucinated-but-plausible steps** for niche/company-specific apps. Mitigation: classification gate + constrained prompt + escalate-early + disclaimer. Residual risk accepted for v1; eval set bounds it.
- **Latency on a live call.** Node-by-node means ~24s per branch. Mitigation: Sonnet, small per-node token budget, clear loading affordance. Benchmark at plan time.
- **Coherence across independently-generated nodes.** Mitigation: full walked-path context every call.
- **Classification accuracy.** A misclassify could wrongly gate a valid problem out, or let a borderline one through. Mitigation: hard floor is category-independent; out-of-scope still offers adhoc/escalate (no dead end).
- **Open (product, for spec review):** the default category allowlist (§5.2) and the hard-floor list (§5.1) — confirm/edit. Model tier — confirm Sonnet pending benchmark.
## 13. Out of scope (restated)
KB ingestion + connectors, RAG grounding, PSA reassign, escalation-package generation, AI chat handoff. Each is its own later phase with its own spec.
**Also deferred (surfaced in review):**
- **Matching against unpromoted `FlowProposal`s** (Finding 2). `flow_matching_engine` matches published flows only. Extending it to also surface outcome-validated drafts before promotion is a later enhancement; Phase 2A relies on engineer promotion (draft → published flow → matchable).
## 14. Review revisions (2026-05-29 Codex review)
All six findings verified against code and resolved in this spec:
1. **Blocker — FlowProposal source linkage:** §6.2 + §8 Migration 3 (new nullable `l1_session_id`, `source_session_id` made nullable, exactly-one CHECK, review-UI link change).
2. **High — match scope:** §3 (match published flows only; proposal-matching deferred §13).
3. **High — escalation notification:** §7 (engineer surface is primary; three explicit notification-system changes enumerated).
4. **Medium — gate ordering:** §3 + §5 Layer 1 (match first; category gate only on the build path).
5. **Medium — flywheel tree shape:** §6.1 (`normalize_walked_path` produces a valid tree with root `id`; unexplored branches → `needs_review` stubs).
6. **Medium — category write auth:** §9 (new `require_account_owner_or_admin` dep; `require_engineer_or_admin` was too broad).

View File

@@ -7,7 +7,7 @@ test.describe('authentication smoke tests', () => {
test('team admin can sign in through the login form', async ({ page }) => {
await signIn(page)
await expect(page).toHaveURL(/\/$/)
await expect(page).toHaveURL(/\/home$/)
await expect(page.getByTestId('app-shell')).toBeVisible()
})
})

View File

@@ -4,7 +4,7 @@ test.use({ storageState: { cookies: [], origins: [] } })
test.describe('public route smoke tests', () => {
test('landing page loads', async ({ page }) => {
await page.goto('/landing')
await page.goto('/')
await expect(
page.getByRole('link', { name: 'Start Free', exact: true }),
@@ -17,7 +17,7 @@ test.describe('public route smoke tests', () => {
test('protected routes redirect unauthenticated users to landing', async ({ page }) => {
await page.goto('/sessions')
await expect(page).toHaveURL(/\/landing$/)
await expect(page).toHaveURL(/\/$/)
await expect(
page.getByRole('link', { name: 'Sign In' }),
).toBeVisible()

View File

@@ -10,7 +10,7 @@
<!-- Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@400;600;700;800&family=IBM+Plex+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible+Next:ital,wght@0,400;0,500;0,600;0,700;0,800;1,400;1,700&family=Atkinson+Hyperlegible+Mono:wght@400;500&family=Bricolage+Grotesque:wght@400;600;700;800&family=IBM+Plex+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<!-- PWA Icons -->
<link rel="apple-touch-icon" href="/icons/app-icon-gradient.svg" />

View File

@@ -0,0 +1,36 @@
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

View File

@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://resolutionflow.com/</loc>
<lastmod>2026-05-13</lastmod>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://resolutionflow.com/pricing</loc>
<lastmod>2026-05-13</lastmod>
<changefreq>monthly</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://resolutionflow.com/contact-sales</loc>
<lastmod>2026-05-13</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://resolutionflow.com/contact</loc>
<lastmod>2026-05-13</lastmod>
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://resolutionflow.com/templates</loc>
<lastmod>2026-05-13</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://resolutionflow.com/terms</loc>
<lastmod>2026-05-13</lastmod>
<changefreq>yearly</changefreq>
<priority>0.4</priority>
</url>
<url>
<loc>https://resolutionflow.com/privacy</loc>
<lastmod>2026-05-13</lastmod>
<changefreq>yearly</changefreq>
<priority>0.4</priority>
</url>
<url>
<loc>https://resolutionflow.com/policies</loc>
<lastmod>2026-05-13</lastmod>
<changefreq>yearly</changefreq>
<priority>0.4</priority>
</url>
<url>
<loc>https://resolutionflow.com/promotions</loc>
<lastmod>2026-05-13</lastmod>
<changefreq>monthly</changefreq>
<priority>0.4</priority>
</url>
</urlset>

View File

@@ -5,6 +5,8 @@ interface PageMetaProps {
description?: string
ogImage?: string
ogType?: string
/** Canonical/Open Graph URL. Defaults to `window.location.href` in the browser. */
url?: string
}
const SITE_NAME = 'ResolutionFlow'
@@ -20,8 +22,12 @@ export function PageMeta({
description = DEFAULT_DESCRIPTION,
ogImage,
ogType = 'website',
url,
}: PageMetaProps) {
const fullTitle = title ? `${title} | ${SITE_NAME}` : `${SITE_NAME}${DEFAULT_TAGLINE}`
const resolvedUrl =
url ?? (typeof window !== 'undefined' ? window.location.href : undefined)
const twitterCard = ogImage ? 'summary_large_image' : 'summary'
return (
<Helmet>
@@ -33,10 +39,11 @@ export function PageMeta({
<meta property="og:description" content={description} />
<meta property="og:type" content={ogType} />
<meta property="og:site_name" content={SITE_NAME} />
{resolvedUrl && <meta property="og:url" content={resolvedUrl} />}
{ogImage && <meta property="og:image" content={ogImage} />}
{/* Twitter */}
<meta name="twitter:card" content="summary" />
<meta name="twitter:card" content={twitterCard} />
<meta name="twitter:title" content={fullTitle} />
<meta name="twitter:description" content={description} />
{ogImage && <meta name="twitter:image" content={ogImage} />}

View File

@@ -79,7 +79,7 @@ export function pickNextStep(
title: 'Run your first FlowPilot session',
description: 'Paste a ticket or pick a flow to see ResolutionFlow in action.',
ctaLabel: 'Start a session',
ctaPath: '/',
ctaPath: '/home',
}
}
if (!status.connected_psa) {

View File

@@ -51,7 +51,7 @@ export function buildChecklistItems(
{
key: 'ran_session',
label: 'Run your first FlowPilot session',
path: '/',
path: '/home',
done: status.ran_session,
},
{

View File

@@ -42,7 +42,7 @@ export function L1WalkTreeVariant({ session, onSessionUpdate, onDone }: Props) {
const lastError = (err: unknown): string => {
if (typeof err === 'object' && err && 'response' in err) {
const detail = (err as any).response?.data?.detail
const detail = (err as { response?: { data?: { detail?: string } } }).response?.data?.detail
if (typeof detail === 'string') return detail
}
return 'Unexpected error'

View File

@@ -58,7 +58,7 @@ export function AppLayout() {
}
const mobileNavItems = [
{ path: '/', label: 'Dashboard', icon: LayoutGrid },
{ path: '/home', label: 'Dashboard', icon: LayoutGrid },
{ path: '/sessions', label: 'Session History', icon: Clock },
{ path: '/escalations', label: 'Escalations', icon: AlertTriangle },
{ path: '/trees', label: 'Guided Flows', icon: GitBranch },
@@ -106,7 +106,7 @@ export function AppLayout() {
style={{ background: 'var(--color-bg-sidebar)', borderRight: '1px solid var(--color-border-default)' }}
>
<div className="flex h-14 items-center justify-between px-4" style={{ borderBottom: '1px solid var(--color-border-default)' }}>
<Link to="/" className="flex items-center gap-2.5">
<Link to="/home" className="flex items-center gap-2.5">
<BrandLogo size="sm" />
<span className="text-sm font-heading font-bold text-text-heading">ResolutionFlow</span>
</Link>

View File

@@ -40,7 +40,7 @@ interface Group {
}
const PAGES: PaletteItem[] = [
{ id: 'page-dashboard', group: 'pages', title: 'Dashboard', path: '/', icon: 'page' },
{ id: 'page-dashboard', group: 'pages', title: 'Dashboard', path: '/home', icon: 'page' },
{ id: 'page-flows', group: 'pages', title: 'All Flows', subtitle: 'Browse your flow library', path: '/trees', icon: 'page' },
{ id: 'page-sessions', group: 'pages', title: 'Sessions', subtitle: 'View session history', path: '/sessions', icon: 'page' },
{ id: 'page-flowpilot', group: 'pages', title: 'FlowPilot', subtitle: 'AI troubleshooting', path: '/pilot', icon: 'page' },

View File

@@ -22,7 +22,7 @@ export function ProtectedRoute({ requiredRole, children }: ProtectedRouteProps)
}
if (!isAuthenticated) {
return <Navigate to="/landing" state={{ from: location }} replace />
return <Navigate to="/" state={{ from: location }} replace />
}
// Enforce must_change_password — redirect unless already on /change-password
@@ -30,6 +30,24 @@ export function ProtectedRoute({ requiredRole, children }: ProtectedRouteProps)
return <Navigate to="/change-password" replace />
}
// L1 techs are confined to their focused surface. The sidebar only exposes
// /l1*, /guides, and /account for them, so any other authed path (the engineer
// dashboard at /home, /pilot, /trees/*, /escalations, …) bounces to /l1. This
// also covers post-login landing: auth sends users to /home, which is not in
// the allowlist, so l1_tech users end up on /l1. Engineer-only AI surfaces
// (/pilot, /assistant) would 403 at POST /api/v1/ai-sessions anyway — this
// turns that backend error into a clean redirect. Runs before the requiredRole
// check so L1 users never trip the engineer-route role logic.
if (effectiveRole === 'l1_tech') {
const L1_ALLOWED_PREFIXES = ['/l1', '/guides', '/account', '/change-password']
const allowed = L1_ALLOWED_PREFIXES.some(
(p) => location.pathname === p || location.pathname.startsWith(p + '/'),
)
if (!allowed) {
return <Navigate to="/l1" replace />
}
}
if (requiredRole) {
const ROLE_HIERARCHY: Record<EffectiveRole, number> = {
super_admin: 5,
@@ -43,23 +61,6 @@ export function ProtectedRoute({ requiredRole, children }: ProtectedRouteProps)
}
}
// L1 users landing on / (e.g. post-login) get redirected to their workspace.
// Does not fire when already on /l1 or any other path, preventing loops.
if (effectiveRole === 'l1_tech' && location.pathname === '/') {
return <Navigate to="/l1" replace />
}
// L1 users hitting engineer-only AI surfaces (Pilot / Assistant) get pushed
// back to /l1 — POST /api/v1/ai-sessions rejects them with 403 anyway, so
// this just turns a backend error into a clean route-level redirect.
if (
effectiveRole === 'l1_tech' &&
(location.pathname.startsWith('/pilot') ||
location.pathname.startsWith('/assistant'))
) {
return <Navigate to="/l1" replace />
}
return <>{children}</>
}

View File

@@ -256,6 +256,7 @@ export function Sidebar() {
: 'text-text-rail-label hover:text-foreground'
)}
title={item.label}
aria-label={item.label}
>
<span className="relative">
<Icon size={24} strokeWidth={1.6} className={active ? 'opacity-100' : 'opacity-60 group-hover:opacity-85'} />

View File

@@ -63,7 +63,7 @@ export function TopBar() {
>
{/* Logo area */}
<Link
to="/"
to="/home"
className="flex items-center gap-2.5 pr-4 transition-all duration-200"
>
<BrandLogo size="sm" />

View File

@@ -71,11 +71,11 @@ const FROZEN_NOW = new Date('2026-05-06T00:00:00Z')
function renderAppLayout() {
return render(
<MemoryRouter initialEntries={['/']}>
<MemoryRouter initialEntries={['/home']}>
<Routes>
<Route element={<AppLayout />}>
<Route
index
path="/home"
element={<div data-testid="child-route-content">child route</div>}
/>
</Route>

View File

@@ -0,0 +1,59 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom'
import { ProtectedRoute } from '../ProtectedRoute'
import { useAuthStore } from '@/store/authStore'
/**
* Probe component: surfaces the current pathname and `location.state.from` so
* the test can assert both the redirect target and that the original
* destination is preserved for post-login return.
*/
function LocationProbe() {
const loc = useLocation()
const from =
(loc.state as { from?: { pathname?: string } } | null)?.from?.pathname ?? ''
return (
<>
<div data-testid="probe-pathname">{loc.pathname}</div>
<div data-testid="probe-from">{from}</div>
</>
)
}
describe('ProtectedRoute — unauthenticated redirect', () => {
beforeEach(() => {
useAuthStore.setState({
user: null,
token: null,
isAuthenticated: false,
isLoading: false,
})
})
it('redirects unauthenticated visits to /home → / and preserves origin in state.from', () => {
render(
<MemoryRouter initialEntries={['/home']}>
<Routes>
<Route
path="/home"
element={
<ProtectedRoute>
<div data-testid="home-content">home</div>
</ProtectedRoute>
}
/>
<Route path="/" element={<LocationProbe />} />
</Routes>
</MemoryRouter>,
)
// The protected page should not render.
expect(screen.queryByTestId('home-content')).not.toBeInTheDocument()
// We landed on / (the public landing route), not /landing.
expect(screen.getByTestId('probe-pathname')).toHaveTextContent('/')
expect(screen.getByTestId('probe-from')).toHaveTextContent('/home')
})
})

View File

@@ -237,7 +237,12 @@ export function AccountSettingsPage() {
const invitesData = await accountsApi.getInvites()
setInvites(invitesData)
} catch (err) {
const resp = (err as any)?.response
const resp = (err as {
response?: {
status?: number
data?: { detail?: { code?: string; role?: string; current?: number; limit?: number } }
}
}).response
if (resp?.status === 402 && resp?.data?.detail?.code === 'seat_limit_exceeded') {
const d = resp.data.detail
const label = d.role === 'l1_tech' ? 'L1' : 'Engineer'

View File

@@ -2416,7 +2416,7 @@ export default function AssistantChatPage() {
setShowConclude(false)
if (activeSessionStatus === 'escalated') {
toast.info('Session escalated. Heading back to your dashboard.')
navigate('/')
navigate('/home')
}
}}
onConclude={handleConclude}

View File

@@ -7,7 +7,7 @@ export default function ContactPage() {
<PageMeta title="Contact" description="Contact ResolutionFlow customer service, sales, billing, or security." />
<div className="min-h-screen bg-background text-foreground">
<div className="mx-auto max-w-3xl px-6 py-16">
<Link to="/landing" className="text-sm text-muted-foreground hover:text-foreground mb-8 inline-block">&larr; Back to home</Link>
<Link to="/" className="text-sm text-muted-foreground hover:text-foreground mb-8 inline-block">&larr; Back to home</Link>
<h1 className="text-3xl font-bold font-heading mb-4">Contact ResolutionFlow</h1>
<p className="text-muted-foreground mb-10">
We respond to customer inquiries Monday through Friday during U.S. business hours, excluding federal holidays. Email is the fastest path to a response.

View File

@@ -164,46 +164,74 @@ export default function LandingPage() {
</div>
</section>
{/* Problem — asymmetric: headline left, cards right */}
<section id="problem" className="landing-section landing-section-alt landing-reveal">
{/* Problem — editorial list, no cards */}
<section id="problem" className="landing-section landing-section-alt landing-reveal landing-section-tight">
<div className="landing-section-inner">
<div className="landing-problem-layout">
<div className="landing-problem-headline">
<div className="landing-section-label">The Problem</div>
<h2>Documentation is broken.<br />Everyone knows it.</h2>
<p>Engineers don&apos;t want to write it. Managers hate chasing it. Clients never see it. The same issues get solved from scratch &mdash; every time.</p>
</div>
<div className="landing-problem-grid">
<ProblemCard icon="&#9201;" color="red" title="15&ndash;25 min lost per ticket" description="More time documenting than resolving. After a complex issue, writing notes is the last thing anyone does." />
<ProblemCard icon="&#128203;" color="amber" title="Vague, useless notes" description={`"Fixed Outlook" tells no one anything. Notes under pressure are always too vague to help next time.`} />
<ProblemCard icon="&#128260;" color="slate" title="Knowledge walks out the door" description="When a senior engineer leaves, years of tribal knowledge vanish overnight." />
<ProblemCard icon="&#129504;" color="violet" title="Context switching kills speed" description="Jumping between the issue, docs, PSA tickets, and knowledge bases fragments focus." />
<p>Engineers don&apos;t want to write it. Managers hate chasing it. Clients never see it. The same issues get solved from scratch, every time.</p>
</div>
<ol className="landing-problem-list">
<li className="landing-problem-item">
<span className="landing-problem-num">01</span>
<div className="landing-problem-body">
<h3>15&ndash;25 min lost per ticket</h3>
<p>More time documenting than resolving. After a complex issue, writing notes is the last thing anyone does.</p>
</div>
</li>
<li className="landing-problem-item">
<span className="landing-problem-num">02</span>
<div className="landing-problem-body">
<h3>Vague, useless notes</h3>
<p>&ldquo;Fixed Outlook&rdquo; tells no one anything. Notes under pressure are always too vague to help next time.</p>
</div>
</li>
<li className="landing-problem-item">
<span className="landing-problem-num">03</span>
<div className="landing-problem-body">
<h3>Knowledge walks out the door</h3>
<p>When a senior engineer leaves, years of tribal knowledge vanish overnight.</p>
</div>
</li>
<li className="landing-problem-item">
<span className="landing-problem-num">04</span>
<div className="landing-problem-body">
<h3>Context switching kills speed</h3>
<p>Jumping between the issue, docs, PSA tickets, and knowledge bases fragments focus.</p>
</div>
</li>
</ol>
</div>
</div>
</section>
{/* Equation */}
{/* Equation — typographic moment */}
<div className="landing-equation-section landing-reveal">
<div className="landing-equation-inner">
<div className="landing-section-label">The Answer</div>
<div className="landing-brand-equation">
<span className="landing-eq-item">Resolution</span>
<span className="landing-eq-operator">+</span>
<span className="landing-eq-item">Documentation</span>
<span className="landing-eq-operator">&minus;</span>
<span className="landing-eq-item">Time</span>
<span className="landing-eq-operator">=</span>
<span className="landing-eq-result">ResolutionFlow</span>
<div className="landing-brand-equation" aria-label="Resolution plus documentation minus time equals ResolutionFlow">
<div className="landing-eq-lhs">
<span className="landing-eq-item">Resolution</span>
<span className="landing-eq-operator">+</span>
<span className="landing-eq-item">Documentation</span>
<span className="landing-eq-operator">&minus;</span>
<span className="landing-eq-item">Time</span>
</div>
<div className="landing-eq-equals">
<span className="landing-eq-operator-equals">=</span>
</div>
<div className="landing-eq-result">ResolutionFlow</div>
</div>
<p className="landing-equation-desc">
What if documentation was a <em>byproduct</em> of solving the issue &mdash; not a separate task?
What if documentation was a <em>byproduct</em> of solving the issue, not a separate task?
</p>
</div>
</div>
{/* How It Works — zigzag */}
<section id="how-it-works" className="landing-section landing-reveal">
<section id="how-it-works" className="landing-section landing-reveal landing-section-tight">
<div className="landing-section-inner">
<div className="landing-section-label">How It Works</div>
<h2 className="landing-section-title">Three steps. Zero note-writing.</h2>
@@ -268,54 +296,47 @@ export default function LandingPage() {
</div>
</section>
{/* Features */}
<section id="features" className="landing-section landing-section-alt landing-reveal">
{/* Features — editorial spec list */}
<section id="features" className="landing-section landing-section-alt landing-reveal landing-section-generous">
<div className="landing-section-inner">
<div className="landing-section-label">Features</div>
<h2 className="landing-section-title">Everything you need to troubleshoot faster.</h2>
<div className="landing-feature-highlight">
<div className="landing-feature-highlight-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3" /><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" /></svg>
</div>
<div className="landing-feature-highlight-marker" aria-hidden="true">FP</div>
<div className="landing-feature-highlight-content">
<h3>FlowPilot &mdash; Your AI Copilot</h3>
<p>Like having a senior engineer on every call. Describe the issue, get expert troubleshooting guidance, and documentation writes itself &mdash; as a byproduct of solving the problem.</p>
<h3>FlowPilot, your AI copilot</h3>
<p>Like having a senior engineer on every call. Describe the issue, get expert troubleshooting guidance, and documentation writes itself, as a byproduct of solving the problem.</p>
</div>
</div>
<div className="landing-features-grid">
<FeatureCard
icon={<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" /><line x1="9" y1="3" x2="9" y2="21" /></svg>}
title="Guided Flows"
description="Build step-by-step troubleshooting paths your team can follow. Great for onboarding and consistency."
/>
<FeatureCard
icon={<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><polyline points="14 2 14 8 20 8" /><line x1="16" y1="13" x2="8" y2="13" /><line x1="16" y1="17" x2="8" y2="17" /></svg>}
title="Zero Empty Tickets"
description="Every session generates timestamped notes, formatted for your PSA. No more empty ticket closures."
/>
<FeatureCard
icon={<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><path d="M23 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" /></svg>}
title="Team Knowledge"
description="Solutions are saved and surfaced when the next engineer hits a similar issue."
/>
<FeatureCard
icon={<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12" /></svg>}
title="Session Analytics"
description="Track resolution times, identify recurring issues, and measure team performance."
/>
<FeatureCard
icon={<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2" /><line x1="8" y1="21" x2="16" y2="21" /><line x1="12" y1="17" x2="12" y2="21" /></svg>}
title="PSA Integration"
description="Connect to ConnectWise, Atera, and Syncro. Push session docs straight to tickets."
/>
</div>
<dl className="landing-feature-spec">
<div className="landing-feature-row">
<dt>Guided Flows</dt>
<dd>Build step-by-step troubleshooting paths your team can follow. Great for onboarding and consistency.</dd>
</div>
<div className="landing-feature-row">
<dt>Zero Empty Tickets</dt>
<dd>Every session generates timestamped notes, formatted for your PSA. No more empty ticket closures.</dd>
</div>
<div className="landing-feature-row">
<dt>Team Knowledge</dt>
<dd>Solutions are saved and surfaced when the next engineer hits a similar issue.</dd>
</div>
<div className="landing-feature-row">
<dt>Session Analytics</dt>
<dd>Track resolution times, identify recurring issues, and measure team performance.</dd>
</div>
<div className="landing-feature-row">
<dt>PSA Integration</dt>
<dd>Connect to ConnectWise, Atera, and Syncro. Push session docs straight to tickets.</dd>
</div>
</dl>
</div>
</section>
{/* Pricing */}
<section id="pricing" className="landing-section landing-reveal">
<section id="pricing" className="landing-section landing-reveal landing-section-generous">
<div className="landing-section-inner">
<div className="landing-section-label">Pricing</div>
<h2 className="landing-section-title">Simple pricing. No surprises.</h2>
@@ -364,7 +385,7 @@ export default function LandingPage() {
</section>
{/* FAQ */}
<section id="faq" className="landing-section landing-section-alt landing-reveal">
<section id="faq" className="landing-section landing-section-alt landing-reveal landing-section-tight">
<div className="landing-section-inner">
<div className="landing-section-label">FAQ</div>
<h2 className="landing-section-title">Common questions</h2>
@@ -399,15 +420,16 @@ export default function LandingPage() {
</div>
</div>
{/* CTA */}
<section className="landing-cta-section landing-reveal">
{/* CTA — drenched */}
<section className="landing-cta-section landing-cta-drench landing-reveal">
<div className="landing-cta-inner">
<h2>Ready to stop writing ticket notes?</h2>
<p>Get early access. Troubleshoot your next ticket with FlowPilot.</p>
<div className="landing-cta-eyebrow">Stop writing ticket notes</div>
<h2>Troubleshoot your next ticket with FlowPilot.</h2>
<p>Get early access. Free to start, no credit card.</p>
<div className="landing-cta-actions">
<Link to="/register?from=beta" className="landing-btn-hero-primary">Get started</Link>
<Link to="/register?from=beta" className="landing-btn-cta-invert">Get started</Link>
<a href="#how-it-works" className="landing-btn-cta-ghost">See how it works</a>
</div>
<p className="landing-cta-fine-print">Free to start. No credit card required.</p>
</div>
</section>
@@ -421,30 +443,6 @@ export default function LandingPage() {
/* ---- Sub-components ---- */
function ProblemCard({ icon, color, title, description }: {
icon: string; color: string; title: string; description: string
}) {
return (
<div className="landing-problem-card">
<div className={`landing-problem-icon ${color}`}>{icon}</div>
<h3>{title}</h3>
<p>{description}</p>
</div>
)
}
function FeatureCard({ icon, title, description }: {
icon: React.ReactNode; title: string; description: string
}) {
return (
<div className="landing-feature-card">
<div className="landing-feature-icon">{icon}</div>
<h3>{title}</h3>
<p>{description}</p>
</div>
)
}
function PricingCard({ name, target, amount, period, note, features, btnLabel, btnStyle, featured, plan }: {
name: string; target: string; amount: string; period?: string; note: string
features: string[]; btnLabel: string; btnStyle: 'outline' | 'filled'; featured?: boolean; plan: string

View File

@@ -112,10 +112,10 @@ export function OAuthCallbackPage() {
// Invitee path lands on the dashboard with the teammate-welcome
// marker; new self-serve owners go to the welcome wizard; returning
// users to /.
let dest = '/'
// users to /home.
let dest = '/home'
if (decoded?.accountInviteCode) {
dest = '/?welcome=teammate'
dest = '/home?welcome=teammate'
} else if (result.is_new_user) {
dest = '/welcome'
}

View File

@@ -7,7 +7,7 @@ export default function PoliciesPage() {
<PageMeta title="Customer Policies" description="ResolutionFlow customer service, billing, refunds, cancellation, legal restrictions, and promotional terms." />
<div className="min-h-screen bg-background text-foreground">
<div className="mx-auto max-w-3xl px-6 py-16">
<Link to="/landing" className="text-sm text-muted-foreground hover:text-foreground mb-8 inline-block">&larr; Back to home</Link>
<Link to="/" className="text-sm text-muted-foreground hover:text-foreground mb-8 inline-block">&larr; Back to home</Link>
<h1 className="text-3xl font-bold font-heading mb-4">Customer Policies</h1>
<p className="text-muted-foreground mb-2">Last updated: May 7, 2026</p>
<p className="text-muted-foreground mb-2"><strong className="text-foreground">Operator:</strong> ResolutionFlow, LLC (the &ldquo;Company&rdquo;), operator of ResolutionFlow (&ldquo;Service&rdquo;).</p>

View File

@@ -7,7 +7,7 @@ export default function PrivacyPage() {
<PageMeta title="Privacy Policy" description="ResolutionFlow Privacy Policy" />
<div className="min-h-screen bg-background text-foreground">
<div className="mx-auto max-w-3xl px-6 py-16">
<Link to="/landing" className="text-sm text-muted-foreground hover:text-foreground mb-8 inline-block">&larr; Back to home</Link>
<Link to="/" className="text-sm text-muted-foreground hover:text-foreground mb-8 inline-block">&larr; Back to home</Link>
<h1 className="text-3xl font-bold font-heading mb-8">Privacy Policy</h1>
<p className="text-muted-foreground mb-6">Last updated: March 21, 2026</p>

View File

@@ -7,7 +7,7 @@ export default function PromotionsPage() {
<PageMeta title="Promotions" description="Active ResolutionFlow promotional offers and their terms." />
<div className="min-h-screen bg-background text-foreground">
<div className="mx-auto max-w-3xl px-6 py-16">
<Link to="/landing" className="text-sm text-muted-foreground hover:text-foreground mb-8 inline-block">&larr; Back to home</Link>
<Link to="/" className="text-sm text-muted-foreground hover:text-foreground mb-8 inline-block">&larr; Back to home</Link>
<h1 className="text-3xl font-bold font-heading mb-4">Promotions</h1>
<p className="text-muted-foreground mb-10">Last updated: May 7, 2026</p>

View File

@@ -168,7 +168,7 @@ export default function PublicTemplatesPage() {
{/* Header */}
<header className="sticky top-0 z-40 border-b border-border bg-background/80">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
<Link to="/landing" className="flex items-center gap-2.5">
<Link to="/" className="flex items-center gap-2.5">
<BrandLogo size="sm" />
<span className="font-heading text-lg font-semibold">
<span className="text-foreground">Resolution</span>
@@ -406,7 +406,7 @@ export default function PublicTemplatesPage() {
<footer className="border-t border-border py-8 px-4 sm:px-6 lg:px-8">
<div className="max-w-7xl mx-auto flex items-center justify-between">
<Link
to="/landing"
to="/"
className="text-muted-foreground text-sm hover:text-foreground transition-colors"
>
Powered by <span className="font-semibold">ResolutionFlow</span>

View File

@@ -423,7 +423,7 @@ export default function SessionHistoryPage() {
description="Start a FlowPilot or chat session to begin. All your sessions will appear here."
action={
<Link
to="/"
to="/home"
className="inline-flex items-center gap-2 rounded-lg bg-primary px-5 py-2.5 text-sm font-semibold text-white hover:brightness-110 active:scale-[0.98] transition-all"
>
Start a Session

View File

@@ -7,7 +7,7 @@ export default function TermsPage() {
<PageMeta title="Terms of Service" description="ResolutionFlow Terms of Service" />
<div className="min-h-screen bg-background text-foreground">
<div className="mx-auto max-w-3xl px-6 py-16">
<Link to="/landing" className="text-sm text-muted-foreground hover:text-foreground mb-8 inline-block">&larr; Back to home</Link>
<Link to="/" className="text-sm text-muted-foreground hover:text-foreground mb-8 inline-block">&larr; Back to home</Link>
<h1 className="text-3xl font-bold font-heading mb-8">Terms of Service</h1>
<p className="text-muted-foreground mb-6">Last updated: March 21, 2026</p>

View File

@@ -20,8 +20,7 @@ const SUCCESS_REDIRECT_MS = 1200
* "Already verified" state. No API call.
* - Else fire `POST /auth/email/verify` exactly once (a `useRef` guard keeps
* React 19 strict-mode double-invoke from double-firing the call). On
* success, refresh the auth store and bounce to `/?verified=1` so the
* dashboard surfaces a toast.
* success, refresh the auth store and bounce to `/home`.
* - On error, show "Invalid or expired token" + a "Resend" CTA that calls
* `POST /auth/email/send-verification`.
*/
@@ -70,10 +69,9 @@ export function VerifyEmailPage() {
if (cancelled) return
setStatus('success')
toast.success('Email verified')
// Brief success state, then redirect with a query flag so the
// dashboard can re-surface confirmation if it wants to.
// Brief success state, then redirect to the dashboard.
window.setTimeout(() => {
navigate('/?verified=1', { replace: true })
navigate('/home', { replace: true })
}, SUCCESS_REDIRECT_MS)
})
.catch((err) => {
@@ -126,7 +124,7 @@ export function VerifyEmailPage() {
Redirecting you to the dashboard
</p>
<Link
to="/?verified=1"
to="/home"
replace
className={cn(
'mt-6 inline-flex items-center rounded-lg bg-primary px-6 py-2 text-sm font-semibold text-primary-foreground',
@@ -149,7 +147,7 @@ export function VerifyEmailPage() {
action needed.
</p>
<Link
to="/"
to="/home"
className={cn(
'mt-6 inline-flex items-center rounded-lg bg-primary px-6 py-2 text-sm font-semibold text-primary-foreground',
'hover:brightness-110',
@@ -181,7 +179,7 @@ export function VerifyEmailPage() {
Resend verification email
</button>
<Link
to="/"
to="/home"
className={cn(
'inline-flex items-center justify-center rounded-lg border border-default bg-input px-6 py-2 text-sm font-medium text-foreground',
'hover:border-border-hover',
@@ -204,7 +202,7 @@ export function VerifyEmailPage() {
Try the link in your verification email again.
</p>
<Link
to="/"
to="/home"
className={cn(
'mt-6 inline-flex items-center rounded-lg bg-primary px-6 py-2 text-sm font-semibold text-primary-foreground',
'hover:brightness-110',

View File

@@ -52,7 +52,7 @@ function renderPage(initialPath: string) {
<MemoryRouter initialEntries={[initialPath]}>
<Routes>
<Route path="/verify-email" element={<VerifyEmailPage />} />
<Route path="/" element={<div>dashboard</div>} />
<Route path="/home" element={<div>dashboard</div>} />
</Routes>
</MemoryRouter>
</HelmetProvider>,
@@ -130,7 +130,7 @@ describe('VerifyEmailPage', () => {
<MemoryRouter initialEntries={['/verify-email?token=valid-token']}>
<Routes>
<Route path="/verify-email" element={<VerifyEmailPage />} />
<Route path="/" element={<div>dashboard</div>} />
<Route path="/home" element={<div>dashboard</div>} />
</Routes>
</MemoryRouter>
</HelmetProvider>,
@@ -142,7 +142,7 @@ describe('VerifyEmailPage', () => {
<MemoryRouter initialEntries={['/verify-email?token=valid-token']}>
<Routes>
<Route path="/verify-email" element={<VerifyEmailPage />} />
<Route path="/" element={<div>dashboard</div>} />
<Route path="/home" element={<div>dashboard</div>} />
</Routes>
</MemoryRouter>
</HelmetProvider>,

View File

@@ -48,7 +48,7 @@ export default function L1Dashboard() {
})
navigate(`/l1/walk/${response.session_id}`)
} catch (err) {
const detail = (err as any)?.response?.data?.detail
const detail = (err as { response?: { data?: { detail?: string } } }).response?.data?.detail
const msg =
typeof detail === 'string' ? detail : 'Failed to start walk. Try again.'
toast.error(msg)

View File

@@ -6,8 +6,8 @@ import { PageLoader } from '@/components/common/PageLoader'
* `/welcome` index — redirect to the next incomplete step (or `/` if done /
* dismissed). Decision table:
*
* onboarding_dismissed === true → /
* onboarding_step_completed >= 3 → /
* onboarding_dismissed === true → /home
* onboarding_step_completed >= 3 → /home
* onboarding_step_completed === null/0 → /welcome/step-1
* onboarding_step_completed === 1 → /welcome/step-2
* onboarding_step_completed === 2 → /welcome/step-3
@@ -19,10 +19,10 @@ export function WelcomeRouter() {
// the page loader rather than racing past the redirect.
if (!user) return <PageLoader />
if (user.onboarding_dismissed) return <Navigate to="/" replace />
if (user.onboarding_dismissed) return <Navigate to="/home" replace />
const completed = user.onboarding_step_completed ?? 0
if (completed >= 3) return <Navigate to="/" replace />
if (completed >= 3) return <Navigate to="/home" replace />
if (completed === 2) return <Navigate to="/welcome/step-3" replace />
if (completed === 1) return <Navigate to="/welcome/step-2" replace />
return <Navigate to="/welcome/step-1" replace />

View File

@@ -85,7 +85,7 @@ export function WelcomeStep1() {
try {
await onboardingApi.dismissRest()
await fetchUser()
navigate('/')
navigate('/home')
} catch {
setError('Could not save. Please try again.')
setSubmitting(null)

View File

@@ -90,7 +90,7 @@ export function WelcomeStep2() {
try {
await onboardingApi.dismissRest()
await fetchUser()
navigate('/')
navigate('/home')
} catch {
setError('Could not save. Please try again.')
setSubmitting(null)

View File

@@ -39,7 +39,7 @@ function makeEmptyRow(): InviteRow {
*
* 1. POST `/accounts/me/invites/bulk` with populated rows.
* 2. PATCH `/users/me/onboarding-step` `{step: 3, action: "complete"}`.
* 3. Navigate to `/?welcome=true` and fire a "You're all set" toast.
* 3. Navigate to `/home` and fire a "You're all set" toast.
*
* Partial-failure UX: rows in `failed[]` keep their input and show an
* inline error. The wizard does NOT auto-advance when there are failures —
@@ -109,7 +109,7 @@ export function WelcomeStep3() {
await onboardingApi.updateStep({ step: 3, action: 'complete' })
await fetchUser()
toast.success("You're all set!")
navigate('/?welcome=true')
navigate('/home')
}
const handleSendInvites = async () => {
@@ -177,7 +177,7 @@ export function WelcomeStep3() {
await onboardingApi.updateStep({ step: 3, action: 'skip' })
await fetchUser()
toast.success("You're all set!")
navigate('/?welcome=true')
navigate('/home')
} catch {
setError('Could not save. Please try again.')
setSubmitting(null)
@@ -191,7 +191,7 @@ export function WelcomeStep3() {
try {
await onboardingApi.dismissRest()
await fetchUser()
navigate('/')
navigate('/home')
} catch {
setError('Could not save. Please try again.')
setSubmitting(null)

View File

@@ -39,7 +39,7 @@ function renderRouter() {
<Route path="/welcome/step-1" element={<div>step-1</div>} />
<Route path="/welcome/step-2" element={<div>step-2</div>} />
<Route path="/welcome/step-3" element={<div>step-3</div>} />
<Route path="/" element={<div>dashboard</div>} />
<Route path="/home" element={<div>dashboard</div>} />
</Routes>
</MemoryRouter>,
)
@@ -100,7 +100,7 @@ describe('WelcomeRouter', () => {
})
})
it('redirects to / when onboarding_step_completed >= 3', async () => {
it('redirects to /home when onboarding_step_completed >= 3', async () => {
useAuthStore.setState({
user: makeUser({ onboarding_step_completed: 3 }),
})
@@ -110,7 +110,7 @@ describe('WelcomeRouter', () => {
})
})
it('redirects to / when onboarding_dismissed is true', async () => {
it('redirects to /home when onboarding_dismissed is true', async () => {
useAuthStore.setState({
user: makeUser({
onboarding_step_completed: 1,

View File

@@ -65,7 +65,7 @@ function renderPage() {
<Routes>
<Route path="/welcome/step-1" element={<WelcomeStep1 />} />
<Route path="/welcome/step-2" element={<div>step-2</div>} />
<Route path="/" element={<div>dashboard</div>} />
<Route path="/home" element={<div>dashboard</div>} />
</Routes>
</MemoryRouter>,
)
@@ -148,7 +148,7 @@ describe('WelcomeStep1', () => {
})
})
it('Skip-the-rest dismisses and navigates to /', async () => {
it('Skip-the-rest dismisses and navigates to /home', async () => {
const user = userEvent.setup()
renderPage()

View File

@@ -66,7 +66,7 @@ function renderPage() {
<Route path="/welcome/step-2" element={<WelcomeStep2 />} />
<Route path="/welcome/step-3" element={<div>step-3</div>} />
<Route path="/account/integrations" element={<div>integrations</div>} />
<Route path="/" element={<div>dashboard</div>} />
<Route path="/home" element={<div>dashboard</div>} />
</Routes>
</MemoryRouter>,
)
@@ -158,7 +158,7 @@ describe('WelcomeStep2', () => {
expect(screen.queryByTestId('welcome-step-2-connect-now')).not.toBeInTheDocument()
})
it('Skip-the-rest dismisses and navigates to /', async () => {
it('Skip-the-rest dismisses and navigates to /home', async () => {
const user = userEvent.setup()
renderPage()

View File

@@ -88,7 +88,7 @@ function renderPage() {
<MemoryRouter initialEntries={['/welcome/step-3']}>
<Routes>
<Route path="/welcome/step-3" element={<WelcomeStep3 />} />
<Route path="/" element={<div>dashboard</div>} />
<Route path="/home" element={<div>dashboard</div>} />
</Routes>
</MemoryRouter>,
)

View File

@@ -7,6 +7,7 @@ import { RouteError } from '@/components/common/RouteError'
import { ErrorBoundary } from '@/components/common/ErrorBoundary'
import { PageLoader } from '@/components/common/PageLoader'
import { lazyWithRetry } from '@/lib/lazyWithRetry'
import { useAuthStore } from '@/store/authStore'
import { L1RouteGuard } from '@/components/layout/L1RouteGuard'
const sentryCreateBrowserRouter = Sentry.wrapCreateBrowserRouterV7(createBrowserRouter)
@@ -125,10 +126,27 @@ function page(Component: React.LazyExoticComponent<React.ComponentType>) {
)
}
/**
* Public `/` wrapper — sends authenticated users to /home before LandingPage
* mounts, so they never see marketing-frame flicker.
*/
// eslint-disable-next-line react-refresh/only-export-components -- router.tsx exports a router instance, not a component
function PublicLanding() {
const isAuthed = useAuthStore((s) => s.isAuthenticated)
if (isAuthed) return <Navigate to="/home" replace />
return page(LandingPage)
}
export const router = sentryCreateBrowserRouter([
{
path: '/',
element: <PublicLanding />,
errorElement: <RouteError />,
},
// Stale-bookmark redirect — keep one release, delete in a follow-up.
{
path: '/landing',
element: page(LandingPage),
element: <Navigate to="/" replace />,
errorElement: <RouteError />,
},
{
@@ -236,7 +254,6 @@ export const router = sentryCreateBrowserRouter([
errorElement: <RouteError />,
},
{
path: '/',
element: (
<ProtectedRoute>
<AppLayout />
@@ -244,61 +261,61 @@ export const router = sentryCreateBrowserRouter([
),
errorElement: <RouteError />,
children: [
{ index: true, element: page(QuickStartPage) },
{ path: 'trees', element: page(TreeLibraryPage) },
{ path: 'my-trees', element: page(MyTreesPage) },
{ path: 'trees/new', element: page(TreeEditorPage) },
{ path: 'trees/:id/edit', element: page(TreeEditorPage) },
{ path: 'flows/new', element: page(ProceduralEditorPage) },
{ path: 'flows/:id/edit', element: page(ProceduralEditorPage) },
{ path: 'flows/:id/navigate', element: page(ProceduralNavigationPage) },
{ path: 'flows/:id/maintenance', element: page(MaintenanceFlowDetailPage) },
{ path: 'flows/:id/batches/:batchId', element: page(BatchStatusPage) },
{ path: 'trees/:id/navigate', element: page(TreeNavigationPage) },
{ path: 'sessions', element: page(SessionHistoryPage) },
{ path: 'sessions/:id', element: page(SessionDetailPage) },
{ path: 'tickets', element: page(TicketsPage) },
{ path: 'shares', element: page(MySharesPage) },
{ path: 'analytics', element: page(TeamAnalyticsPage) },
{ path: 'analytics/me', element: page(MyAnalyticsPage) },
{ path: 'feedback', element: page(FeedbackPage) },
{ path: 'step-library', element: page(StepLibraryPage) },
{ path: 'scripts', element: page(ScriptLibraryPage) },
{ path: 'scripts/manage', element: page(ScriptManagePage) },
{ path: 'script-builder', element: page(ScriptBuilderPage) },
{ path: 'network-diagrams', element: page(NetworkDiagramsPage) },
{ path: 'network-diagrams/new', element: page(DiagramEditorPage) },
{ path: 'network-diagrams/:id', element: page(DiagramEditorPage) },
{ path: 'kb-accelerator', element: page(KBAcceleratorPage) },
{ path: '/home', element: page(QuickStartPage) },
{ path: '/trees', element: page(TreeLibraryPage) },
{ path: '/my-trees', element: page(MyTreesPage) },
{ path: '/trees/new', element: page(TreeEditorPage) },
{ path: '/trees/:id/edit', element: page(TreeEditorPage) },
{ path: '/flows/new', element: page(ProceduralEditorPage) },
{ path: '/flows/:id/edit', element: page(ProceduralEditorPage) },
{ path: '/flows/:id/navigate', element: page(ProceduralNavigationPage) },
{ path: '/flows/:id/maintenance', element: page(MaintenanceFlowDetailPage) },
{ path: '/flows/:id/batches/:batchId', element: page(BatchStatusPage) },
{ path: '/trees/:id/navigate', element: page(TreeNavigationPage) },
{ path: '/sessions', element: page(SessionHistoryPage) },
{ path: '/sessions/:id', element: page(SessionDetailPage) },
{ path: '/tickets', element: page(TicketsPage) },
{ path: '/shares', element: page(MySharesPage) },
{ path: '/analytics', element: page(TeamAnalyticsPage) },
{ path: '/analytics/me', element: page(MyAnalyticsPage) },
{ path: '/feedback', element: page(FeedbackPage) },
{ path: '/step-library', element: page(StepLibraryPage) },
{ path: '/scripts', element: page(ScriptLibraryPage) },
{ path: '/scripts/manage', element: page(ScriptManagePage) },
{ path: '/script-builder', element: page(ScriptBuilderPage) },
{ path: '/network-diagrams', element: page(NetworkDiagramsPage) },
{ path: '/network-diagrams/new', element: page(DiagramEditorPage) },
{ path: '/network-diagrams/:id', element: page(DiagramEditorPage) },
{ path: '/kb-accelerator', element: page(KBAcceleratorPage) },
// Phase 1 — FlowPilot migration. The unified chat-primary surface lives at
// /pilot; /assistant permanently redirects. FlowPilotSessionPage (old
// guided surface) is no longer mounted.
{ path: 'pilot', element: page(AssistantChatPage) },
{ path: 'pilot/:sessionId', element: page(AssistantChatPage) },
{ path: 'assistant', element: <Navigate to="/pilot" replace /> },
{ path: 'assistant/:sessionId', element: <AssistantSessionRedirect /> },
{ path: 'flow-assist', element: page(FlowAssistPage) },
{ path: 'escalations', element: page(EscalationQueuePage) },
{ path: 'queue', element: page(SessionQueuePage) },
{ path: 'review-queue', element: page(ReviewQueuePage) },
{ path: 'analytics/flowpilot', element: page(FlowPilotAnalyticsPage) },
{ path: 'dev/branching', element: page(DevBranchingPage) },
{ path: 'guides', element: page(GuidesHubPage) },
{ path: 'guides/:slug', element: page(GuideDetailPage) },
{ path: '/pilot', element: page(AssistantChatPage) },
{ path: '/pilot/:sessionId', element: page(AssistantChatPage) },
{ path: '/assistant', element: <Navigate to="/pilot" replace /> },
{ path: '/assistant/:sessionId', element: <AssistantSessionRedirect /> },
{ path: '/flow-assist', element: page(FlowAssistPage) },
{ path: '/escalations', element: page(EscalationQueuePage) },
{ path: '/queue', element: page(SessionQueuePage) },
{ path: '/review-queue', element: page(ReviewQueuePage) },
{ path: '/analytics/flowpilot', element: page(FlowPilotAnalyticsPage) },
{ path: '/dev/branching', element: page(DevBranchingPage) },
{ path: '/guides', element: page(GuidesHubPage) },
{ path: '/guides/:slug', element: page(GuideDetailPage) },
// Welcome wizard (Phase 2). Mounted inside AppLayout so the email-
// verification banner persists above each step.
{ path: 'welcome', element: page(WelcomeRouter) },
{ path: 'welcome/step-1', element: page(WelcomeStep1) },
{ path: 'welcome/step-2', element: page(WelcomeStep2) },
{ path: 'welcome/step-3', element: page(WelcomeStep3) },
{ path: '/welcome', element: page(WelcomeRouter) },
{ path: '/welcome/step-1', element: page(WelcomeStep1) },
{ path: '/welcome/step-2', element: page(WelcomeStep2) },
{ path: '/welcome/step-3', element: page(WelcomeStep3) },
// L1 workspace routes — gated by canUseL1Surface
{ path: 'l1', element: <L1RouteGuard>{page(L1Dashboard)}</L1RouteGuard> },
{ path: 'l1/walk/:sessionId', element: <L1RouteGuard>{page(L1WalkPage)}</L1RouteGuard> },
{ path: 'l1/drafts', element: <L1RouteGuard>{page(L1DraftsPage)}</L1RouteGuard> },
{ path: 'l1/tickets', element: <L1RouteGuard>{page(L1TicketsPage)}</L1RouteGuard> },
{ path: '/l1', element: <L1RouteGuard>{page(L1Dashboard)}</L1RouteGuard> },
{ path: '/l1/walk/:sessionId', element: <L1RouteGuard>{page(L1WalkPage)}</L1RouteGuard> },
{ path: '/l1/drafts', element: <L1RouteGuard>{page(L1DraftsPage)}</L1RouteGuard> },
{ path: '/l1/tickets', element: <L1RouteGuard>{page(L1TicketsPage)}</L1RouteGuard> },
// Admin routes
{
path: 'admin',
path: '/admin',
element: (
<ErrorBoundary>
<Suspense fallback={<PageLoader />}>
@@ -327,7 +344,7 @@ export const router = sentryCreateBrowserRouter([
},
// Account routes
{
path: 'account',
path: '/account',
element: (
<ErrorBoundary>
<Suspense fallback={<PageLoader />}>

View File

@@ -7,7 +7,7 @@
/* ---- LANDING COLOR PALETTE ---- */
.landing-page {
--lp-bg: #14161d;
--lp-bg-alt: #181a22;
--lp-bg-alt: #1c1f2a;
--lp-card: #1e2028;
--lp-elevated: #262830;
--lp-border: #2a2e3a;
@@ -23,14 +23,24 @@
--lp-success: #34d399;
--lp-danger: #f87171;
--lp-warning: #fbbf24;
/* Typeset: a single hyperlegibility-engineered family across the page.
Atkinson Hyperlegible Next (Braille Institute, 2024) — designed for
low-vision readers. Picked here because MSP engineers read this page
mid-ticket, under pressure, often glancing. Hyperlegibility IS the
brand value, not decoration. Mono sibling pairs naturally for
timestamps and ticket IDs. */
--lp-font-display: 'Atkinson Hyperlegible Next', system-ui, sans-serif;
--lp-font-body: 'Atkinson Hyperlegible Next', system-ui, sans-serif;
--lp-font-mono: 'Atkinson Hyperlegible Mono', ui-monospace, monospace;
}
/* ---- BASE ---- */
.landing-page {
font-family: 'IBM Plex Sans', sans-serif;
font-family: var(--lp-font-body);
background: var(--lp-bg);
color: var(--lp-text-body);
line-height: 1.6;
line-height: 1.55;
overflow-x: hidden;
-webkit-font-smoothing: antialiased;
min-height: 100vh;
@@ -110,7 +120,7 @@
}
.landing-nav-wordmark {
font-family: 'Bricolage Grotesque', sans-serif;
font-family: var(--lp-font-display);
font-size: 1.25rem;
font-weight: 700;
color: var(--lp-text-heading);
@@ -189,6 +199,14 @@
padding: 5rem 2rem;
}
.landing-section-tight {
padding: 4rem 2rem 5rem;
}
.landing-section-generous {
padding: 7rem 2rem 6rem;
}
.landing-section-alt {
background: var(--lp-bg-alt);
}
@@ -199,24 +217,36 @@
}
.landing-section-label {
font-family: 'IBM Plex Sans', sans-serif;
font-size: 0.7rem;
font-weight: 600;
color: var(--lp-accent-text);
letter-spacing: 0.14em;
display: flex;
align-items: center;
gap: 0.9rem;
font-family: var(--lp-font-display);
font-size: 0.9rem;
font-weight: 700;
color: var(--lp-accent);
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 0.75rem;
margin-bottom: 1.25rem;
}
.landing-section-label::before {
content: '';
width: 40px;
height: 2px;
background: var(--lp-accent);
flex-shrink: 0;
}
.landing-section-title {
font-family: 'Bricolage Grotesque', sans-serif;
font-size: clamp(2rem, 4vw, 2.75rem);
font-family: var(--lp-font-display);
font-size: clamp(2rem, 4.5vw, 3.25rem);
font-weight: 800;
color: var(--lp-text-heading);
letter-spacing: -0.03em;
line-height: 1.15;
letter-spacing: -0.035em;
line-height: 1.05;
margin-top: 0;
margin-bottom: 1rem;
max-width: 22ch;
}
.landing-section-desc {
@@ -251,8 +281,8 @@
position: absolute;
inset: 0;
background:
linear-gradient(to right, #14161d 22%, rgba(20, 22, 29, 0.80) 38%, rgba(20, 22, 29, 0.20) 58%, transparent 78%),
linear-gradient(to top, #14161d 0%, rgba(20, 22, 29, 0) 16%);
linear-gradient(to right, #14161d 0%, #14161d 38%, rgba(20, 22, 29, 0.92) 52%, rgba(20, 22, 29, 0.35) 72%, transparent 92%),
linear-gradient(to top, #14161d 0%, rgba(20, 22, 29, 0.4) 24%, rgba(20, 22, 29, 0) 48%);
z-index: 1;
}
@@ -300,7 +330,7 @@
}
.landing-hero h1 {
font-family: 'Bricolage Grotesque', sans-serif;
font-family: var(--lp-font-display);
font-size: clamp(2.5rem, 5vw, 3.75rem);
font-weight: 800;
line-height: 1.08;
@@ -447,7 +477,7 @@
font-size: 0.65rem;
font-weight: 700;
color: var(--lp-text-secondary);
font-family: 'IBM Plex Sans', sans-serif;
font-family: var(--lp-font-body);
}
.tc-status {
@@ -531,7 +561,7 @@
.tc-time {
font-size: 0.55rem;
color: var(--lp-text-dim);
font-family: 'JetBrains Mono', monospace;
font-family: var(--lp-font-mono);
flex-shrink: 0;
min-width: 28px;
}
@@ -601,7 +631,7 @@
align-items: flex-start;
gap: 8px;
font-size: 0.65rem;
font-family: 'IBM Plex Sans', sans-serif;
font-family: var(--lp-font-body);
padding: 6px 10px;
border-radius: 6px;
}
@@ -641,7 +671,7 @@
}
.landing-mock-chat-line code {
font-family: 'JetBrains Mono', monospace;
font-family: var(--lp-font-mono);
font-size: 0.6rem;
padding: 1px 5px;
border-radius: 3px;
@@ -708,142 +738,172 @@
color: var(--lp-success);
}
/* ---- PROBLEM SECTION (asymmetric) ---- */
/* ---- PROBLEM SECTION (editorial, no cards) ---- */
.landing-problem-layout {
display: grid;
grid-template-columns: 2fr 3fr;
gap: 3rem;
grid-template-columns: 5fr 7fr;
gap: clamp(2.5rem, 6vw, 6rem);
align-items: start;
}
.landing-problem-headline {
position: sticky;
top: 6rem;
}
.landing-problem-headline h2 {
font-family: 'Bricolage Grotesque', sans-serif;
font-size: clamp(1.75rem, 3.5vw, 2.5rem);
font-family: var(--lp-font-display);
font-size: clamp(2rem, 4vw, 3rem);
font-weight: 800;
color: var(--lp-text-heading);
letter-spacing: -0.03em;
line-height: 1.15;
margin: 0 0 1rem;
letter-spacing: -0.035em;
line-height: 1.05;
margin: 0 0 1.25rem;
}
.landing-problem-headline > p {
font-size: 1rem;
font-size: 1.05rem;
color: var(--lp-text-secondary);
line-height: 1.7;
line-height: 1.65;
max-width: 36ch;
}
.landing-problem-grid {
.landing-problem-list {
list-style: none;
margin: 0;
padding: 0;
}
.landing-problem-item {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
grid-template-columns: 4.5rem 1fr;
column-gap: 1.25rem;
padding: 2rem 0;
border-top: 1px solid var(--lp-border);
}
.landing-problem-card {
padding: 1.25rem;
border-radius: 8px;
background: var(--lp-card);
border: 1px solid var(--lp-border);
transition: border-color 0.3s;
.landing-problem-item:last-child {
border-bottom: 1px solid var(--lp-border);
}
.landing-problem-card:hover {
border-color: var(--lp-border-hover);
.landing-problem-num {
font-family: var(--lp-font-display);
font-size: 0.85rem;
font-weight: 700;
color: var(--lp-accent);
letter-spacing: 0.08em;
padding-top: 0.65rem;
}
.landing-problem-icon {
width: 36px;
height: 36px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 0.75rem;
font-size: 1.1rem;
}
.landing-problem-icon.red {
background: rgba(248, 113, 113, 0.1);
color: var(--lp-danger);
}
.landing-problem-icon.amber {
background: rgba(251, 191, 36, 0.1);
color: var(--lp-warning);
}
.landing-problem-icon.slate {
background: rgba(145, 152, 168, 0.1);
color: var(--lp-text-secondary);
}
.landing-problem-icon.violet {
background: rgba(139, 92, 246, 0.1);
color: #a78bfa;
}
.landing-problem-card h3 {
font-family: 'Bricolage Grotesque', sans-serif;
font-size: 0.95rem;
.landing-problem-body h3 {
font-family: var(--lp-font-display);
font-size: clamp(1.35rem, 2.5vw, 1.85rem);
font-weight: 700;
color: var(--lp-text-heading);
margin-bottom: 0.4rem;
letter-spacing: -0.01em;
letter-spacing: -0.025em;
line-height: 1.15;
margin: 0 0 0.6rem;
}
.landing-problem-card p {
font-size: 0.8rem;
.landing-problem-body p {
font-size: 0.95rem;
color: var(--lp-text-secondary);
line-height: 1.6;
line-height: 1.65;
margin: 0;
max-width: 52ch;
}
/* ---- EQUATION ---- */
/* ---- EQUATION (hero-scale typographic moment) ---- */
.landing-equation-section {
text-align: center;
padding: 4rem 2rem;
text-align: left;
padding: 9rem 2rem 8rem;
position: relative;
overflow: hidden;
}
.landing-equation-section::before {
content: '';
position: absolute;
inset: 0;
background:
radial-gradient(60% 80% at 78% 30%, rgba(96, 165, 250, 0.10), transparent 70%);
pointer-events: none;
z-index: 0;
}
.landing-equation-inner {
max-width: 900px;
max-width: 1200px;
margin: 0 auto;
position: relative;
z-index: 1;
}
.landing-brand-equation {
font-family: var(--lp-font-display);
font-weight: 800;
letter-spacing: -0.04em;
line-height: 0.95;
margin: 1.5rem 0 2rem;
}
.landing-eq-lhs {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
align-items: baseline;
flex-wrap: wrap;
font-family: 'Bricolage Grotesque', sans-serif;
font-size: clamp(1.25rem, 3vw, 2.25rem);
font-weight: 700;
letter-spacing: -0.02em;
margin-bottom: 1.5rem;
gap: 0.5em;
font-size: clamp(1.5rem, 4vw, 3rem);
color: var(--lp-text-secondary);
margin-bottom: 0.4rem;
}
.landing-eq-item {
padding: 0.4rem 1rem;
border-radius: 8px;
background: var(--lp-card);
border: 1px solid var(--lp-border);
color: var(--lp-text-heading);
}
.landing-eq-operator {
color: var(--lp-accent);
font-size: 1.5em;
font-weight: 600;
}
.landing-eq-equals {
font-size: clamp(2rem, 5vw, 4rem);
color: var(--lp-accent);
line-height: 1;
margin: 0.1em 0 0.05em;
}
.landing-eq-operator-equals {
display: inline-block;
}
.landing-eq-result {
color: var(--lp-accent-text);
font-size: clamp(2.25rem, 11vw, 9.5rem);
font-weight: 800;
color: var(--lp-text-heading);
letter-spacing: -0.055em;
line-height: 0.92;
display: inline-block;
position: relative;
padding-bottom: 0.05em;
max-width: 100%;
}
.landing-eq-result::after {
content: '';
position: absolute;
left: 0;
right: 14%;
bottom: 0.02em;
height: 0.12em;
background: linear-gradient(to right, var(--lp-accent), rgba(96, 165, 250, 0));
}
.landing-equation-desc {
font-size: 1.05rem;
font-size: 1.1rem;
color: var(--lp-text-secondary);
max-width: 480px;
margin: 0 auto;
line-height: 1.7;
max-width: 520px;
margin: 2rem 0 0;
line-height: 1.6;
}
/* ---- HOW IT WORKS (zigzag) ---- */
@@ -872,7 +932,7 @@
}
.landing-zigzag-number {
font-family: 'Bricolage Grotesque', sans-serif;
font-family: var(--lp-font-display);
font-size: 0.75rem;
font-weight: 700;
color: var(--lp-accent);
@@ -881,7 +941,7 @@
}
.landing-zigzag-text h3 {
font-family: 'Bricolage Grotesque', sans-serif;
font-family: var(--lp-font-display);
font-size: 1.5rem;
font-weight: 700;
color: var(--lp-text-heading);
@@ -907,100 +967,79 @@
background: var(--lp-bg-alt);
}
/* ---- FEATURES ---- */
/* ---- FEATURES (editorial spec list) ---- */
.landing-feature-highlight {
display: flex;
align-items: flex-start;
gap: 1.5rem;
padding: 2rem;
border-radius: 8px;
background: var(--lp-accent-soft);
border: 1px solid rgba(96, 165, 250, 0.15);
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 1.75rem;
padding: 2.25rem 2rem;
border-radius: 0;
background: transparent;
border: none;
border-top: 1px solid var(--lp-accent);
border-bottom: 1px solid var(--lp-border);
margin-top: 2.5rem;
margin-bottom: 1.5rem;
margin-bottom: 0;
}
.landing-feature-highlight-icon {
width: 52px;
height: 52px;
border-radius: 8px;
background: rgba(96, 165, 250, 0.15);
display: flex;
align-items: center;
justify-content: center;
.landing-feature-highlight-marker {
font-family: var(--lp-font-display);
font-size: 2.25rem;
font-weight: 800;
color: var(--lp-accent);
flex-shrink: 0;
letter-spacing: -0.04em;
line-height: 1;
padding-right: 1.75rem;
border-right: 1px solid var(--lp-border);
}
.landing-feature-highlight-content h3 {
font-family: 'Bricolage Grotesque', sans-serif;
font-size: 1.35rem;
font-family: var(--lp-font-display);
font-size: clamp(1.35rem, 2.4vw, 1.65rem);
font-weight: 700;
color: var(--lp-text-heading);
letter-spacing: -0.01em;
margin-bottom: 0.5rem;
letter-spacing: -0.02em;
margin: 0 0 0.4rem;
}
.landing-feature-highlight-content p {
font-size: 0.95rem;
font-size: 1rem;
color: var(--lp-text-secondary);
line-height: 1.65;
margin: 0;
max-width: 68ch;
}
.landing-features-grid {
.landing-feature-spec {
margin: 0;
padding: 0;
}
.landing-feature-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
grid-template-columns: minmax(180px, 24%) 1fr;
column-gap: 3rem;
align-items: baseline;
padding: 1.75rem 0;
border-bottom: 1px solid var(--lp-border);
}
.landing-feature-card {
padding: 1.5rem;
border-radius: 8px;
background: var(--lp-card);
border: 1px solid var(--lp-border);
transition: border-color 0.3s;
}
.landing-feature-card:hover {
border-color: var(--lp-border-hover);
}
.landing-feature-icon {
width: 36px;
height: 36px;
border-radius: 8px;
background: var(--lp-accent-soft);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1rem;
color: var(--lp-accent-text);
}
.landing-feature-card h3 {
font-family: 'Bricolage Grotesque', sans-serif;
font-size: 1rem;
.landing-feature-row dt {
font-family: var(--lp-font-display);
font-size: clamp(1.15rem, 2vw, 1.5rem);
font-weight: 700;
color: var(--lp-text-heading);
margin-bottom: 0.4rem;
letter-spacing: -0.01em;
letter-spacing: -0.02em;
line-height: 1.15;
}
.landing-feature-card p {
font-size: 0.85rem;
.landing-feature-row dd {
font-size: 1rem;
color: var(--lp-text-secondary);
line-height: 1.6;
line-height: 1.65;
margin: 0;
}
/* 5 cards: 3 + 2 bottom row centered */
.landing-features-grid .landing-feature-card:nth-child(4) {
grid-column: 1 / 2;
}
.landing-features-grid .landing-feature-card:nth-child(5) {
grid-column: 2 / 3;
max-width: 62ch;
}
/* ---- PRICING ---- */
@@ -1048,7 +1087,7 @@
}
.landing-pricing-plan-name {
font-family: 'Bricolage Grotesque', sans-serif;
font-family: var(--lp-font-display);
font-size: 1.1rem;
font-weight: 700;
color: var(--lp-text-heading);
@@ -1069,7 +1108,7 @@
}
.landing-pricing-price .amount {
font-family: 'Bricolage Grotesque', sans-serif;
font-family: var(--lp-font-display);
font-size: 2.5rem;
font-weight: 800;
color: var(--lp-text-heading);
@@ -1199,7 +1238,7 @@
border: none;
cursor: pointer;
text-align: left;
font-family: 'IBM Plex Sans', sans-serif;
font-family: var(--lp-font-body);
font-size: 1rem;
font-weight: 600;
color: var(--lp-text-heading);
@@ -1255,7 +1294,7 @@
}
.landing-founder-section blockquote {
font-family: 'Bricolage Grotesque', sans-serif;
font-family: var(--lp-font-display);
font-size: 1.25rem;
font-weight: 500;
line-height: 1.6;
@@ -1271,32 +1310,125 @@
padding-left: 1.25rem;
}
/* ---- CTA ---- */
/* ---- CTA (drenched) ---- */
.landing-cta-section {
text-align: center;
padding: 5rem 2rem;
padding: 6rem 2rem;
background: var(--lp-bg-alt);
}
.landing-cta-section.landing-cta-drench {
background: var(--lp-accent);
color: #0a1430;
padding: 7rem 2rem;
position: relative;
overflow: hidden;
}
.landing-cta-section.landing-cta-drench::before {
content: '';
position: absolute;
inset: 0;
background:
radial-gradient(80% 60% at 100% 0%, rgba(255, 255, 255, 0.18), transparent 60%),
radial-gradient(60% 80% at 0% 100%, rgba(13, 15, 21, 0.18), transparent 60%);
pointer-events: none;
}
.landing-cta-inner {
max-width: 520px;
max-width: 1100px;
margin: 0 auto;
position: relative;
z-index: 1;
}
.landing-cta-eyebrow {
display: flex;
align-items: center;
gap: 0.9rem;
font-family: var(--lp-font-display);
font-size: 0.9rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #0a1430;
margin-bottom: 1.5rem;
}
.landing-cta-eyebrow::before {
content: '';
width: 40px;
height: 2px;
background: #0a1430;
flex-shrink: 0;
opacity: 0.55;
}
.landing-cta-section h2 {
font-family: 'Bricolage Grotesque', sans-serif;
font-size: clamp(1.75rem, 3.5vw, 2.5rem);
font-family: var(--lp-font-display);
font-size: clamp(2.25rem, 5vw, 4rem);
font-weight: 800;
color: var(--lp-text-heading);
letter-spacing: -0.03em;
margin-bottom: 0.75rem;
color: #0a1430;
letter-spacing: -0.04em;
line-height: 1.02;
margin: 0 0 1.25rem;
max-width: 22ch;
}
.landing-cta-section h2 + p {
font-size: 1.15rem;
color: #0a1430;
opacity: 0.78;
margin-bottom: 2.5rem;
line-height: 1.55;
max-width: 44ch;
}
.landing-cta-actions {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.landing-btn-cta-invert {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1rem;
color: var(--lp-text-secondary);
margin-bottom: 2rem;
line-height: 1.7;
font-weight: 600;
color: var(--lp-accent);
text-decoration: none;
padding: 0.95rem 2rem;
border-radius: 8px;
background: #ffffff;
transition: transform 0.25s ease-out, box-shadow 0.25s ease-out;
letter-spacing: -0.01em;
box-shadow: 0 1px 0 rgba(13, 15, 21, 0.08);
}
.landing-btn-cta-invert:hover {
transform: translateY(-2px);
box-shadow: 0 12px 28px rgba(10, 20, 48, 0.25);
}
.landing-btn-cta-ghost {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1rem;
font-weight: 600;
color: #0a1430;
text-decoration: none;
padding: 0.95rem 1.5rem;
border-radius: 8px;
border: 1px solid rgba(10, 20, 48, 0.35);
background: transparent;
transition: background 0.25s, border-color 0.25s;
}
.landing-btn-cta-ghost:hover {
background: rgba(10, 20, 48, 0.06);
border-color: rgba(10, 20, 48, 0.55);
}
.landing-cta-email-form {
@@ -1317,7 +1449,7 @@
border: 1px solid var(--lp-border);
background: var(--lp-card);
color: var(--lp-text-heading);
font-family: 'IBM Plex Sans', sans-serif;
font-family: var(--lp-font-body);
font-size: 0.9rem;
outline: none;
transition: border-color 0.3s, box-shadow 0.3s;
@@ -1605,8 +1737,18 @@
gap: 2rem;
}
.landing-problem-grid {
grid-template-columns: 1fr;
.landing-problem-headline {
position: static;
}
.landing-problem-item {
grid-template-columns: 3rem 1fr;
column-gap: 1rem;
padding: 1.5rem 0;
}
.landing-problem-num {
padding-top: 0.5rem;
}
.landing-zigzag {
@@ -1628,19 +1770,22 @@
order: 2;
}
.landing-features-grid {
grid-template-columns: 1fr;
}
.landing-features-grid .landing-feature-card:nth-child(4),
.landing-features-grid .landing-feature-card:nth-child(5) {
grid-column: auto;
}
.landing-feature-highlight {
flex-direction: column;
grid-template-columns: 1fr;
gap: 1rem;
padding: 1.5rem;
padding: 1.5rem 0;
}
.landing-feature-highlight-marker {
padding-right: 0;
border-right: none;
font-size: 1.75rem;
}
.landing-feature-row {
grid-template-columns: 1fr;
row-gap: 0.6rem;
padding: 1.5rem 0;
}
.landing-pricing-grid {
@@ -1669,16 +1814,8 @@
}
.landing-equation-section {
padding: 3rem 1.25rem;
}
.landing-brand-equation {
font-size: 1.1rem;
gap: 0.4rem;
}
.landing-eq-item {
padding: 0.3rem 0.6rem;
padding: 5rem 1.25rem 4.5rem;
text-align: left;
}
.landing-founder-section {