diff --git a/docs/CODEBASE-GUIDE.md b/docs/CODEBASE-GUIDE.md
index aa8dd78..bf3652c 100644
--- a/docs/CODEBASE-GUIDE.md
+++ b/docs/CODEBASE-GUIDE.md
@@ -253,7 +253,16 @@ embeds the demo documents once per server and uploads the files in the backgroun
the landing or sign-in pages, which don't need the API; they still ping it so it's warm by the time the app opens).
## 7b2. App frame and navigation (frontend)
-- The signed-in pages live in the route group `app/(app)/` (URLs unchanged: `/dashboard`, `/analytics`, ...).
+- The signed-in pages live in the route group `app/(app)/`: `/home`, `/spending`, `/bills`, `/receipts`
+ (`?tab=documents|import|review`), `/ask`, `/settings`. The old URLs (`/dashboard`, `/analytics`, `/ai-analytics`,
+ `/chatbot`, `/documents`, `/import`, `/review`, `/upload`) redirect to them in `next.config.js` (temporary 307s, query
+ strings carried). Until UI revamp Phase 5, Spending, Bills and Receipts reuse the old pages' `*Content.tsx`.
+- **Sidebar** (`components/app-sidebar.tsx`): Home · Track (Spending, Bills & subscriptions) · Receipts & statements
+ (badge = flagged review items) · Ask Lumen (Ctrl/⌘+K). It collapses to a 56px icon rail with Ctrl/⌘+B, remembered
+ in the `sidebar_state` cookie. The layout reads that cookie on the sidebar's first render, which is always
+ client-side because the auth guard shows a loader on the server, so there's no jump and no server read. Shortcuts
+ skip key presses in text fields (`isTypingTarget` in `lib/utils.ts`). **Account** (Settings, API keys, Log out) is the
+ top-right `components/avatar-menu.tsx`.
`app/(app)/layout.tsx` holds the auth guard, sidebar and top bar; they mount once and stay while you move between
pages, so only the content area swaps. `app/(app)/template.tsx` fades each page's content in (`.app-page-in` in
`globals.css`). `components/dashboard-shell.tsx` is just a page's content wrapper and optional header card.
@@ -268,7 +277,10 @@ embeds the demo documents once per server and uploads the files in the backgroun
- **No layout shift from overlays.** `globals.css` sets `scrollbar-gutter: stable` and cancels Radix's scroll-lock
margin (`body[data-scroll-locked]`), so opening a menu or dialog never moves the page. Guarded by
`npm run test:e2e` (Playwright, local only), which drives the dev-only harness `app/dev/layout-shift` (404 in
- production). Firefox is opt-in with `E2E_FIREFOX=1`.
+ production). Firefox is opt-in with `E2E_FIREFOX=1`. `html, body` use `overflow-x: clip`, never `hidden`:
+ `hidden` makes `
` a scroll container whose scrollbar disappears when a menu opens.
+- **Signed-in e2e:** `e2e/fixtures.ts` `signIn(page)` fakes a Supabase session (localStorage) plus the
+ `lumen_session` cookie, and stubs every backend call, so `e2e/shell.spec.ts` drives the real shell without servers.
- **Motion:** use `motion/react` only (framer-motion was removed). Shared timings live in `lib/motion.ts`
(`DURATION`, `EASE_OUT`, `fadeUp`). Reduced motion is honoured globally by `MotionConfig reducedMotion="user"`
in the app layout and a CSS rule in `globals.css`.
diff --git a/docs/direction/STORY.md b/docs/direction/STORY.md
index a23fc89..18abd75 100644
--- a/docs/direction/STORY.md
+++ b/docs/direction/STORY.md
@@ -312,3 +312,17 @@ with the numbers and the files that prove them. Newest last. Raw numbers live in
12 e2e checks pass, including portal colours, AA contrast for muted text (4.9:1), login staying dark and
reduced motion. framer-motion was removed. Story point: reproduce in a test first. Here the test environment
was hiding the very bug.
+
+## 27. The new app shell, and the second half of the shifting-page bug (UI revamp, Phase 1)
+- **S:** The sidebar still had the November-2025 tabs ("Workspace", "Compare", a test link), so its labels no longer
+ matched what the app does. The Phase 0 fix had also left Ask Lumen shifting 15 px once in about four runs.
+- **T:** Ship the new navigation (Home · Track · Receipts · Ask) with old URLs still working. Collapse to an icon
+ rail without a jump on reload. Find the remaining shift.
+- **A:** Added a Playwright fixture that fakes a signed-in session and stubs the backend, so the real shell is
+ testable without servers. A probe showed `` owning its own 15 px scrollbar: `overflow-x: hidden` on html and
+ body turns body into a scroll container, and a menu's scroll lock removes that scrollbar. Switched to `overflow-x:
+ clip`. The collapsed state is read on the sidebar's first (client-only) render, so app pages stay static on Vercel.
+- **R:** 31 e2e checks pass, including 8 redirects, auth on all new routes, a rail on first paint, shortcuts ignored
+ while typing, a live review badge and the mobile sheet closing. Avatar-menu shift checks passed 24/24 on repeat
+ (failed 1 in 4 before). Story point: a flaky test is a bug report. Here it caught the half of the original bug
+ that the first fix missed.
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..5029257 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,21 @@ 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)*
+- [x] ~~**Phase 6.** `(app)/layout` reads the sidebar cookie on the server.~~ Not needed: the sidebar only renders client-side (behind the auth guard), so Phase 1 reads the cookie there and the app routes stay static. *(Phase 1)*
+- [ ] **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)*
+- [ ] **Phase 3.** The sidebar's review badge downloads every flagged item just to count them. Add a count-only endpoint (e.g. `GET /api/review?status=flagged&count=1`). *(Phase 1 review)*
+- [ ] **Phase 5.** `/spending` stacks two old pages, so it has two `
`s and fetches the calendar and reminders twice. It goes away with the redesign. *(Phase 1 review)*
+- [ ] **Phase 6.** iOS Safari 15 and older don't support `overflow-x: clip`, so wide landing marquees could scroll sideways there. If that matters, add an `@supports not (overflow-x: clip)` fallback (not `hidden` on body). *(Phase 1 review)*
+- [ ] **Phase 4.** Ctrl/⌘+K checks `e.key === "k"`, so Caps Lock or Shift misses it. Use `e.code === "KeyK"`. *(Phase 1 review)*
+- [ ] **Phase 6.** The sidebar cookie expires after 7 days (shadcn default), so a collapsed rail reopens after a week away. Extend it to about a year. *(Phase 1 review)*
+- [ ] **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)*
diff --git a/frontend/e2e/fixtures.ts b/frontend/e2e/fixtures.ts
new file mode 100644
index 0000000..08c2f73
--- /dev/null
+++ b/frontend/e2e/fixtures.ts
@@ -0,0 +1,52 @@
+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" } },
+ }),
+ );
+ });
+ // a tiny stateful review queue: approving/rejecting removes the item, so the sidebar badge can be checked
+ let queue = Array.from({ length: opts.reviewCount ?? 0 }, (_, i) => ({
+ id: `r${i}`,
+ status: "flagged",
+ confidence: "low",
+ invoice: { vendor_name: `Vendor ${i}` },
+ flags: [],
+ transaction_id: null,
+ note: null,
+ created_at: 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: queue } });
+ const decided = path.match(/^\/api\/review\/([^/]+)\/(approve|reject)$/);
+ if (decided) {
+ const item = queue.find((q) => q.id === decided[1])!;
+ queue = queue.filter((q) => q.id !== decided[1]);
+ return route.fulfill({ json: { item: { ...item, status: `${decided[2]}d` } } });
+ }
+ if (path === "/api/agent/proposals") return route.fulfill({ json: { proposals: [] } });
+ if (path === "/chat/history") return route.fulfill({ json: { success: true, messages: [] } });
+ if (path === "/chat/suggestions") return route.fulfill({ json: { suggestions: [] } });
+ 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/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
new file mode 100644
index 0000000..a5bebfe
--- /dev/null
+++ b/frontend/e2e/shell.spec.ts
@@ -0,0 +1,192 @@
+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([]);
+});
+
+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}`));
+ }
+});
+
+const sidebar = (page: import("@playwright/test").Page) => page.locator('[data-slot="sidebar-container"]');
+const railWidth = (page: import("@playwright/test").Page) =>
+ page.locator('[data-slot="sidebar-gap"]').evaluate((e) => e.getBoundingClientRect().width);
+
+test("sidebar shows the new IA", async ({ page }) => {
+ await signIn(page);
+ await page.goto("/home");
+ const links = sidebar(page).locator('[data-sidebar="menu-button"]');
+ await expect(links).toHaveText(["Home", "Spending", "Bills & subscriptions", "Receipts & statements", /^Ask Lumen/]);
+ await expect(sidebar(page).getByText("Track", { exact: true })).toBeVisible();
+ for (const old of ["Workspace", "Compare", "Financial command center"]) {
+ await expect(page.getByText(old, { exact: true })).toHaveCount(0);
+ }
+});
+
+test("review badge counts flagged receipts", async ({ page }) => {
+ await signIn(page, { reviewCount: 3 });
+ await page.goto("/home");
+ await expect(sidebar(page).locator('[data-sidebar="menu-badge"]')).toHaveText("3");
+});
+
+test("ctrl+b collapses to a 56px rail with tooltips", async ({ page }) => {
+ await signIn(page);
+ await page.goto("/home");
+ await expect(sidebar(page)).toBeVisible();
+ await page.keyboard.press("Control+b");
+ await expect.poll(() => railWidth(page)).toBeCloseTo(56, 0);
+ await expect(sidebar(page).getByText("Spending", { exact: true })).toBeHidden();
+ await sidebar(page).locator('a[href="/spending"]').hover();
+ await expect(page.getByRole("tooltip")).toHaveText("Spending");
+});
+
+test("collapsed state survives reload without a jump", async ({ page }) => {
+ // record the sidebar's data-state the moment it first enters the DOM (the auth guard renders it client-side)
+ await page.addInitScript(() => {
+ new MutationObserver((_, obs) => {
+ const el = document.querySelector('[data-slot="sidebar"][data-state]');
+ if (el) {
+ (window as unknown as { firstSidebarState: string }).firstSidebarState = el.getAttribute("data-state")!;
+ obs.disconnect();
+ }
+ }).observe(document, { childList: true, subtree: true });
+ });
+ await page.context().addCookies([{ name: "sidebar_state", value: "false", domain: "localhost", path: "/" }]);
+ await signIn(page);
+ await page.goto("/home");
+ await expect(sidebar(page)).toBeVisible();
+ expect(await page.evaluate(() => (window as unknown as { firstSidebarState: string }).firstSidebarState)).toBe(
+ "collapsed",
+ );
+ expect(await railWidth(page)).toBeCloseTo(56, 0);
+});
+
+test("ctrl+k opens Ask", async ({ page }) => {
+ await signIn(page);
+ await page.goto("/home");
+ await expect(sidebar(page)).toBeVisible();
+ await page.keyboard.press("Control+k");
+ await expect(page).toHaveURL(/\/ask$/);
+});
+
+test("shortcuts ignore text fields", async ({ page }) => {
+ await signIn(page);
+ await page.goto("/ask");
+ const box = page.locator("textarea").first();
+ await box.click();
+ const before = await railWidth(page);
+ await page.keyboard.press("Control+k");
+ await page.keyboard.press("Control+b");
+ await page.waitForTimeout(300);
+ await expect(page).toHaveURL(/\/ask$/);
+ expect(await railWidth(page)).toBe(before);
+});
+
+const accountButton = (page: import("@playwright/test").Page) => page.getByRole("button", { name: "Account menu" });
+
+test("avatar menu has account actions", async ({ page }) => {
+ await signIn(page);
+ await page.goto("/home");
+ await accountButton(page).click();
+ await expect(page.getByRole("menuitem")).toHaveText(["Settings", "API keys", "Log out"]);
+ await expect(sidebar(page).getByText("e2e@lumen.test")).toHaveCount(0);
+});
+
+test("avatar menu does not move the page", async ({ page }) => {
+ await signIn(page);
+ for (const p of ["/ask", "/home"]) {
+ await page.goto(p);
+ const main = page.locator("#main");
+ await expect(accountButton(page)).toBeVisible();
+ const before = await main.boundingBox();
+ await accountButton(page).click();
+ await expect(page.getByRole("menu")).toBeVisible();
+ const after = await main.boundingBox();
+ expect(after!.x, p).toBeCloseTo(before!.x, 0);
+ expect(after!.width, p).toBeCloseTo(before!.width, 0);
+ await page.keyboard.press("Escape");
+ }
+});
+
+test("log out returns to the landing page", async ({ page }) => {
+ await signIn(page);
+ await page.goto("/home");
+ await accountButton(page).click();
+ await page.getByRole("menuitem", { name: "Log out" }).click();
+ await expect(page).toHaveURL(/localhost:3100\/$/);
+});
+
+test("body is never its own scroll container (it would lose its scrollbar when a menu opens)", async ({ page }) => {
+ await signIn(page);
+ for (const p of ["/ask", "/home"]) {
+ await page.goto(p);
+ await expect(accountButton(page)).toBeVisible();
+ expect(await page.evaluate(() => getComputedStyle(document.body).overflowY), p).toBe("visible");
+ }
+});
+
+test("review badge updates after a decision on the same page", async ({ page }) => {
+ await signIn(page, { reviewCount: 2 });
+ await page.goto("/receipts?tab=review");
+ const badge = sidebar(page).locator('[data-sidebar="menu-badge"]');
+ await expect(badge).toHaveText("2");
+ await page.getByRole("button", { name: "Approve" }).first().click();
+ await expect(badge).toHaveText("1");
+});
+
+test("mobile: tapping a link closes the sidebar sheet", async ({ page }) => {
+ await page.setViewportSize({ width: 375, height: 800 });
+ await signIn(page);
+ await page.goto("/home");
+ await page.getByRole("button", { name: "Toggle Sidebar" }).click();
+ const sheet = page.locator('[data-mobile="true"]');
+ await expect(sheet).toBeVisible();
+ await sheet.getByRole("link", { name: "Spending" }).click();
+ await expect(page).toHaveURL(/\/spending$/, { timeout: 20_000 }); // first dev compile of /spending is slow
+ await expect(sheet).toBeHidden();
+});
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)/layout.tsx b/frontend/src/app/(app)/layout.tsx
index 7656b2e..45ebc15 100644
--- a/frontend/src/app/(app)/layout.tsx
+++ b/frontend/src/app/(app)/layout.tsx
@@ -5,7 +5,7 @@ import { MotionConfig } from "motion/react";
import { AppSidebar } from "@/components/app-sidebar";
import { AuthGuard } from "@/components/auth/auth-guard";
import { ProfileProvider } from "@/components/profile/profile-provider";
-import { Separator } from "@/components/ui/separator";
+import { AvatarMenu } from "@/components/avatar-menu";
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
// The signed-in app's frame: auth check, sidebar and top bar are built once and stay mounted while you move between
@@ -18,14 +18,17 @@ export default function AppLayout({ children }: { children: ReactNode }) {
-
+ {/* Only ever rendered client-side (the auth guard shows a loader on the server), so reading the remembered
+ state straight from the cookie gives the right width on the sidebar's first paint: no jump, no SSR read. */}
+
-
-
Lumen
+
+
+
{children}
diff --git a/frontend/src/app/(app)/receipts/page.tsx b/frontend/src/app/(app)/receipts/page.tsx
new file mode 100644
index 0000000..66fb472
--- /dev/null
+++ b/frontend/src/app/(app)/receipts/page.tsx
@@ -0,0 +1,9 @@
+import type { Metadata } from "next";
+import { ReceiptsTabs } from "./receipts-tabs";
+
+export const metadata: Metadata = { title: "Receipts & statements" };
+
+// Interim (UI revamp Phase 1): the old Documents, Import and Review pages as tabs until Phase 5 redesigns this page.
+export default function Page() {
+ return ;
+}
diff --git a/frontend/src/app/(app)/receipts/receipts-tabs.tsx b/frontend/src/app/(app)/receipts/receipts-tabs.tsx
new file mode 100644
index 0000000..783edcb
--- /dev/null
+++ b/frontend/src/app/(app)/receipts/receipts-tabs.tsx
@@ -0,0 +1,42 @@
+"use client";
+
+import Link from "next/link";
+import { useSearchParams } from "next/navigation";
+import { cn } from "@/lib/utils";
+import DocumentsContent from "../documents/documentsContent";
+import ImportContent from "../import/importContent";
+import ReviewContent from "../review/reviewContent";
+
+const TABS = [
+ { id: "documents", label: "Documents", Content: DocumentsContent },
+ { id: "import", label: "Import", Content: ImportContent },
+ { id: "review", label: "Review", Content: ReviewContent },
+] as const;
+
+// Reads ?tab= on the client so /receipts stays a static page (no server render per visit).
+export function ReceiptsTabs() {
+ const tab = useSearchParams().get("tab");
+ const active = TABS.find((t) => t.id === 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)/review/reviewContent.tsx b/frontend/src/app/(app)/review/reviewContent.tsx
index 70ea69d..6806ff2 100644
--- a/frontend/src/app/(app)/review/reviewContent.tsx
+++ b/frontend/src/app/(app)/review/reviewContent.tsx
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { filesApi, reviewApi, type ReviewItem } from "@/lib/api/client";
+import { eventBus, EVENTS } from "@/lib/events";
import { toast } from "@/lib/toast";
function errorMessage(error: unknown, fallback: string): string {
@@ -36,6 +37,7 @@ export default function ReviewContent() {
const done = await reviewApi.decide(item.id, decision, { edits });
toast.success(decision === "approve" ? `Approved${done.flags.length ? " (with warnings)" : ""}` : "Rejected");
setItems((list) => list.filter((i) => i.id !== item.id));
+ eventBus.emit(EVENTS.REVIEW_DECIDED);
} catch (error) {
toast.error(errorMessage(error, "Couldn't save the decision."));
}
diff --git a/frontend/src/app/(app)/settings/settingsContent.tsx b/frontend/src/app/(app)/settings/settingsContent.tsx
index 0d8ecd4..b980ac2 100644
--- a/frontend/src/app/(app)/settings/settingsContent.tsx
+++ b/frontend/src/app/(app)/settings/settingsContent.tsx
@@ -179,7 +179,7 @@ export default function SettingsContent() {
description="Add your own model keys. Without one, Lumen's free allowance applies."
eyebrow="Account"
>
-
+
{data?.providers.map((p) => (
))}
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/globals.css b/frontend/src/app/globals.css
index 06a2c94..2012ca9 100644
--- a/frontend/src/app/globals.css
+++ b/frontend/src/app/globals.css
@@ -172,9 +172,11 @@
* {
@apply border-border outline-ring/50;
}
+ /* clip, not hidden: `hidden` turns into its own scroll container (overflow-y becomes auto), whose
+ scrollbar then vanishes when a menu locks scrolling and shifts the page (seen on Ask Lumen). */
html,
body {
- overflow-x: hidden;
+ overflow-x: clip;
}
/* only in the app: on the dark public pages an unused gutter shows as a seam beside full-bleed layouts */
:root:has([data-app-theme="sky"]) {
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() {