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() {