From 294f82ae0cad2317941062d6ffbafcbbf3f779e0 Mon Sep 17 00:00:00 2001 From: d3v07 <90106942+d3v07@users.noreply.github.com> Date: Sun, 24 May 2026 14:39:29 -0400 Subject: [PATCH 01/21] =?UTF-8?q?test:=20integration=20coverage=20for=20se?= =?UTF-8?q?ed=20=E2=86=92=20ChangeReportRepository=20boot=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Copilot's only review finding on PR #11: the boot path that wires loadSeeds() → buildApp({ seedChangeReports: getSeededChangeReports() }) had no automated test. Without this, the regression that PR #11 fixed (lifecycle endpoints 404'ing on every seeded change id) could silently reappear because: - changes.lifecycle.test.ts seeds its own in-memory repo (doesn't touch loadSeeds or getSeededChangeReports) - billing.test.ts uses loadSeeds for org/user data but not change reports - the --seed CLI integration test only covers createDevSeed() New file: tests/api/seed-boot.test.ts (3 tests) 1. acknowledge succeeds on a seeded id when seedChangeReports is passed 2. acknowledge returns 404 when seedChangeReports is omitted (regression guard — confirms the boot wiring is the only path keeping data flowing) 3. getSeededChangeReports() exposes the parsed seed contents Full suite: 101/101 passing (was 98) · typecheck clean. --- tests/api/seed-boot.test.ts | 62 +++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 tests/api/seed-boot.test.ts diff --git a/tests/api/seed-boot.test.ts b/tests/api/seed-boot.test.ts new file mode 100644 index 0000000..aefd47a --- /dev/null +++ b/tests/api/seed-boot.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { resolve } from "node:path"; +import { buildApp } from "../../apps/api/src/server.js"; +import { + loadSeeds, + getSeededChangeReports, +} from "../../apps/api/src/seed/loader.js"; + +// Boot-path coverage for the fix in PR #11: +// loadSeeds() populates the legacy ChangeReportStore; buildApp() must +// hydrate the canonical ChangeReportRepository from the same data so +// lifecycle endpoints find seeded ids instead of returning 404. +// +// Without this test, the regression Copilot flagged (lifecycle 404 on +// every seeded change) could reappear silently. + +const BEARER = "Bearer demo_token_acme_corp_2026"; +const SEED_DIR = resolve(__dirname, "../../seed"); +const SEED_CHANGE_ID = "chg_seed_notion"; + +beforeEach(() => { + loadSeeds({ seedDir: SEED_DIR }); +}); + +describe("seed boot path hydrates ChangeReportRepository", () => { + it("acknowledge succeeds on a seeded change id", async () => { + const app = buildApp({ seedChangeReports: getSeededChangeReports() }); + + const res = await app.request(`/v1/changes/${SEED_CHANGE_ID}/acknowledge`, { + method: "POST", + headers: { Authorization: BEARER, "Content-Type": "application/json" }, + body: JSON.stringify({ note: "Reviewed by vendor owner" }), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { id: string; state: string; acknowledgedAt: string }; + expect(body.id).toBe(SEED_CHANGE_ID); + expect(body.state).toBe("acknowledged"); + expect(body.acknowledgedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + it("returns 404 when seed reports are NOT hydrated (regression guard)", async () => { + // Build WITHOUT seeding the canonical repo — this is the pre-fix behaviour + // and must always be a 404, not a 500. Confirms the boot wiring is the + // only thing standing between the seed data and the lifecycle routes. + const app = buildApp(); // no seedChangeReports → empty repo + + const res = await app.request(`/v1/changes/${SEED_CHANGE_ID}/acknowledge`, { + method: "POST", + headers: { Authorization: BEARER, "Content-Type": "application/json" }, + body: JSON.stringify({ note: "test" }), + }); + + expect(res.status).toBe(404); + }); + + it("getSeededChangeReports exposes the parsed seed contents", () => { + const reports = getSeededChangeReports(); + expect(reports.length).toBeGreaterThan(0); + expect(reports.some((r) => r.id === SEED_CHANGE_ID)).toBe(true); + }); +}); From 8b5bba63ea5e249b1ecaf7ab4772cdd2a62ca760 Mon Sep 17 00:00:00 2001 From: d3v07 <90106942+d3v07@users.noreply.github.com> Date: Sun, 24 May 2026 14:52:26 -0400 Subject: [PATCH 02/21] chore: apply changes --- BUILD.md | 346 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 346 insertions(+) create mode 100644 BUILD.md diff --git a/BUILD.md b/BUILD.md new file mode 100644 index 0000000..dfb0cc6 --- /dev/null +++ b/BUILD.md @@ -0,0 +1,346 @@ +# BUILD.md — Multi-Agent Execution Plan + +**Spec:** [plan.md](plan.md) (locked, do not re-debate during build). +**Branch:** `saasb2b` · **Mode:** parallel where safe, sequential where required. + +--- + +## Overview + +Five waves. **Strict file-ownership boundaries** prevent agent collisions +(no two agents touch the same file in the same wave). **Hard gates** between +waves: `pnpm typecheck && pnpm test` must be green before the next wave starts. +**Max 3 concurrent agents per wave** (global rule: coordination cost dominates beyond that). + +``` +Wave 1 Foundation ───3 agents in parallel───► GATE: typecheck + grep clean +Wave 2 App shell ───1 agent (sequential)──► GATE: shell renders, body.app-mode toggles +Wave 3 Screens ───3 agents in parallel───► GATE: each route renders w/ seed data +Wave 4 Cross-cutting ───2 agents in parallel───► GATE: ⌘K + role param both work +Wave 5 Polish + review ───1 build + 1 reviewer───► GATE: AA clean, PR ready +``` + +--- + +## Architecture Changes (file ownership matrix) + +| Path | Owner agent | Wave | +|---|---|---| +| `apps/web/src/styles/tokens.css` (new) | A | 1 | +| `apps/web/src/styles/app.css` (new) | A | 1 | +| `apps/web/src/styles.css` (delete) | A | 1 | +| `apps/api/src/db/change-reports.ts` (delete) | B | 1 | +| `apps/api/src/db/changeReports.ts` | B | 1 | +| `apps/api/src/seed/loader.ts` | B | 1 | +| `apps/api/src/routes/changes.ts` | B | 1 | +| `public/app/**/*` (delete all) | C | 1 | +| `apps/web/public/app/**/*` (delete all) | C | 1 | +| Global "Unsyphn" → "Redline" (grep -ril) | C | 1 | +| `apps/web/src/App.tsx` | D | 2 | +| `apps/web/index.html` | D | 2 | +| `apps/web/src/main.tsx` | D | 2 | +| `apps/web/src/lib/role.ts` (new) | D | 2 | +| `apps/web/src/screens/Portfolio.tsx` (new) | E | 3 | +| `apps/web/src/components/VendorCard.tsx` (new) | E | 3 | +| `apps/web/src/components/FleetStats.tsx` (new) | E | 3 | +| `apps/web/src/screens/ChangeReport.tsx` (new) | F | 3 | +| `apps/web/src/components/Drawer.tsx` (new) | F | 3 | +| `apps/web/src/components/SeverityBadge.tsx` (new) | F | 3 | +| `apps/web/src/screens/SensoBrief.tsx` (restyle) | G | 3 | +| `apps/web/src/screens/StripeModal.tsx` (UX-1 fix) | G | 3 | +| `apps/web/src/screens/Onboarding.tsx` (new, replaces VendorOnboarding) | G | 3 | +| `apps/api/src/routes/evidence.ts` (add `/bundle.html`) | G | 3 | +| `apps/web/src/components/CommandPalette.tsx` (new) | H | 4 | +| `apps/web/src/lib/keyboard.ts` (new) | H | 4 | +| `apps/web/src/components/RoleSwitcher.tsx` (new) | I | 4 | +| `apps/web/src/lib/role.ts` (extend) | I | 4 | +| `PAGE_AUDIT.md` (refresh) | J | 5 | + +**No path appears under more than one agent in the same wave.** +Cross-wave reuse is fine because of hard gates. + +--- + +## Implementation Phases + +### Wave 1 — Foundation (3 parallel) + +Run all three in one message. None depends on the others. Each has a tight, +self-contained brief. + +#### Agent A — Design tokens & global CSS +- **Type:** `frontend-architect` +- **Owns:** `apps/web/src/styles/tokens.css`, `apps/web/src/styles/app.css` +- **Brief:** + > Create `apps/web/src/styles/tokens.css` with the exact token block from + > [plan.md](plan.md) §1.1. Create `apps/web/src/styles/app.css` with global + > resets, body defaults (Helvetica Neue, font-weight 300), and `body.app-mode` + > dark scope. Delete `apps/web/src/styles.css`. Do NOT touch React components + > (the shell wave imports the new CSS). Verify: file paths exist, no other + > files modified. +- **Risk:** Low (additive + one delete). + +#### Agent B — API bug fixes +- **Type:** `backend-architect` +- **Owns:** `apps/api/src/db/change-reports.ts`, `apps/api/src/db/changeReports.ts`, `apps/api/src/seed/loader.ts`, `apps/api/src/routes/changes.ts` +- **Brief:** + > Fix the lifecycle 404 bug documented in REGRESSION_REPORT.md §A2. The + > legacy `db/change-reports.ts` and canonical `db/changeReports.ts` are two + > stores. Migrate `seed/loader.ts` and `routes/changes.ts` to use the + > canonical repo only. Delete `db/change-reports.ts`. Verify with + > `pnpm test` (lifecycle tests must pass on seeded data) and + > `curl -X POST .../v1/changes/chg_seed_notion/acknowledge` returns 200. +- **Risk:** Medium (touches production state machine; tests are the safety net). + +#### Agent C — Static demo delete + brand rename +- **Type:** `general-purpose` +- **Owns:** `public/app/**/*`, `apps/web/public/app/**/*`, all "Unsyphn" occurrences +- **Brief:** + > Two mechanical sweeps. (1) Delete `public/app/` and `apps/web/public/app/` + > directories entirely — audit confirmed non-load-bearing. (2) Global rename + > "Unsyphn" → "Redline" across `apps/`, `public/`, `packages/`. Use case-aware + > replacement (Unsyphn / UNSYPHN / unsyphn → Redline / REDLINE / redline). + > Skip `node_modules`, `dist`, `.git`. Verify: `grep -ril "unsyphn"` returns + > zero. Do NOT change any logo asset references yet (logo stays as + > `unsyphlogo.png` until Wave 2 swaps it). +- **Risk:** Medium (broad search-replace; verify with grep before declaring done). + +**Wave 1 GATE (run after all three return):** +```bash +pnpm typecheck # must be 0 errors +pnpm test # 98+ tests pass (B added lifecycle coverage) +grep -ril "unsyphn" apps/ public/ packages/ # must be empty +test ! -d public/app # must not exist +test ! -d apps/web/public/app # must not exist +test -f apps/web/src/styles/tokens.css +test ! -f apps/web/src/styles.css +``` + +--- + +### Wave 2 — App shell (1 agent) + +#### Agent D — Routes, mount, role plumbing +- **Type:** `frontend-architect` +- **Owns:** `apps/web/src/App.tsx`, `apps/web/index.html`, `apps/web/src/main.tsx`, `apps/web/src/lib/role.ts` (new) +- **Brief:** + > Rewrite `App.tsx` against the 6-route IA in [plan.md](plan.md) §2: + > `/app` (Portfolio), `/app/vendor/:id`, `/app/change/:id`, `/app/evidence/:id`, + > `/app/policy`, `/app/onboarding`, `/app/settings`. Each route is a + > placeholder screen for this wave (`
Portfolio coming in W3
`). + > Add top bar with brand mark + nav + role dropdown slot. Import + > `styles/tokens.css` and `styles/app.css` in `main.tsx`. Toggle + > `body.app-mode` on `/app/*` routes via `useEffect`. Create + > `lib/role.ts` exporting `parseRole(search): Role` and `useRole(): Role` + > hook (reads `?role=`). Verify: every route returns 200, body class toggles + > correctly, landing at `/` unaffected. +- **Risk:** Medium (router is the spine; placeholder screens prevent over-scoping). + +**Wave 2 GATE:** +```bash +pnpm dev:web & +sleep 3 +curl -s localhost:4004/ | grep -q "Redline" # landing renders +curl -s localhost:4004/app | grep -q "Portfolio" # placeholder route works +# manual: body.app-mode applies on /app, not on / +``` + +--- + +### Wave 3 — Screens (3 parallel) + +All three depend on Wave 2 (shell exists, tokens loaded). They own disjoint screens +and components. + +#### Agent E — Portfolio + Vendor cards +- **Type:** `frontend-architect` +- **Owns:** `apps/web/src/screens/Portfolio.tsx`, `apps/web/src/components/VendorCard.tsx`, `apps/web/src/components/FleetStats.tsx` +- **Brief:** + > Implement Portfolio per [plan.md](plan.md) §3 step 1. Fleet stats strip + > (`143 vendors · 12 changes · 3 P1 · $84k at-risk`) pulls from + > `/v1/dashboard/summary` (already exists). Vendor card grid (6–8 cards) + > with posture chip, renewal date, owner avatar. Click vendor → + > `/app/vendor/:id`. Click change chip → `/app/change/:id`. Use tokens from + > Wave 1. No new API endpoints — read what exists. +- **Risk:** Low. + +#### Agent F — ChangeReport + Drawer + lifecycle wiring +- **Type:** `frontend-architect` +- **Owns:** `apps/web/src/screens/ChangeReport.tsx`, `apps/web/src/components/Drawer.tsx`, `apps/web/src/components/SeverityBadge.tsx` +- **Brief:** + > Port `public/app/screen-change.jsx` to React TSX with new tokens. Header + > (vendor + severity badge), diff cards with citations, 4 lifecycle action + > buttons (acknowledge/snooze/resolve/escalate). Wire to the canonical + > `/v1/changes/:id/*` endpoints (Wave 1 Agent B fixed these). Escalate + > opens a modal (use Drawer component). Render at `/app/change/:id`. +- **Risk:** Medium (lifecycle integration — verify with seed data). + +#### Agent G — Evidence + Bundle + Onboarding + Stripe fix +- **Type:** `frontend-architect` +- **Owns:** `apps/web/src/screens/SensoBrief.tsx`, `apps/web/src/screens/StripeModal.tsx`, `apps/web/src/screens/Onboarding.tsx` (new), `apps/api/src/routes/evidence.ts` +- **Brief:** + > Three jobs. + > (1) Restyle `SensoBrief.tsx` to new tokens; preserve print stylesheet. + > Add "Generate Compliance Bundle" button. + > (2) Add `GET /v1/evidence/:id/bundle.html` to `routes/evidence.ts` — + > server-rendered printable HTML (ChangeReport + citations + routed actions). + > (3) Create `Onboarding.tsx` with 4 tier cards (Team/Business/Enterprise + + > Compliance Pack add-on per PDF §10). CTA opens existing `StripeModal`. + > Fix `StripeModal` UX-1 (× button inert in already-entitled branch) — + > one wire fix from REGRESSION_REPORT. +- **Risk:** Medium (touches API + 3 React files; all logic boundaries are well-defined). + +**Wave 3 GATE:** +```bash +pnpm typecheck # 0 errors +pnpm test # all green +pnpm build # ≤ 320kB JS bundle +# manual via preview MCP: +# /app shows Portfolio with seed data +# /app/change/chg_seed_notion shows ChangeReport, acknowledge button returns 200 +# /app/evidence/chg_seed_notion renders Senso brief +# /app/onboarding shows 4 tier cards; Stripe modal opens + closes +``` + +--- + +### Wave 4 — Cross-cutting (2 parallel) + +#### Agent H — Command palette + keyboard +- **Type:** `frontend-architect` +- **Owns:** `apps/web/src/components/CommandPalette.tsx`, `apps/web/src/lib/keyboard.ts` +- **Brief:** + > Global `⌘K` palette. Items: Jump to vendor (fuzzy match against + > `/v1/dashboard/summary` vendor list), Open recent change, Switch role, + > Generate Bundle, Open settings. Up/Down navigates, Enter executes, + > Esc closes. Mount in App.tsx via React portal so it overlays any route. + > Keyboard registry must respect text input focus (don't fire ⌘K when typing). +- **Risk:** Low (purely additive overlay). + +#### Agent I — Role switcher +- **Type:** `frontend-architect` +- **Owns:** `apps/web/src/components/RoleSwitcher.tsx`, `apps/web/src/lib/role.ts` (extend) +- **Brief:** + > Top-bar dropdown (Procurement / Legal / Security / Finance). Selecting + > a role updates `?role=` query param via `history.replaceState`. Hook + > `useRole()` (created in Wave 2) returns current role. Three of four + > views can show placeholder copy in their screens for v1 — only + > Procurement is real. Mount into the App.tsx top bar slot Agent D left. +- **Risk:** Low. + +**Wave 4 GATE:** +```bash +pnpm typecheck && pnpm test && pnpm build +# manual: ⌘K opens palette from /app and /app/vendor/notion; role dropdown +# updates URL; back/forward preserves role +``` + +--- + +### Wave 5 — Polish + review + +#### Agent J — A11y + audit refresh +- **Type:** `quality-engineer` +- **Owns:** any file with an a11y violation; `PAGE_AUDIT.md` +- **Brief:** + > Run axe on every route. Fix: missing labels, contrast violations, + > focus traps in drawer + palette, keyboard reach for all interactive + > elements, focus restore on modal close. Refresh `PAGE_AUDIT.md` + > to reflect new IA. Update score card. **Do not refactor unrelated + > code.** Report violations fixed, count by severity. +- **Risk:** Low. + +#### Code review (final, by reviewer agent — not parallel with J) +- **Type:** `code-reviewer` +- **Brief:** + > Review the full `saasb2b` branch diff vs `main`. Focus: security + > (Stripe path, evidence public route auth, SSE token handling), + > silent failures, mock leftovers, dead code, TASTE check + > (Slate × Seven applied or drifted?). Block on CRITICAL; else + > produce sign-off table. + +**Wave 5 GATE (release):** +- `pnpm typecheck && pnpm test && pnpm build` all green +- `grep -ril "unsyphn"` empty +- axe AA clean on every route +- 3-min demo script runs end-to-end without dead ends +- `code-reviewer` returns no CRITICAL findings +- PR opened, branch ready to merge + +--- + +## Concurrency rules + +1. **Max 3 agents in any one message.** Coordination cost beats marginal speed past 3. +2. **No file appears under two owners in the same wave.** Re-read the matrix before dispatch. +3. **Each agent gets its file list verbatim in its brief.** Agents must error rather than touch unlisted paths. +4. **Hard gate between waves.** Do not start Wave N+1 until Wave N gate passes. +5. **Background allowed, not required.** Wave 1 agents can run foreground (we need their work before Wave 2). +6. **Worktree isolation NOT used** — file boundaries already prevent collisions; worktree adds merge cost without benefit here. + +--- + +## Testing strategy + +### Per-wave verification +- **Wave 1:** typecheck, full test suite, grep for forbidden strings, file-existence asserts. +- **Wave 2:** dev server boots, both routes (landing + placeholder app) render, body class toggles. +- **Wave 3:** typecheck + tests + build size budget; manual preview MCP walk of 4 routes. +- **Wave 4:** typecheck + tests; manual ⌘K + role-switcher flow. +- **Wave 5:** axe AA clean + code-reviewer sign-off. + +### Test coverage targets (global rules) +- Unit (Vitest): `lib/role.ts` parser, `keyboard.ts` registry, `lib/evidence-bundle.ts` HTML render. Add tests when shape stabilizes (Wave 4+). +- Integration: lifecycle endpoint (already tested; Agent B widens coverage). +- E2E: 3-min demo script as a Playwright test (Wave 5, optional but high value). + +### What we don't test pre-build +- Layout pixel-perfection — visual via preview MCP screenshots is enough. +- Static screen content — covered by snapshot if it stabilizes; otherwise manual. + +--- + +## Risks & Mitigations + +| Risk | Likelihood | Impact | Mitigation | +|---|---|---|---| +| Agents collide on shared file | Low (matrix prevents it) | High (lost work) | File-ownership matrix; each brief lists owned paths explicitly. | +| Agent B's store migration breaks lifecycle in prod path | Medium | High | `pnpm test` covers it; Wave 1 gate requires green tests. Roll back single commit if red. | +| Brand rename catches false positives ("Unsync", etc.) | Low | Low | Case-aware replace, exclude `node_modules`. Grep verifies cleanup. | +| Wave 3 agents pull conflicting design decisions | Medium | Medium | Tokens are the single source of truth from Wave 1. Each agent reads plan.md §1 before starting. | +| StripeModal UX-1 fix has unseen side effects | Low | Medium | Existing webhook tests in place; manual verification of both branches (entitled vs not). | +| Static demo deletion removes something React app secretly imports | Low (audit checked) | Medium | Wave 1 ends with typecheck — failure indicates a hidden import; restore selectively. | +| Wave 4 keyboard registry conflicts with native shortcuts | Medium | Low | Scope to `/app/*` routes; honor `e.target.tagName === "INPUT"`; document conflicts. | +| Wave 5 a11y agent over-reaches and refactors | Medium | Medium | Brief is explicit: fix only violations, don't refactor. Reviewer agent catches it. | + +--- + +## Success criteria + +The build is done when, in a single `pnpm dev` session: + +1. `/` renders the existing landing page on dark Slate paint. +2. `/app` renders Portfolio with fleet stats and 6+ vendor cards. +3. Clicking a vendor card → vendor detail (placeholder OK for v1). +4. Clicking a change chip → `/app/change/:id` with working acknowledge/escalate. +5. Escalate flow opens drawer → modal → confirmation → `/app/evidence/:id`. +6. Evidence brief renders with Senso URL semantics + Generate Bundle button. +7. Bundle button serves a print-ready HTML page. +8. `/app/onboarding` → 4 tier cards → Stripe modal → test-mode checkout → success state with × that closes. +9. `⌘K` opens palette anywhere in `/app/*`. +10. Role dropdown updates `?role=` and back/forward preserves it. +11. `pnpm typecheck && pnpm test && pnpm build` green. +12. No "Unsyphn" string anywhere. +13. axe AA clean on every route. +14. `code-reviewer` agent returns no CRITICAL. + +--- + +## STOP — awaiting your call + +Reply with one of: +- **`yes`** / **`proceed`** → I dispatch Wave 1 (Agents A, B, C in parallel). +- **`modify: `** → I revise this orchestration and stop again. +- **`abort`** → drop it. + +I will not dispatch any agent until you confirm. From a7fbd7b7dc694365e11cb0db3a569d9ed6068cfa Mon Sep 17 00:00:00 2001 From: d3v07 <90106942+d3v07@users.noreply.github.com> Date: Sun, 24 May 2026 15:42:21 -0400 Subject: [PATCH 03/21] chore: apply changes --- .claude/launch.json | 2 +- BUILD.md | 16 +- PAGE_AUDIT.md | 12 +- README.md | 40 +- REGRESSION_REPORT.md | 4 +- apps/api/src/app.ts | 4 +- apps/api/src/db/change-reports.ts | 29 - apps/api/src/db/changeReports.ts | 9 + apps/api/src/routes/evidence.ts | 413 +++++- apps/api/src/seed/loader.ts | 2 - apps/api/src/server.ts | 6 +- apps/web/index.html | 32 +- apps/web/public/app/app.css | 484 ------- apps/web/public/app/app.jsx | 207 --- apps/web/public/app/app2.css | 1443 -------------------- apps/web/public/app/brand.jsx | 30 - apps/web/public/app/browser-window.jsx | 114 -- apps/web/public/app/escalate.jsx | 96 -- apps/web/public/app/index.html | 69 - apps/web/public/app/live.js | 187 --- apps/web/public/app/routing.jsx | 40 - apps/web/public/app/screen-change.jsx | 358 ----- apps/web/public/app/screen-evidence.jsx | 201 --- apps/web/public/app/screen-onboarding.jsx | 351 ----- apps/web/public/app/screen-portfolio.jsx | 163 --- apps/web/public/app/shared.jsx | 142 -- apps/web/public/app/tokens.css | 215 --- apps/web/public/app/vendor-data.jsx | 578 -------- apps/web/src/App.tsx | 225 +-- apps/web/src/components/CommandPalette.tsx | 423 ++++++ apps/web/src/components/Drawer.tsx | 161 +++ apps/web/src/components/FleetStats.tsx | 112 ++ apps/web/src/components/RoleSwitcher.tsx | 177 +++ apps/web/src/components/SeverityBadge.tsx | 19 + apps/web/src/components/VendorCard.tsx | 152 +++ apps/web/src/lib/keyboard.ts | 53 + apps/web/src/lib/role.ts | 34 + apps/web/src/main.tsx | 3 +- apps/web/src/screens/ChangeReport.tsx | 485 +++++++ apps/web/src/screens/Onboard.tsx | 2 +- apps/web/src/screens/Onboarding.tsx | 310 +++++ apps/web/src/screens/Portfolio.tsx | 142 ++ apps/web/src/screens/SensoBrief.tsx | 46 +- apps/web/src/screens/StripeModal.tsx | 187 ++- apps/web/src/screens/VendorOnboarding.tsx | 163 --- apps/web/src/styles.css | 310 ----- apps/web/src/styles/app.css | 267 ++++ apps/web/src/styles/brief.css | 344 +++-- apps/web/src/styles/tokens.css | 70 + apps/web/vite.config.ts | 53 +- plan.md | 6 +- public/app/app.css | 484 ------- public/app/app.jsx | 207 --- public/app/app2.css | 1443 -------------------- public/app/brand.jsx | 30 - public/app/browser-window.jsx | 114 -- public/app/escalate.jsx | 96 -- public/app/index.html | 69 - public/app/live.js | 187 --- public/app/routing.jsx | 40 - public/app/screen-change.jsx | 358 ----- public/app/screen-evidence.jsx | 201 --- public/app/screen-onboarding.jsx | 351 ----- public/app/screen-portfolio.jsx | 163 --- public/app/shared.jsx | 142 -- public/app/tokens.css | 215 --- public/app/vendor-data.jsx | 578 -------- public/index.html | 32 +- tests/api/evidence.test.ts | 16 + tests/api/seed-boot.test.ts | 8 +- tests/web-static/live-sidecar.test.ts | 126 -- tests/web-static/static.test.ts | 110 -- 72 files changed, 3485 insertions(+), 10476 deletions(-) delete mode 100644 apps/api/src/db/change-reports.ts delete mode 100644 apps/web/public/app/app.css delete mode 100644 apps/web/public/app/app.jsx delete mode 100644 apps/web/public/app/app2.css delete mode 100644 apps/web/public/app/brand.jsx delete mode 100644 apps/web/public/app/browser-window.jsx delete mode 100644 apps/web/public/app/escalate.jsx delete mode 100644 apps/web/public/app/index.html delete mode 100644 apps/web/public/app/live.js delete mode 100644 apps/web/public/app/routing.jsx delete mode 100644 apps/web/public/app/screen-change.jsx delete mode 100644 apps/web/public/app/screen-evidence.jsx delete mode 100644 apps/web/public/app/screen-onboarding.jsx delete mode 100644 apps/web/public/app/screen-portfolio.jsx delete mode 100644 apps/web/public/app/shared.jsx delete mode 100644 apps/web/public/app/tokens.css delete mode 100644 apps/web/public/app/vendor-data.jsx create mode 100644 apps/web/src/components/CommandPalette.tsx create mode 100644 apps/web/src/components/Drawer.tsx create mode 100644 apps/web/src/components/FleetStats.tsx create mode 100644 apps/web/src/components/RoleSwitcher.tsx create mode 100644 apps/web/src/components/SeverityBadge.tsx create mode 100644 apps/web/src/components/VendorCard.tsx create mode 100644 apps/web/src/lib/keyboard.ts create mode 100644 apps/web/src/lib/role.ts create mode 100644 apps/web/src/screens/ChangeReport.tsx create mode 100644 apps/web/src/screens/Onboarding.tsx create mode 100644 apps/web/src/screens/Portfolio.tsx delete mode 100644 apps/web/src/screens/VendorOnboarding.tsx delete mode 100644 apps/web/src/styles.css create mode 100644 apps/web/src/styles/app.css create mode 100644 apps/web/src/styles/tokens.css delete mode 100644 public/app/app.css delete mode 100644 public/app/app.jsx delete mode 100644 public/app/app2.css delete mode 100644 public/app/brand.jsx delete mode 100644 public/app/browser-window.jsx delete mode 100644 public/app/escalate.jsx delete mode 100644 public/app/index.html delete mode 100644 public/app/live.js delete mode 100644 public/app/routing.jsx delete mode 100644 public/app/screen-change.jsx delete mode 100644 public/app/screen-evidence.jsx delete mode 100644 public/app/screen-onboarding.jsx delete mode 100644 public/app/screen-portfolio.jsx delete mode 100644 public/app/shared.jsx delete mode 100644 public/app/tokens.css delete mode 100644 public/app/vendor-data.jsx delete mode 100644 tests/web-static/live-sidecar.test.ts delete mode 100644 tests/web-static/static.test.ts diff --git a/.claude/launch.json b/.claude/launch.json index 3d042e0..465bf25 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -13,7 +13,7 @@ "name": "web", "runtimeExecutable": "pnpm", "runtimeArgs": ["--filter", "@redline/web", "dev"], - "port": 4004, + "port": 4321, "autoPort": false } ] diff --git a/BUILD.md b/BUILD.md index dfb0cc6..0897d60 100644 --- a/BUILD.md +++ b/BUILD.md @@ -35,7 +35,7 @@ Wave 5 Polish + review ───1 build + 1 reviewer───► GATE: AA cl | `apps/api/src/routes/changes.ts` | B | 1 | | `public/app/**/*` (delete all) | C | 1 | | `apps/web/public/app/**/*` (delete all) | C | 1 | -| Global "Unsyphn" → "Redline" (grep -ril) | C | 1 | +| Global "Redline" → "Redline" (grep -ril) | C | 1 | | `apps/web/src/App.tsx` | D | 2 | | `apps/web/index.html` | D | 2 | | `apps/web/src/main.tsx` | D | 2 | @@ -94,13 +94,13 @@ self-contained brief. #### Agent C — Static demo delete + brand rename - **Type:** `general-purpose` -- **Owns:** `public/app/**/*`, `apps/web/public/app/**/*`, all "Unsyphn" occurrences +- **Owns:** `public/app/**/*`, `apps/web/public/app/**/*`, all "Redline" occurrences - **Brief:** > Two mechanical sweeps. (1) Delete `public/app/` and `apps/web/public/app/` > directories entirely — audit confirmed non-load-bearing. (2) Global rename - > "Unsyphn" → "Redline" across `apps/`, `public/`, `packages/`. Use case-aware - > replacement (Unsyphn / UNSYPHN / unsyphn → Redline / REDLINE / redline). - > Skip `node_modules`, `dist`, `.git`. Verify: `grep -ril "unsyphn"` returns + > "Redline" → "Redline" across `apps/`, `public/`, `packages/`. Use case-aware + > replacement (Redline / REDLINE / redline → Redline / REDLINE / redline). + > Skip `node_modules`, `dist`, `.git`. Verify: `grep -ril "redline"` returns > zero. Do NOT change any logo asset references yet (logo stays as > `unsyphlogo.png` until Wave 2 swaps it). - **Risk:** Medium (broad search-replace; verify with grep before declaring done). @@ -109,7 +109,7 @@ self-contained brief. ```bash pnpm typecheck # must be 0 errors pnpm test # 98+ tests pass (B added lifecycle coverage) -grep -ril "unsyphn" apps/ public/ packages/ # must be empty +grep -ril "redline" apps/ public/ packages/ # must be empty test ! -d public/app # must not exist test ! -d apps/web/public/app # must not exist test -f apps/web/src/styles/tokens.css @@ -261,7 +261,7 @@ pnpm typecheck && pnpm test && pnpm build **Wave 5 GATE (release):** - `pnpm typecheck && pnpm test && pnpm build` all green -- `grep -ril "unsyphn"` empty +- `grep -ril "redline"` empty - axe AA clean on every route - 3-min demo script runs end-to-end without dead ends - `code-reviewer` returns no CRITICAL findings @@ -330,7 +330,7 @@ The build is done when, in a single `pnpm dev` session: 9. `⌘K` opens palette anywhere in `/app/*`. 10. Role dropdown updates `?role=` and back/forward preserves it. 11. `pnpm typecheck && pnpm test && pnpm build` green. -12. No "Unsyphn" string anywhere. +12. No "Redline" string anywhere. 13. axe AA clean on every route. 14. `code-reviewer` agent returns no CRITICAL. diff --git a/PAGE_AUDIT.md b/PAGE_AUDIT.md index 1e05477..22b6ea3 100644 --- a/PAGE_AUDIT.md +++ b/PAGE_AUDIT.md @@ -1,4 +1,4 @@ -# Page Audit — Unsyphn B2B SaaS +# Page Audit — Redline B2B SaaS Generated: 2026-05-24 · Branch: production Status legend: `[ok]` working · `[brk]` broken/dead · `[wrn]` works but ugly/inconsistent · `[na]` not in scope @@ -46,7 +46,7 @@ flows route to `/app/` (the static Babel-JSX demo). ### A.1 Nav - A.1.1 [wrn] Brand mark `.nav .brand .mark` — conic-gradient circle, no logo image — **fix**: replace with `` from `unsyphlogo.png` -- A.1.2 [ok] Brand text "UNSYPHN / Subscription OS" +- A.1.2 [ok] Brand text "REDLINE / Subscription OS" - A.1.3 [brk] Product link — `href="#"` dead - A.1.4 [brk] Vault link — `href="#"` dead - A.1.5 [brk] Pricing link — `href="#"` dead @@ -149,13 +149,13 @@ flows route to `/app/` (the static Babel-JSX demo). - **K.0** [**CRITICAL**] `apps/web/index.html` missing `#root` + main.tsx script — entire React tree unreachable - K.1 [brk] Default route "/" falls to `` = Add Vendor (not a dashboard) -- K.4 [brk] Header brand text `.app__mark` says **"Redline"** — mismatch with landing "UNSYPHN" +- K.4 [brk] Header brand text `.app__mark` says **"Redline"** — mismatch with landing "REDLINE" - K.5 [ok] Header nav buttons wired - K.7 [wrn] Breadcrumb is ``, should be ` -function VendorOnboardingApp(): JSX.Element { - return ( -
-
- Unsyphn - - - ← Back to Dashboard - -
- -
+ + + + ); } diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx new file mode 100644 index 0000000..0d3f75e --- /dev/null +++ b/apps/web/src/components/CommandPalette.tsx @@ -0,0 +1,423 @@ +import { useEffect, useRef, useState, useCallback } from "react"; +import { createPortal } from "react-dom"; +import { useRole } from "../lib/role.js"; +import { useGlobalShortcut } from "../lib/keyboard.js"; + +type Role = "procurement" | "legal" | "security" | "finance"; + +interface PaletteItem { + id: string; + label: string; + hint?: string; + group: string; + disabled?: boolean; + action: () => void; +} + +const SEED_VENDORS = [ + { name: "Notion", slug: "notion" }, + { name: "Stripe", slug: "stripe" }, + { name: "Figma", slug: "figma" }, + { name: "Vercel", slug: "vercel" }, + { name: "Linear", slug: "linear" }, + { name: "GitHub", slug: "github" }, + { name: "Slack", slug: "slack" }, + { name: "Salesforce", slug: "salesforce" }, +]; + +const SEED_CHANGES = [ + { id: "chg_seed_notion", label: "Notion — data retention change" }, + { id: "chg_seed_stripe_subprocessor", label: "Stripe — subprocessor update" }, +]; + +const ROLES: Role[] = ["procurement", "legal", "security", "finance"]; + +function navigate(path: string): void { + window.history.pushState({}, "", path); + window.dispatchEvent(new PopStateEvent("popstate")); +} + +function useVendorList(): { name: string; slug: string }[] { + const [vendors, setVendors] = useState(SEED_VENDORS); + + useEffect(() => { + fetch("/v1/dashboard/summary") + .then((r) => (r.ok ? r.json() : null)) + .then((data: unknown) => { + if (!data || typeof data !== "object") return; + const d = data as Record; + const list = d.vendors ?? d.data; + if (!Array.isArray(list)) return; + const mapped = list + .filter((v): v is { name?: unknown; id?: unknown; slug?: unknown } => + typeof v === "object" && v !== null + ) + .map((v) => ({ + name: String(v.name ?? v.id ?? ""), + slug: String(v.slug ?? v.id ?? ""), + })) + .filter((v) => v.name && v.slug); + if (mapped.length > 0) setVendors(mapped); + }) + .catch(() => { + // fall back to seed vendors already in state + }); + }, []); + + return vendors; +} + +function substrMatch(haystack: string, needle: string): boolean { + return haystack.toLowerCase().includes(needle.toLowerCase()); +} + +interface PaletteProps { + open: boolean; + onClose: () => void; +} + +function Palette({ open, onClose }: PaletteProps): JSX.Element | null { + const [query, setQuery] = useState(""); + const [activeIdx, setActiveIdx] = useState(0); + const inputRef = useRef(null); + const listRef = useRef(null); + const dialogRef = useRef(null); + const vendors = useVendorList(); + const [, setRole] = useRole(); + const pathname = + typeof window !== "undefined" ? window.location.pathname : "/app"; + + const isChangePage = /^\/app\/(change|evidence)\/([^/?#]+)/.test(pathname); + const changeIdMatch = pathname.match( + /^\/app\/(change|evidence)\/([^/?#]+)/ + ); + const currentChangeId = changeIdMatch?.[2]; + + const buildItems = useCallback((): PaletteItem[] => { + const items: PaletteItem[] = []; + + vendors.forEach((v) => { + items.push({ + id: `vendor-${v.slug}`, + label: v.name, + group: "Jump to vendor", + action: () => navigate(`/app/vendor/${v.slug}`), + }); + }); + + SEED_CHANGES.forEach((c) => { + items.push({ + id: `change-${c.id}`, + label: c.label, + group: "Open recent change", + action: () => navigate(`/app/change/${c.id}`), + }); + }); + + ROLES.forEach((r) => { + const label = r.charAt(0).toUpperCase() + r.slice(1); + items.push({ + id: `role-${r}`, + label: `Switch to ${label}`, + group: "Switch role", + action: () => { + setRole(r); + onClose(); + }, + }); + }); + + SEED_CHANGES.forEach((c) => { + items.push({ + id: `brief-${c.id}`, + label: `Senso brief — ${c.label}`, + group: "Open Senso brief", + action: () => navigate(`/app/evidence/${c.id}`), + }); + }); + + if (isChangePage && currentChangeId) { + items.push({ + id: "bundle", + label: "Generate Compliance Bundle", + hint: "Opens printable HTML", + group: "Actions", + action: () => + window.open(`/v1/evidence/${currentChangeId}/bundle.html`, "_blank"), + }); + } else { + items.push({ + id: "bundle", + label: "Generate Compliance Bundle", + hint: "Open a change first", + group: "Actions", + disabled: true, + action: () => {}, + }); + } + + return items; + }, [vendors, isChangePage, currentChangeId, setRole, onClose]); + + const filtered = buildItems().filter( + (item) => !query || substrMatch(item.label, query) + ); + + useEffect(() => { + if (open) { + setQuery(""); + setActiveIdx(0); + requestAnimationFrame(() => inputRef.current?.focus()); + } + }, [open]); + + useEffect(() => { + setActiveIdx(0); + }, [query]); + + useEffect(() => { + const el = listRef.current?.querySelector( + `[data-idx="${activeIdx}"]` + ); + el?.scrollIntoView({ block: "nearest" }); + }, [activeIdx]); + + const FOCUSABLE = 'button:not([disabled]), [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; + + const handleKeyDown = (e: React.KeyboardEvent): void => { + if (e.key === "Tab") { + const dialog = dialogRef.current; + if (!dialog) return; + const focusable = Array.from(dialog.querySelectorAll(FOCUSABLE)).filter( + (el) => !el.hasAttribute("disabled"), + ); + if (focusable.length === 0) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault(); + last?.focus(); + } + } else { + if (document.activeElement === last) { + e.preventDefault(); + first?.focus(); + } + } + } else if (e.key === "ArrowDown") { + e.preventDefault(); + setActiveIdx((i) => Math.min(i + 1, filtered.length - 1)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setActiveIdx((i) => Math.max(i - 1, 0)); + } else if (e.key === "Enter") { + e.preventDefault(); + const item = filtered[activeIdx]; + if (item && !item.disabled) { + item.action(); + onClose(); + } + } else if (e.key === "Escape") { + e.stopPropagation(); + onClose(); + } + }; + + if (!open) return null; + + let lastGroup = ""; + + return createPortal( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > +
+
+ setQuery(e.currentTarget.value)} + style={{ width: "100%", border: "none", background: "transparent" }} + aria-label="Search commands" + autoComplete="off" + /> +
+ +
    + {filtered.length === 0 && ( +
  • + No results +
  • + )} + {filtered.map((item, idx) => { + const showGroup = item.group !== lastGroup; + lastGroup = item.group; + return ( +
  • + {showGroup && ( + + )} + +
  • + ); + })} +
+
+
, + document.body + ); +} + +export function CommandPalette(): JSX.Element { + const [open, setOpen] = useState(false); + const triggerRef = useRef(null); + + const openPalette = useCallback(() => { + triggerRef.current = document.activeElement as HTMLElement; + setOpen(true); + }, []); + + const closePalette = useCallback(() => { + setOpen(false); + requestAnimationFrame(() => { + if (triggerRef.current && triggerRef.current !== document.body) { + triggerRef.current.focus(); + } + }); + }, []); + + useGlobalShortcut("Meta+K", openPalette); + useGlobalShortcut("Control+K", openPalette); + + useEffect(() => { + const onOpen = (): void => openPalette(); + window.addEventListener("redline:openPalette", onOpen); + return () => window.removeEventListener("redline:openPalette", onOpen); + }, [openPalette]); + + useEffect(() => { + if (!open) return; + const onEsc = (e: KeyboardEvent): void => { + if (e.key === "Escape") closePalette(); + }; + window.addEventListener("keydown", onEsc); + return () => window.removeEventListener("keydown", onEsc); + }, [open, closePalette]); + + return ; +} diff --git a/apps/web/src/components/Drawer.tsx b/apps/web/src/components/Drawer.tsx new file mode 100644 index 0000000..29ea3c6 --- /dev/null +++ b/apps/web/src/components/Drawer.tsx @@ -0,0 +1,161 @@ +import { useEffect, useRef, type ReactNode } from "react"; + +interface Props { + open: boolean; + onClose: () => void; + children: ReactNode; + title?: string; +} + +const FOCUSABLE = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; + +export function Drawer({ open, onClose, children, title }: Props): JSX.Element { + const panelRef = useRef(null); + const openerRef = useRef(null); + const titleId = "drawer-title"; + + useEffect(() => { + if (open) { + openerRef.current = document.activeElement; + const first = panelRef.current?.querySelectorAll(FOCUSABLE)[0]; + first?.focus(); + } else { + const opener = openerRef.current; + if (opener instanceof HTMLElement) opener.focus(); + openerRef.current = null; + } + }, [open]); + + useEffect(() => { + if (!open) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + onClose(); + return; + } + + if (e.key !== "Tab") return; + + const panel = panelRef.current; + if (!panel) return; + + const focusable = Array.from(panel.querySelectorAll(FOCUSABLE)).filter( + (el) => !el.hasAttribute("disabled"), + ); + if (focusable.length === 0) return; + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + + if (first === undefined || last === undefined) return; + + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault(); + last.focus(); + } + } else { + if (document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [open, onClose]); + + return ( + <> +