From 1a03c3b8867435a21e9e39aacef0bd50dd88bbeb Mon Sep 17 00:00:00 2001
From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com>
Date: Tue, 20 Oct 2026 10:09:35 +0530
Subject: [PATCH 1/9] docs: Phase 1 shell plan and UI revamp deferred backlog
---
.../2026-10-20-ui-revamp-phase1-shell.md | 218 ++++++++++++++++++
.../specs/2026-10-20-ui-revamp-design.md | 13 ++
2 files changed, 231 insertions(+)
create mode 100644 docs/superpowers/plans/2026-10-20-ui-revamp-phase1-shell.md
diff --git a/docs/superpowers/plans/2026-10-20-ui-revamp-phase1-shell.md b/docs/superpowers/plans/2026-10-20-ui-revamp-phase1-shell.md
new file mode 100644
index 0000000..c2ba7f5
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-20-ui-revamp-phase1-shell.md
@@ -0,0 +1,218 @@
+# UI Revamp Phase 1 (App Shell) Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Replace the November-2025 shell with the new information architecture. That means a new sidebar with an icon-rail collapse, a top-right avatar menu, and new URLs with redirects from the old ones. Old page bodies stay as they are until Phase 5.
+
+**Architecture:**
+- **Routes.** Page folders are renamed or added under `app/(app)/`. Old URLs move to new ones through `next.config.js` `redirects()`, which run before middleware and carry query strings over.
+- **Sidebar.** It keeps the shadcn `Sidebar` primitives, with new items. The collapsed state is read from the existing `sidebar_state` cookie on the server, so the first paint is already correct and nothing jumps.
+- **Avatar menu.** It lives in the existing sticky header.
+- **Tests.** A Playwright fixture fakes a signed-in session: a Supabase session in localStorage, plus stubbed backend responses. This lets e2e drive the real shell.
+
+**Tech Stack:** Next.js 14 app router, shadcn sidebar/dropdown, `lucide-react`, Playwright (from Phase 0).
+
+**Spec:** `docs/superpowers/specs/2026-10-20-ui-revamp-design.md`, sections 3 and 6.3 and phase 1 of section 10. Builds on the Phase 0 branch `ui-revamp/phase0-foundations` (PR #89).
+
+## Global Constraints
+
+- **IA, verbatim from spec section 3:**
+ - Home.
+ - TRACK: Spending, Bills & subscriptions.
+ - PLAN: Budget, Forecast. **Both are hidden until their pages exist, so Phase 1 renders no PLAN group.**
+ - A separator, then the stand-alone items: Receipts & statements (with the review badge) and Ask Lumen (⌘K).
+- **Group labels name what the user does.** Never "Workspace", "AI", "Data" or "Compare".
+- **Profile, settings, API keys (BYOK) and log out all live in the top-right avatar menu.** The sidebar is navigation only, and there is no theme item yet.
+- **Collapse:**
+ - An icon rail of about 56px (`3.5rem`) with tooltips.
+ - Toggled with a button or Ctrl/⌘+B, and remembered across visits.
+ - Content must not jump on load.
+- **The zero layout-shift rule (spec 6.3) applies.** The Phase 0 shift test pattern is reused for the avatar menu.
+- **Suggested new routes, adopted:** `/home`, `/spending`, `/bills`, `/receipts`, `/ask`, plus the existing `/settings`.
+- **Old URLs must keep working** for bookmarks and the demo.
+- **Git:** no "claude" in branch names and no attribution lines. Open one PR into `refactor`. Keep `docs/CODEBASE-GUIDE.md` current.
+
+## Decisions this plan makes (owner may override)
+
+- **Old → new redirects:**
+ - `/dashboard` → `/home`
+ - `/analytics` → `/spending`
+ - `/ai-analytics` → `/spending`
+ - `/chatbot` → `/ask`
+ - `/documents` → `/receipts`
+ - `/import` → `/receipts?tab=import`
+ - `/review` → `/receipts?tab=review`
+ - `/upload` → `/home?upload=1`
+
+ All are `permanent: false`, because routes may still change before launch.
+- **Interim page bodies until Phase 5:**
+ - `/spending`: `AnalyticsContent`, then `AiAnalyticsContent`, stacked.
+ - `/bills`: `PaymentCalendarCard` and `ReminderDetailsCard`. These two cards also still appear inside `/spending`. That duplication is accepted until Phase 5.
+ - `/receipts`: a tab bar (Documents | Import | Review, driven by `?tab=`) over the existing contents.
+- **Collapsed state is server-read with `cookies()` in `(app)/layout.tsx`.** This makes the app routes render dynamically, which costs one serverless invocation per navigation on Vercel. The alternative, reading it on the client, causes a visible width jump on every hard load, which breaks the owner's no-shift rule. The cost should be revisited in Phase 6 (perf).
+- **⌘K / Ctrl+K navigates to `/ask` for now.** The sidebar Ask input with handoff is Phase 4.
+
+## Review Focus
+
+1. **Hard reload with the sidebar collapsed:** the first paint must already be a rail, with no expand-then-collapse. Task 3 test `collapsed state survives reload without a jump`.
+2. **Old bookmarked URLs with query strings:** `/analytics?tab=weekly` must land on `/spending?tab=weekly`. Task 2 test `old URLs redirect`.
+3. **Unauthenticated access to new URLs:** these must be protected exactly like the old ones, through the middleware list and matcher. Task 2 test `new URLs require a session`.
+4. **Opening the avatar menu must not move the page,** which was the original bug's trigger. Task 4 test `avatar menu does not move the page`.
+5. **Keyboard users:** Ctrl+B and Ctrl+K must not fire while typing in an input or textarea. Ask Lumen's composer is a textarea, and Ctrl+K inside it must not navigate away. Task 3 test `shortcuts ignore text fields`.
+
+## File map
+
+| File | Change |
+|---|---|
+| `frontend/e2e/fixtures.ts` (new) | `signIn(page)`: fake Supabase session plus `lumen_session` cookie, and stubs for every backend call to `NEXT_PUBLIC_BACKEND_URL` |
+| `frontend/e2e/shell.spec.ts` (new) | Phase 1 tests |
+| `frontend/src/app/(app)/home/` | `git mv dashboard home` (title "Home") |
+| `frontend/src/app/(app)/ask/` | `git mv chatbot ask` (title "Ask Lumen") |
+| `frontend/src/app/(app)/spending/page.tsx`, `bills/page.tsx`, `receipts/page.tsx` (new) | Interim compositions (see Decisions). `analytics/`, `ai-analytics/`, `documents/`, `import/` and `review/` keep only their `*Content.tsx`; their `page.tsx` files and `upload/` are deleted |
+| `frontend/next.config.js` | Redirect list |
+| `frontend/src/middleware.ts`, `frontend/src/app/robots.ts` | Protect and disallow the new prefixes (`/home`, `/spending`, `/bills`, `/receipts`, `/ask`, `/settings`); drop the old ones, since redirects run first |
+| In-app links | `login-content.tsx`, `onboarding-content.tsx` (×2), `reset-password/page.tsx`, `error.tsx`, `import/importContent.tsx`: `/dashboard` → `/home`. `demo/page.tsx` (×2): `/chatbot` → `/ask`. `animatedListItemUse.tsx`: `/upload` → `/home?upload=1`. `dashboardContent.tsx` keeps reading `?upload=1` |
+| `frontend/src/components/app-sidebar.tsx` | Rewritten (Task 3) |
+| `frontend/src/components/avatar-menu.tsx` (new) | Header avatar dropdown (Task 4) |
+| `frontend/src/components/ui/sidebar.tsx:32` | `SIDEBAR_WIDTH_ICON = "3.5rem"` |
+| `frontend/src/app/(app)/layout.tsx` | Server component; reads the cookie; header gets `AvatarMenu` |
+| Delete | `nav-main.tsx`, `nav-projects.tsx`, `nav-user.tsx`, `team-switcher.tsx` (only `app-sidebar` imports them) |
+
+---
+
+### Task 1: Signed-in e2e fixture
+
+**Files:** Create `frontend/e2e/fixtures.ts`, `frontend/e2e/shell.spec.ts`
+
+**Interfaces:**
+- Produces `export async function signIn(page: Page, opts?: { reviewCount?: number }): Promise`. It must run **before** `page.goto` and does three things:
+ 1. Adds the cookie `lumen_session=1` for `localhost`.
+ 2. Adds an init script that writes the localStorage key `sb-127-auth-token`. This is supabase-js's default `sb--auth-token` for `http://127.0.0.1:54321`; confirm it by reading what `getSession` looks up. The value is `{access_token:"e2e", refresh_token:"e2e", token_type:"bearer", expires_in:3600, expires_at:, user:{id:"e2e-user", email:"e2e@lumen.test", user_metadata:{full_name:"E2E User"}}}`.
+ 3. Adds `page.route("http://127.0.0.1:9/**", …)`. Use `GET /api/review` → `{items: Array(reviewCount ?? 0).fill(minimal flagged item)}` and `GET /api/profile` → `{display_name:"E2E User"}`. Every other request gets `200 {}`. Return an empty array instead wherever the calling code maps over the result. Find those by watching the console in Step 2.
+- Every later e2e test in this plan uses `signIn`.
+
+- [ ] **Step 1: Write the test** `signed-in shell renders` in `shell.spec.ts`. It calls `signIn(page)` and `page.goto("/dashboard")` (the old URL; Task 2 renames it), then expects `getByRole("navigation")` or the sidebar to be visible, and expects no element with the text "Redirecting to sign in".
+- [ ] **Step 2: Run** `npx playwright test e2e/shell.spec.ts`. The expected result is a PASS once the fixture works. A first failure that names a crashing page because a stub has the wrong shape is fixed in `fixtures.ts`, not in app code. Check `page.on("pageerror")`: there must be no uncaught errors.
+- [ ] **Step 3: Commit** with `test: signed-in e2e fixture (fake supabase session + backend stubs)`.
+
+### Task 2: New routes, redirects, protection, links
+
+**Files:** See the file map rows for routes, `next.config.js`, middleware, robots and in-app links.
+
+**Interfaces:**
+- Produces the routes `/home`, `/spending`, `/bills`, `/receipts` (where `?tab=documents|import|review` defaults to documents) and `/ask`.
+- Page titles are, exactly: "Home", "Spending", "Bills & subscriptions", "Receipts & statements" and "Ask Lumen".
+
+- [ ] **Step 1: Write the failing tests** in `shell.spec.ts`:
+
+```ts
+const moved: [string, string][] = [
+ ["/dashboard", "/home"], ["/analytics?tab=weekly", "/spending?tab=weekly"], ["/ai-analytics", "/spending"],
+ ["/chatbot", "/ask"], ["/documents", "/receipts"], ["/import", "/receipts?tab=import"],
+ ["/review", "/receipts?tab=review"], ["/upload", "/home?upload=1"],
+];
+test("old URLs redirect", async ({ request }) => {
+ for (const [from, to] of moved) {
+ const r = await request.get(from, { maxRedirects: 0 });
+ expect([307, 308]).toContain(r.status());
+ expect(r.headers()["location"]).toBe(to);
+ }
+});
+test("new URLs require a session", async ({ request }) => {
+ for (const p of ["/home", "/spending", "/bills", "/receipts", "/ask", "/settings"]) {
+ const r = await request.get(p, { maxRedirects: 0 });
+ expect(r.headers()["location"]).toContain("/login?reason=unauthorized");
+ }
+});
+test("new pages render for a signed-in user", async ({ page }) => {
+ await signIn(page);
+ for (const [p, title] of [["/home","Home"],["/spending","Spending"],["/bills","Bills & subscriptions"],
+ ["/receipts","Receipts & statements"],["/receipts?tab=review","Receipts & statements"],["/ask","Ask Lumen"]]) {
+ await page.goto(p);
+ await expect(page).toHaveTitle(new RegExp(`^${title}`));
+ }
+});
+```
+
+- [ ] **Step 2: Run them.** The expected result is all three FAIL, with a 404 or no redirect.
+- [ ] **Step 3: Implement.**
+ - Run the `git mv` renames.
+ - Add the three new `page.tsx` files, each exporting `metadata.title`.
+ - `receipts/page.tsx` is a client tab bar of ``, styled `label-mono`, with the active tab underlined. Below it, render the matching existing `*Content`.
+ - Add the redirects to the list in `next.config.js`.
+ - Update the middleware prefixes and matcher, and update `robots.ts`.
+ - Update the in-app links listed in the file map.
+ - Then confirm none are left: `grep -rnE '"/(dashboard|chatbot|analytics|ai-analytics|documents|import|review|upload)["?]' src` should return no output, except `nav-projects.tsx`, which Task 5 deletes.
+- [ ] **Step 4: Run** `npm run test:e2e`. The expected result is that everything passes, including the Phase 0 suite.
+- [ ] **Step 5: Commit** with `feat: new app routes (/home /spending /bills /receipts /ask) with redirects from old URLs`.
+
+### Task 3: Sidebar: new IA, icon rail, remembered state, shortcuts
+
+**Files:** `app-sidebar.tsx` (rewrite), `ui/sidebar.tsx:32`, `(app)/layout.tsx`, `shell.spec.ts`
+
+**Interfaces:**
+- Produces the `AppSidebar` items as data, in this order:
+ - Home: `/home`, icon `House`.
+ - Group label "Track":
+ - Spending: `/spending`, icon `ChartLine`.
+ - Bills & subscriptions: `/bills`, icon `CalendarClock`.
+ - `SidebarSeparator`.
+ - Receipts & statements: `/receipts`, icon `Receipt`. Shows a badge with the `reviewApi.list()` length when it is greater than 0, and hides it on error.
+ - Ask Lumen: `/ask`, icon `Sparkles`. Shows a `⌘K` hint, or `Ctrl K` when `navigator.platform` isn't a Mac. Detect this after mount; the hint is the same width either way, so nothing shifts.
+- Active item rule: `pathname === url || pathname.startsWith(url + "/")`.
+- Every item passes `tooltip={label}`.
+- Header: the logo plus "Lumen", with no tagline.
+- `(app)/layout.tsx` becomes a server component. It runs `const defaultOpen = cookies().get("sidebar_state")?.value !== "false"` and passes that to `SidebarProvider defaultOpen`. Client-only wrappers (`AuthGuard`, `ProfileProvider`, `MotionConfig`) stay as children.
+- The ⌘K handler lives in `app-sidebar.tsx`: a `keydown` listener that calls `router.push("/ask")`.
+
+- [ ] **Step 1: Write the failing tests:**
+ - **`sidebar shows the new IA`:** after `signIn` and `/home`, the visible sidebar link texts are exactly `["Home","Spending","Bills & subscriptions","Receipts & statements","Ask Lumen"]`, there is a group label "Track", and the page has no text "Workspace", "Compare" or "Financial command center".
+ - **`review badge counts flagged receipts`:** with `signIn(page,{reviewCount:3})`, the Receipts item contains "3".
+ - **`ctrl+b collapses to a 56px rail with tooltips`:**
+ 1. Press `Control+b`.
+ 2. The width of `[data-slot=sidebar-gap]` (or the sidebar container) is 56px ± 1.
+ 3. Item labels are not visible.
+ 4. Hovering the Spending icon shows the tooltip "Spending".
+ - **`collapsed state survives reload without a jump`:**
+ 1. Collapse, then reload.
+ 2. Before any script runs (`page.goto(url, { waitUntil: "commit" })` plus reading the HTML through `request.get` with the `sidebar_state=false` cookie), the server HTML contains `data-state="collapsed"`.
+ 3. After load, the rail width is 56px.
+ - **`ctrl+k opens Ask`:** on `/home`, pressing `Control+k` lands on `/ask`.
+ - **`shortcuts ignore text fields`:** on `/ask`, focus the composer textarea and press `Control+k` and `Control+b`. The URL is still `/ask` and the sidebar state is unchanged. The shadcn handler may need an `isTypingTarget(e.target)` guard: skip when the target is an `input`, a `textarea` or `[contenteditable]`.
+- [ ] **Step 2: Run them.** The expected result is FAIL, because the old IA still shows "Workspace".
+- [ ] **Step 3: Implement** according to the Interfaces above. Delete the `NavMain` and `NavProjects` imports.
+- [ ] **Step 4: Run** `npm run test:e2e`. The expected result is that everything passes.
+- [ ] **Step 5: Commit** with `feat: sidebar with new IA, 56px icon rail, remembered collapse, Ctrl+K to Ask`.
+
+### Task 4: Header avatar menu (profile, settings, keys, log out)
+
+**Files:** Create `components/avatar-menu.tsx`; modify `(app)/layout.tsx` (header) and `app-sidebar.tsx` (remove the `SidebarFooter`/`NavUser`).
+
+**Interfaces:**
+- `export function AvatarMenu(): JSX.Element` uses `useAuth` and `useProfile`, with the same name and avatar fallback logic `app-sidebar` has today.
+- Trigger: a round 32px avatar button with `aria-label="Account menu"`.
+- Menu (shadcn `DropdownMenu`, `align="end"`):
+ - A label with the name and email.
+ - "Settings" → `/settings`.
+ - "API keys" → `/settings#keys`. Add `id="keys"` to the BYOK section in `settingsContent.tsx`.
+ - A separator.
+ - "Log out", which calls `authApi.logout()` and then `router.replace("/login")`, the same as `nav-user` does today.
+- The header shows the sidebar trigger on the left and `AvatarMenu` on the right. The "Lumen" text in the header is removed.
+
+- [ ] **Step 1: Write the failing tests:**
+ - **`avatar menu has account actions`:** after `signIn` and `/home`, click `getByRole("button",{name:"Account menu"})`. The menu items are "Settings", "API keys" and "Log out", and the sidebar has no email text.
+ - **`avatar menu does not move the page`:** the Phase 0 pattern. Take the bounding box of `#main` before and after opening the menu on `/ask` and on `/home`; x and width must be unchanged.
+ - **`log out returns to login`:** click "Log out". The URL matches `/login`. Supabase `signOut` will call the stubbed host, so return 204 for `**/auth/v1/logout`.
+- [ ] **Step 2: Run them.** The expected result is FAIL, because there is no "Account menu" button.
+- [ ] **Step 3: Implement** according to the Interfaces above.
+- [ ] **Step 4: Run** `npm run test:e2e`. The expected result is that everything passes.
+- [ ] **Step 5: Commit** with `feat: top-right avatar menu with settings, API keys and log out`.
+
+### Task 5: Clean up and ship
+
+- [ ] **Step 1:** Delete `nav-main.tsx`, `nav-projects.tsx`, `nav-user.tsx` and `team-switcher.tsx`. Running `grep -rn "nav-main\|nav-projects\|nav-user\|team-switcher" src` should return no output.
+- [ ] **Step 2:** Run each of these:
+ - `npx tsc --noEmit`, `npm run lint` and `npm run build`, with the dummy env from Phase 0. All should be clean.
+ - `npm run test:e2e`: everything passes.
+- [ ] **Step 3:** Update `docs/CODEBASE-GUIDE.md` §7b2 with the new routes and their redirects, the sidebar data and badge, the cookie-driven collapse (and why the layout is dynamic), the avatar menu and the e2e `signIn` fixture.
+- [ ] **Step 4:** Commit, push the `ui-revamp/phase1-shell` branch and open a PR into `refactor`. If #89 isn't merged yet, base the PR on `ui-revamp/phase0-foundations` and say so in the body. The PR body lists the tests, the redirect table and the decisions above. Run the whole-branch review first, as in Phase 0.
diff --git a/docs/superpowers/specs/2026-10-20-ui-revamp-design.md b/docs/superpowers/specs/2026-10-20-ui-revamp-design.md
index 7b9de5b..aff6e1c 100644
--- a/docs/superpowers/specs/2026-10-20-ui-revamp-design.md
+++ b/docs/superpowers/specs/2026-10-20-ui-revamp-design.md
@@ -203,3 +203,16 @@ Keep `docs/CODEBASE-GUIDE.md` current in each PR.
- Chart implementation (keep recharts or hand-rolled SVG like the mockup), as long as it matches the look and passes the perf budget.
- Mobile layout details beyond "sidebar becomes a sheet".
- Copy for empty states and the brief's rule templates.
+
+## 12. Deferred backlog (revisit in the phase named)
+
+Small items found while building, parked here so they aren't lost. Tick them off in the PR that fixes them.
+
+- [ ] **Phase 2.** The `theme-color` meta is still `#050505` inside the light app, so the mobile browser bar stays black. Make it per route group (`viewport` export in `(app)/layout`). *(Phase 0 review)*
+- [ ] **Phase 2.** Muted text on the `#cfe2fb` end of `--band` is about 4.2:1. Use `foreground` for text on the band. *(Phase 0 review)*
+- [ ] **Phase 4.** The Ask Lumen chat still uses the dark `.bui` palette inside the light shell. Restyle it to Sky. *(Phase 0)*
+- [ ] **Phase 6.** The global reduced-motion rule also stops loading spinners. Consider exempting spinners with `motion-safe:` or giving them a static "Loading…" label. *(Phase 0 review)*
+- [ ] **Phase 6.** The e2e suite is local and Chromium-only. Add Firefox once Windows allows Playwright's build (`E2E_FIREFOX=1`), and consider running it in CI with the `signIn` fixture. *(Phase 0)*
+- [ ] **Phase 6.** `(app)/layout` reads the sidebar cookie on the server, which makes the app routes dynamic (one Vercel invocation per navigation). Measure it, and switch to a no-jump client approach if it's costly. *(Phase 1 decision)*
+- [ ] **Phase 5.** Spending and Bills both show the payment calendar and reminders cards in the interim. Remove the duplicate when the pages are redesigned. *(Phase 1 decision)*
+- [ ] **Owner, manual.** In Firefox, check that the profile/avatar menu doesn't shift the Ask Lumen column, and click through the old pages on light in a demo session. *(Phase 0 PR #89)*
From edf6db640349ca31af3be67c8a594aba1eacdac5 Mon Sep 17 00:00:00 2001
From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com>
Date: Tue, 20 Oct 2026 10:10:35 +0530
Subject: [PATCH 2/9] test: signed-in e2e fixture (fake supabase session +
backend stubs)
---
frontend/e2e/fixtures.ts | 33 +++++++++++++++++++++++++++++++++
frontend/e2e/shell.spec.ts | 12 ++++++++++++
2 files changed, 45 insertions(+)
create mode 100644 frontend/e2e/fixtures.ts
create mode 100644 frontend/e2e/shell.spec.ts
diff --git a/frontend/e2e/fixtures.ts b/frontend/e2e/fixtures.ts
new file mode 100644
index 0000000..e7a4a51
--- /dev/null
+++ b/frontend/e2e/fixtures.ts
@@ -0,0 +1,33 @@
+import type { Page } from "@playwright/test";
+
+// Fake a signed-in user without Supabase or the backend. Call before page.goto.
+// - supabase-js reads a non-expired session from localStorage without a network call
+// (key = sb--auth-token; the e2e host is 127.0.0.1).
+// - the `lumen_session` cookie gets past middleware.
+// - every backend call (NEXT_PUBLIC_BACKEND_URL in playwright.config.ts) is stubbed.
+export async function signIn(page: Page, opts: { reviewCount?: number } = {}): Promise {
+ await page.context().addCookies([{ name: "lumen_session", value: "1", domain: "localhost", path: "/" }]);
+ await page.addInitScript(() => {
+ const now = Math.floor(Date.now() / 1000);
+ localStorage.setItem(
+ "sb-127-auth-token",
+ JSON.stringify({
+ access_token: "e2e",
+ refresh_token: "e2e",
+ token_type: "bearer",
+ expires_in: 3600,
+ expires_at: now + 3600,
+ user: { id: "e2e-user", email: "e2e@lumen.test", user_metadata: { full_name: "E2E User" } },
+ }),
+ );
+ });
+ const flagged = { id: "r1", status: "flagged", reasons: [], fields: {}, created_at: null, note: null };
+ await page.route("http://127.0.0.1:9/**", (route) => {
+ const path = new URL(route.request().url()).pathname;
+ if (path === "/api/review") return route.fulfill({ json: { items: Array(opts.reviewCount ?? 0).fill(flagged) } });
+ if (path === "/api/profile") return route.fulfill({ json: { display_name: "E2E User", avatar_key: null } });
+ return route.fulfill({ json: {} });
+ });
+ // supabase signOut and any other auth call
+ await page.route("http://127.0.0.1:54321/**", (route) => route.fulfill({ status: 204, body: "" }));
+}
diff --git a/frontend/e2e/shell.spec.ts b/frontend/e2e/shell.spec.ts
new file mode 100644
index 0000000..31913a4
--- /dev/null
+++ b/frontend/e2e/shell.spec.ts
@@ -0,0 +1,12 @@
+import { test, expect } from "@playwright/test";
+import { signIn } from "./fixtures";
+
+test("signed-in shell renders", async ({ page }) => {
+ const errors: string[] = [];
+ page.on("pageerror", (e) => errors.push(e.message));
+ await signIn(page);
+ await page.goto("/dashboard");
+ await expect(page.locator('[data-slot="sidebar"]').first()).toBeVisible();
+ await expect(page.getByText("Redirecting to sign in")).toHaveCount(0);
+ expect(errors).toEqual([]);
+});
From 953cc3ac6d0b7af64eeae6977388286486e4ed65 Mon Sep 17 00:00:00 2001
From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com>
Date: Tue, 20 Oct 2026 10:14:28 +0530
Subject: [PATCH 3/9] feat: new app routes (/home /spending /bills /receipts
/ask) with redirects from old URLs
---
frontend/e2e/foundations.spec.ts | 4 +-
frontend/e2e/shell.spec.ts | 42 +++++++++++++++++++
frontend/next.config.js | 13 +++++-
frontend/src/app/(app)/ai-analytics/page.tsx | 8 ----
frontend/src/app/(app)/analytics/page.tsx | 8 ----
.../(app)/{chatbot => ask}/chatbotContent.tsx | 0
.../src/app/(app)/{chatbot => ask}/page.tsx | 0
frontend/src/app/(app)/bills/page.tsx | 18 ++++++++
frontend/src/app/(app)/documents/page.tsx | 8 ----
.../{dashboard => home}/dashboardContent.tsx | 0
.../app/(app)/{dashboard => home}/page.tsx | 2 +-
.../src/app/(app)/import/importContent.tsx | 2 +-
frontend/src/app/(app)/import/page.tsx | 8 ----
frontend/src/app/(app)/receipts/page.tsx | 41 ++++++++++++++++++
frontend/src/app/(app)/review/page.tsx | 8 ----
frontend/src/app/(app)/spending/page.tsx | 15 +++++++
frontend/src/app/(app)/upload/page.tsx | 5 ---
frontend/src/app/demo/page.tsx | 4 +-
frontend/src/app/error.tsx | 2 +-
frontend/src/app/login/login-content.tsx | 4 +-
.../src/app/onboarding/onboarding-content.tsx | 4 +-
frontend/src/app/reset-password/page.tsx | 2 +-
frontend/src/app/robots.ts | 11 ++---
.../src/components/animatedListItemUse.tsx | 2 +-
frontend/src/middleware.ts | 20 ++++-----
25 files changed, 155 insertions(+), 76 deletions(-)
delete mode 100644 frontend/src/app/(app)/ai-analytics/page.tsx
delete mode 100644 frontend/src/app/(app)/analytics/page.tsx
rename frontend/src/app/(app)/{chatbot => ask}/chatbotContent.tsx (100%)
rename frontend/src/app/(app)/{chatbot => ask}/page.tsx (100%)
create mode 100644 frontend/src/app/(app)/bills/page.tsx
delete mode 100644 frontend/src/app/(app)/documents/page.tsx
rename frontend/src/app/(app)/{dashboard => home}/dashboardContent.tsx (100%)
rename frontend/src/app/(app)/{dashboard => home}/page.tsx (73%)
delete mode 100644 frontend/src/app/(app)/import/page.tsx
create mode 100644 frontend/src/app/(app)/receipts/page.tsx
delete mode 100644 frontend/src/app/(app)/review/page.tsx
create mode 100644 frontend/src/app/(app)/spending/page.tsx
delete mode 100644 frontend/src/app/(app)/upload/page.tsx
diff --git a/frontend/e2e/foundations.spec.ts b/frontend/e2e/foundations.spec.ts
index 461147b..6c36eba 100644
--- a/frontend/e2e/foundations.spec.ts
+++ b/frontend/e2e/foundations.spec.ts
@@ -96,8 +96,8 @@ test("reduced motion kills transitions", async ({ browser }) => {
test("app pages are sky from the first server paint (no dark auth loader)", async ({ request }) => {
// the `lumen_session` hint cookie gets past middleware, like a signed-in user's hard refresh
- const html = await (await request.get("/dashboard", { headers: { cookie: "lumen_session=1" } })).text();
- expect(html).toContain('data-app-theme="sky"');
+ const html = await (await request.get("/home", { headers: { cookie: "lumen_session=1" } })).text();
+ expect(html.includes('data-app-theme="sky"'), "sky wrapper in server HTML").toBe(true);
});
test("dark .bui panels keep readable muted text inside sky", async ({ page }) => {
diff --git a/frontend/e2e/shell.spec.ts b/frontend/e2e/shell.spec.ts
index 31913a4..0c75580 100644
--- a/frontend/e2e/shell.spec.ts
+++ b/frontend/e2e/shell.spec.ts
@@ -10,3 +10,45 @@ test("signed-in shell renders", async ({ page }) => {
await expect(page.getByText("Redirecting to sign in")).toHaveCount(0);
expect(errors).toEqual([]);
});
+
+const moved: [string, string][] = [
+ ["/dashboard", "/home"],
+ ["/analytics?tab=weekly", "/spending?tab=weekly"],
+ ["/ai-analytics", "/spending"],
+ ["/chatbot", "/ask"],
+ ["/documents", "/receipts"],
+ ["/import", "/receipts?tab=import"],
+ ["/review", "/receipts?tab=review"],
+ ["/upload", "/home?upload=1"],
+];
+
+test("old URLs redirect", async ({ request }) => {
+ for (const [from, to] of moved) {
+ const r = await request.get(from, { maxRedirects: 0 });
+ expect([307, 308], from).toContain(r.status());
+ expect(r.headers()["location"], from).toBe(to);
+ }
+});
+
+test("new URLs require a session", async ({ request }) => {
+ for (const p of ["/home", "/spending", "/bills", "/receipts", "/ask", "/settings"]) {
+ const r = await request.get(p, { maxRedirects: 0 });
+ expect(r.headers()["location"] ?? "", p).toContain("/login?reason=unauthorized");
+ }
+});
+
+test("new pages render for a signed-in user", async ({ page }) => {
+ await signIn(page);
+ const pages: [string, string][] = [
+ ["/home", "Home"],
+ ["/spending", "Spending"],
+ ["/bills", "Bills & subscriptions"],
+ ["/receipts", "Receipts & statements"],
+ ["/receipts?tab=review", "Receipts & statements"],
+ ["/ask", "Ask Lumen"],
+ ];
+ for (const [p, title] of pages) {
+ await page.goto(p);
+ await expect(page, p).toHaveTitle(new RegExp(`^${title}`));
+ }
+});
diff --git a/frontend/next.config.js b/frontend/next.config.js
index 74a5938..fdcda45 100644
--- a/frontend/next.config.js
+++ b/frontend/next.config.js
@@ -22,7 +22,18 @@ const nextConfig = {
// The login page used to live at /signin; old bookmarks and already-sent email links still work
// (query strings carry over, and browsers keep the #fragment Supabase puts on email links).
async redirects() {
- return [{ source: "/signin", destination: "/login", permanent: true }];
+ // UI revamp Phase 1 renamed the app pages; temporary (307) while names may still change before launch.
+ const moved = [
+ ["/dashboard", "/home"],
+ ["/analytics", "/spending"],
+ ["/ai-analytics", "/spending"],
+ ["/chatbot", "/ask"],
+ ["/documents", "/receipts"],
+ ["/import", "/receipts?tab=import"],
+ ["/review", "/receipts?tab=review"],
+ ["/upload", "/home?upload=1"],
+ ].map(([source, destination]) => ({ source, destination, permanent: false }));
+ return [{ source: "/signin", destination: "/login", permanent: true }, ...moved];
},
};
diff --git a/frontend/src/app/(app)/ai-analytics/page.tsx b/frontend/src/app/(app)/ai-analytics/page.tsx
deleted file mode 100644
index 769ae54..0000000
--- a/frontend/src/app/(app)/ai-analytics/page.tsx
+++ /dev/null
@@ -1,8 +0,0 @@
-import type { Metadata } from "next";
-import AIAnalyticsContent from "./aiAnalyticsContent";
-
-export const metadata: Metadata = { title: "AI insights" };
-
-export default function Page() {
- return ;
-}
diff --git a/frontend/src/app/(app)/analytics/page.tsx b/frontend/src/app/(app)/analytics/page.tsx
deleted file mode 100644
index 161c4f3..0000000
--- a/frontend/src/app/(app)/analytics/page.tsx
+++ /dev/null
@@ -1,8 +0,0 @@
-import type { Metadata } from "next";
-import AnalyticsContent from "./analyticsContent";
-
-export const metadata: Metadata = { title: "Analytics" };
-
-export default function Page() {
- return ;
-}
diff --git a/frontend/src/app/(app)/chatbot/chatbotContent.tsx b/frontend/src/app/(app)/ask/chatbotContent.tsx
similarity index 100%
rename from frontend/src/app/(app)/chatbot/chatbotContent.tsx
rename to frontend/src/app/(app)/ask/chatbotContent.tsx
diff --git a/frontend/src/app/(app)/chatbot/page.tsx b/frontend/src/app/(app)/ask/page.tsx
similarity index 100%
rename from frontend/src/app/(app)/chatbot/page.tsx
rename to frontend/src/app/(app)/ask/page.tsx
diff --git a/frontend/src/app/(app)/bills/page.tsx b/frontend/src/app/(app)/bills/page.tsx
new file mode 100644
index 0000000..b8af91a
--- /dev/null
+++ b/frontend/src/app/(app)/bills/page.tsx
@@ -0,0 +1,18 @@
+import type { Metadata } from "next";
+import { DashboardShell } from "@/components/dashboard-shell";
+import PaymentCalendarCard from "@/components/ai-analytics/paymentCalendarCard";
+import ReminderDetailsCard from "@/components/ai-analytics/reminderDetailsCard";
+
+export const metadata: Metadata = { title: "Bills & subscriptions" };
+
+// Interim (UI revamp Phase 1): the payment calendar and reminders until Phase 5 redesigns Bills.
+export default function Page() {
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/app/(app)/documents/page.tsx b/frontend/src/app/(app)/documents/page.tsx
deleted file mode 100644
index 9f1b4af..0000000
--- a/frontend/src/app/(app)/documents/page.tsx
+++ /dev/null
@@ -1,8 +0,0 @@
-import type { Metadata } from "next";
-import DocumentsContent from "./documentsContent";
-
-export const metadata: Metadata = { title: "Documents" };
-
-export default function Page() {
- return ;
-}
diff --git a/frontend/src/app/(app)/dashboard/dashboardContent.tsx b/frontend/src/app/(app)/home/dashboardContent.tsx
similarity index 100%
rename from frontend/src/app/(app)/dashboard/dashboardContent.tsx
rename to frontend/src/app/(app)/home/dashboardContent.tsx
diff --git a/frontend/src/app/(app)/dashboard/page.tsx b/frontend/src/app/(app)/home/page.tsx
similarity index 73%
rename from frontend/src/app/(app)/dashboard/page.tsx
rename to frontend/src/app/(app)/home/page.tsx
index 0abb86f..1a31dc8 100644
--- a/frontend/src/app/(app)/dashboard/page.tsx
+++ b/frontend/src/app/(app)/home/page.tsx
@@ -1,7 +1,7 @@
import type { Metadata } from "next";
import DashboardContent from "./dashboardContent";
-export const metadata: Metadata = { title: "Overview" };
+export const metadata: Metadata = { title: "Home" };
export default function Page() {
return ;
diff --git a/frontend/src/app/(app)/import/importContent.tsx b/frontend/src/app/(app)/import/importContent.tsx
index a938809..804140c 100644
--- a/frontend/src/app/(app)/import/importContent.tsx
+++ b/frontend/src/app/(app)/import/importContent.tsx
@@ -208,7 +208,7 @@ export default function ImportContent() {
Imported {result.imported} · {result.duplicates} duplicates · {result.matched} already recorded ·{" "}
{result.credits} credits skipped
{result.unreadable.length > 0 && ` · unreadable lines: ${result.unreadable.join(", ")}`}.{" "}
-
+
See the dashboard
diff --git a/frontend/src/app/(app)/import/page.tsx b/frontend/src/app/(app)/import/page.tsx
deleted file mode 100644
index 435e2fe..0000000
--- a/frontend/src/app/(app)/import/page.tsx
+++ /dev/null
@@ -1,8 +0,0 @@
-import type { Metadata } from "next";
-import ImportContent from "./importContent";
-
-export const metadata: Metadata = { title: "Import statement" };
-
-export default function Page() {
- return ;
-}
diff --git a/frontend/src/app/(app)/receipts/page.tsx b/frontend/src/app/(app)/receipts/page.tsx
new file mode 100644
index 0000000..6db7090
--- /dev/null
+++ b/frontend/src/app/(app)/receipts/page.tsx
@@ -0,0 +1,41 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { cn } from "@/lib/utils";
+import DocumentsContent from "../documents/documentsContent";
+import ImportContent from "../import/importContent";
+import ReviewContent from "../review/reviewContent";
+
+export const metadata: Metadata = { title: "Receipts & statements" };
+
+const TABS = [
+ { id: "documents", label: "Documents", Content: DocumentsContent },
+ { id: "import", label: "Import", Content: ImportContent },
+ { id: "review", label: "Review", Content: ReviewContent },
+] as const;
+
+// Interim (UI revamp Phase 1): the old Documents, Import and Review pages as tabs until Phase 5 redesigns this page.
+export default function Page({ searchParams }: { searchParams: { tab?: string } }) {
+ const active = TABS.find((t) => t.id === searchParams.tab) ?? TABS[0];
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/frontend/src/app/(app)/review/page.tsx b/frontend/src/app/(app)/review/page.tsx
deleted file mode 100644
index 9eb07ea..0000000
--- a/frontend/src/app/(app)/review/page.tsx
+++ /dev/null
@@ -1,8 +0,0 @@
-import type { Metadata } from "next";
-import ReviewContent from "./reviewContent";
-
-export const metadata: Metadata = { title: "Review" };
-
-export default function Page() {
- return ;
-}
diff --git a/frontend/src/app/(app)/spending/page.tsx b/frontend/src/app/(app)/spending/page.tsx
new file mode 100644
index 0000000..8e0b98a
--- /dev/null
+++ b/frontend/src/app/(app)/spending/page.tsx
@@ -0,0 +1,15 @@
+import type { Metadata } from "next";
+import AnalyticsContent from "../analytics/analyticsContent";
+import AIAnalyticsContent from "../ai-analytics/aiAnalyticsContent";
+
+export const metadata: Metadata = { title: "Spending" };
+
+// Interim (UI revamp Phase 1): the old Analytics and AI Insights pages stacked until Phase 5 redesigns Spending.
+export default function Page() {
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/frontend/src/app/(app)/upload/page.tsx b/frontend/src/app/(app)/upload/page.tsx
deleted file mode 100644
index 9b90f50..0000000
--- a/frontend/src/app/(app)/upload/page.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import { redirect } from "next/navigation";
-
-export default function UploadPage() {
- redirect("/dashboard?upload=1");
-}
diff --git a/frontend/src/app/demo/page.tsx b/frontend/src/app/demo/page.tsx
index 25cfa08..3640781 100644
--- a/frontend/src/app/demo/page.tsx
+++ b/frontend/src/app/demo/page.tsx
@@ -28,7 +28,7 @@ export default function DemoPage() {
headers: { Authorization: `Bearer ${data.session.access_token}` },
});
if (!res.ok) throw new Error(`Demo setup failed (${res.status})`);
- router.replace("/chatbot");
+ router.replace("/ask");
} catch (err) {
const message = err instanceof Error ? err.message : "Something went wrong.";
// Don't leave the visitor signed in to an empty demo account: sign out so a retry starts clean.
@@ -41,7 +41,7 @@ export default function DemoPage() {
useEffect(() => {
if (loading || started.current) return;
started.current = true;
- if (user) router.replace("/chatbot");
+ if (user) router.replace("/ask");
else start();
}, [loading, user, router, start]);
diff --git a/frontend/src/app/error.tsx b/frontend/src/app/error.tsx
index 058f8d6..0d7c65b 100644
--- a/frontend/src/app/error.tsx
+++ b/frontend/src/app/error.tsx
@@ -22,7 +22,7 @@ export default function Error({
diff --git a/frontend/src/app/login/login-content.tsx b/frontend/src/app/login/login-content.tsx
index 64e35db..1c83d7a 100644
--- a/frontend/src/app/login/login-content.tsx
+++ b/frontend/src/app/login/login-content.tsx
@@ -35,8 +35,8 @@ const TITLES: Record = {
// Only allow same-origin relative paths. "//host" and "/\host" are treated by
// browsers as protocol-relative URLs and would redirect off-site.
function safeNextPath(next: string | null): string {
- if (!next || !next.startsWith("/")) return "/dashboard";
- if (next.startsWith("//") || next.startsWith("/\\")) return "/dashboard";
+ if (!next || !next.startsWith("/")) return "/home";
+ if (next.startsWith("//") || next.startsWith("/\\")) return "/home";
return next;
}
diff --git a/frontend/src/app/onboarding/onboarding-content.tsx b/frontend/src/app/onboarding/onboarding-content.tsx
index 76b220a..a582790 100644
--- a/frontend/src/app/onboarding/onboarding-content.tsx
+++ b/frontend/src/app/onboarding/onboarding-content.tsx
@@ -50,7 +50,7 @@ const GOALS: [Goal, string][] = [
];
function safeNext(next: string | null): string {
- if (!next || !next.startsWith("/") || next.startsWith("//") || next.startsWith("/\\")) return "/dashboard";
+ if (!next || !next.startsWith("/") || next.startsWith("//") || next.startsWith("/\\")) return "/home";
return next;
}
@@ -120,7 +120,7 @@ export default function OnboardingContent() {
return;
}
if (user.is_anonymous) {
- router.replace("/dashboard"); // demo visitors never onboard
+ router.replace("/home"); // demo visitors never onboard
return;
}
loadedFor.current = user.id;
diff --git a/frontend/src/app/reset-password/page.tsx b/frontend/src/app/reset-password/page.tsx
index ee87011..df42e14 100644
--- a/frontend/src/app/reset-password/page.tsx
+++ b/frontend/src/app/reset-password/page.tsx
@@ -50,7 +50,7 @@ export default function ResetPasswordPage() {
password,
});
if (updateError) throw updateError;
- router.replace("/dashboard");
+ router.replace("/home");
} catch (err) {
setError(err instanceof Error ? err.message : "Couldn't update your password.");
setSubmitting(false);
diff --git a/frontend/src/app/robots.ts b/frontend/src/app/robots.ts
index d16bd0a..7d3d7f6 100644
--- a/frontend/src/app/robots.ts
+++ b/frontend/src/app/robots.ts
@@ -9,11 +9,12 @@ export default function robots(): MetadataRoute.Robots {
userAgent: "*",
allow: "/",
disallow: [
- "/dashboard",
- "/analytics",
- "/ai-analytics",
- "/chatbot",
- "/upload",
+ "/home",
+ "/spending",
+ "/bills",
+ "/receipts",
+ "/ask",
+ "/settings",
"/login",
"/demo",
"/reset-password",
diff --git a/frontend/src/components/animatedListItemUse.tsx b/frontend/src/components/animatedListItemUse.tsx
index 655bbfc..30a7443 100644
--- a/frontend/src/components/animatedListItemUse.tsx
+++ b/frontend/src/components/animatedListItemUse.tsx
@@ -198,7 +198,7 @@ export default function AnimatedListItemUse() {