SPA vs Actual UI — Frontend Drift
Audience: anyone touching upsquad-client UI, or wondering why a feature they merged never showed up on beta-dev.
Goal: understand that we run two frontends of the same product, why that causes silent drift, and the CI gate that now catches it.
Status legend: 🟢 LIVE (in the prod code path) · 🟡 STUBBED/partial · 🔵 DEV-ONLY (beta-dev/docker) · 🟠 PENDING/PARKED (designed, gated off, or blocked).
Changelog since 2026-07-06 (imported from Knowledge-Base Google Doc)
- Drift is now guarded, not just documented. A per-PR frontend-parity gate exists (founder directive 2026-07-13, audit
upsquad-client#564):frontend-parity.yml+scripts/check-frontend-parity.sh+parity-manifest.tsv. The base doc predated it and only described the problem. 🟢 - Decision recorded: validate SPA↔portal parity (one-time audit + per-PR CI check) — do not consolidate the two frontends now; the GCP backend migration collapses them later. Ships as a stopgap, explicitly marked for retirement.
- Grounded the "we got bitten" story in the actual audit finding rather than the ADR-0022 wave framing (see below).
1. The two frontends (verified)
upsquad-client builds two complete, separate frontends. Base claim confirmed against the repo:
The SPA — app/ | The Next portal — src/ | |
|---|---|---|
| Tech | Vite + React (single-page app) | Next.js (server-rendered) |
| Served at | beta-dev.app.upsquad.ai (docker + host Envoy + Cloudflare tunnel) 🟢 | app.upsquad.ai (Next container) 🟠 |
| Auth | Compile-time VITE_DEV_MODE bypass + persona switcher (Clerk in prod builds) | Clerk (real sign-in, signup webhooks) |
| Backend | Live adapter → /rpc → context-engine (+ mock mode for demos) | Same RPCs, own generated stubs |
| Who uses it | You, daily — the demo + staff surface | Effectively nobody — Clerk-gated, no one signs in |
Which one is deployed and real: the app/ Vite SPA is the founder's live surface on beta-dev (docker stack, not GCP — see the beta-dev topology note). The src/ Next portal targets app.upsquad.ai but is not in daily use. Both frontend-parity.yml and parity-manifest.tsv state this in their own headers.
How it got this way (per ADR-0016, "Demo-Fork Frontend Strategy", accepted 2026-06-10): demo/ (a mock-only Vite SPA, the approved look) was forked to app/ and live-wired until it became the working product. The Next portal was overhauled to match structurally but never entered daily use. ADR-0016 amendment (2026-06-12): app.upsquad.ai cutover is a founder-triggered DNS re-point; the Next portal parks at legacy.app.upsquad.ai, deferred-not-deleted.
2. Why it's a problem (we already got bitten)
The drift is not theoretical. During a frontend wave the work was split by anchoring each page to wherever its "real" code already lived — some features landed in the SPA, others in the portal. The portal-side ones merged, deployed, and were invisible on beta-dev, because the live surface is the SPA. They then had to be re-ported into the SPA. Net effect: the team-gateway, agent-tool-access, and guardrail surfaces exist twice, in two codebases, with two sets of generated stubs. Audit #564 catalogued the divergences.
The failure mode is silent: nothing is red, CI is green, the PR merges — the feature just never appears where the founder looks.
3. Current mitigation — the per-PR parity gate 🟢
Rather than consolidate now (deferred to the GCP migration), we guard the two-frontend state with a CI gate. Three artifacts, all in upsquad-client:
| Artifact | Path | Role |
|---|---|---|
| Gate workflow | .github/workflows/frontend-parity.yml | Fires when either frontend, the manifest, or the script changes (paths: app/**, src/**, manifest, script). |
| Skip-twin | .github/workflows/frontend-parity-skip.yml | Reports the same frontend-parity check on out-of-path PRs so it ALWAYS reports and can be a required status check (mirrors the app-ci / docker-build skip-twin pattern, core#654). |
| Parity map | parity-manifest.tsv | Tab-separated: feature-key ⇥ app-glob ⇥ src-glob ⇥ flag. |
| Gate logic | scripts/check-frontend-parity.sh | Diffs PR changed files against the manifest. |
How it decides (per PR): for each gated row it computes appHit / srcHit. Touching both sides → fine. Touching neither → fine. Touching one side → VIOLATION and the PR fails — unless the PR body carries a waiver line:
parity-waiver: <feature-key> — <reason>
(The gate re-runs on PR-body edited events so adding a waiver clears the failure. The escape hatch mirrors the demo-sync-provenance provenance line.) Files matched by a grandfathered row are exempt — the gate blocks new drift only, it does not force back-filling the pre-existing gaps.
Current manifest state (verified):
| flag | feature-keys |
|---|---|
gated (parity enforced) | team-gateway, agent-tool-access, approvals-triage |
grandfathered (exempt, known gaps) | mcp-registry, pending-binding-approval, server-edit, manager-inbox, guardrail-backend-fork |
🟡 Doc-vs-manifest nit: the workflow/script headers describe "four pre-existing #564 gaps", but the manifest carries five
grandfatheredrows. The gate behaves off the manifest (five), so this is a stale comment, not a functional bug — worth a one-line fix on the next touch.
Remediating a gap = land the missing side, then flip that row from grandfathered to gated (or delete it) in the same PR.
4. Long-term resolution — the GCP backend migration
The parity gate is explicitly a stopgap ("retire it once the two frontends are unified" — manifest header). The permanent fix is the GCP backend migration: per ADR-0016's 2026-06-12 amendment, the SPA dist is served same-origin from GCS + CDN behind the same load balancer as the Cloud Run API in a GCP spoke — one origin, no CORS seam, no web-server container. SPA releases promote by release prefix (spa-releases/<sha>/), byte-identical, per ADR-0017. Once operations move to GCP, the Next portal's reason-to-exist (server-rendered auth surface) collapses into the same-origin SPA and the drift class is structurally gone; the Next portal retires from legacy.app.upsquad.ai.
Until then: land user-facing features on both frontends in the same PR, or file a waiver with the reason. Don't assume a portal-only merge is visible on beta-dev — it isn't.
5. Source map
- Gate:
.github/workflows/{frontend-parity,frontend-parity-skip}.yml,scripts/check-frontend-parity.sh,parity-manifest.tsv(all inupsquad-client). - SPA:
upsquad-client/app/(Vite). Portal:upsquad-client/src/(Next.js). Design source:upsquad-client/demo/. - Decisions: ADR-0016 (demo-fork frontend strategy + 2026-06-12 serving amendment), ADR-0017 (staged promotion). Audit + directive:
upsquad-client#564(OPEN).
Reflects upsquad-core@9858af5f (2026-07-24). If the GCP migration lands or the manifest flips grandfathered→gated, update §3–§4.