diff --git a/.gitignore b/.gitignore index 0b758ae..8a48f7d 100644 --- a/.gitignore +++ b/.gitignore @@ -101,3 +101,6 @@ graphify-out/ # Retrieval model files (rag/embed.py MODEL_CACHE), fetched at build time backend/.models/ +.superpowers/ +frontend/test-results/ +frontend/playwright-report/ diff --git a/docs/CODEBASE-GUIDE.md b/docs/CODEBASE-GUIDE.md index 2481949..aa8dd78 100644 --- a/docs/CODEBASE-GUIDE.md +++ b/docs/CODEBASE-GUIDE.md @@ -259,6 +259,19 @@ embeds the demo documents once per server and uploads the files in the backgroun `globals.css`). `components/dashboard-shell.tsx` is just a page's content wrapper and optional header card. - There is no root `loading.tsx`: the pages are static and prefetched, and a full-screen spinner made every click look like a reload. +- **Sky theme (UI revamp, `docs/superpowers/specs/2026-10-20-ui-revamp-design.md`).** `app/(app)/layout.tsx` wraps + the shell in `
`. In `globals.css`, `:root:has([data-app-theme="sky"])` + swaps every shadcn colour token to the light Sky palette, so portaled menus and dialogs match too. The + `dark:` variant is also switched off while the shell is mounted. Public pages keep the forced dark theme. + Extra tokens: `good`, `bad`, `accent-soft`, `--band`. Type roles: `font-serif` (Newsreader), `font-label` / + `.label-mono` (IBM Plex Mono labels). +- **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`. +- **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`. ## 7c. Ask Lumen screen (frontend, UI phase) - `/chatbot` (`app/chatbot/chatbotContent.tsx`) is the one chat with the agent (the old `/agent` test page is gone). diff --git a/docs/direction/STORY.md b/docs/direction/STORY.md index 6239667..e71d065 100644 --- a/docs/direction/STORY.md +++ b/docs/direction/STORY.md @@ -311,3 +311,18 @@ with the numbers and the files that prove them. Newest last. Raw numbers live in the old regime's slabs ("first ₹2.5 lakh at 0%"). Fixing that, and L05 (unused subscriptions) and L08 (savings goal), needs a prompt/tool change and a new recording. Story point: a grader bug can hide a dishonest answer as easily as it can fail a right one. + +## 27. Stopping overlays from nudging the page (UI revamp, Phase 0) +- **S:** Opening the profile menu shifted the Ask Lumen chat column sideways. Every menu, dialog, select and sheet in + the app did the same, because Radix's scroll lock adds a margin equal to the scrollbar width. +- **T:** Fix it once for the whole app, prove it stays fixed, and lay the light "Sky" theme over the signed-in app + without touching the dark public pages. +- **A:** A dev-only harness page with one of each overlay and a Playwright check that measures the content column + before and after opening each one. The headless browser's hidden scrollbars first masked the bug, so the check + turns them back on. The fix is two CSS rules: a stable scrollbar gutter and cancelling the lock's margin. Sky tokens + are keyed on `:root:has([data-app-theme="sky"])`, so portaled menus match and the `dark:` variant switches off + in-app. One animation library instead of two, with reduced motion honoured globally. +- **R:** Shift dropped from 7.5 px to 0 on 4 of the 5 overlays (the fifth, non-modal, was already fine). + 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. diff --git a/docs/superpowers/plans/2026-10-20-ui-revamp-phase0.md b/docs/superpowers/plans/2026-10-20-ui-revamp-phase0.md new file mode 100644 index 0000000..62bcb56 --- /dev/null +++ b/docs/superpowers/plans/2026-10-20-ui-revamp-phase0.md @@ -0,0 +1,342 @@ +# UI Revamp Phase 0 (Foundations) 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:** Lay the ground every later revamp phase builds on: no layout shift from overlays, the Sky light theme scoped to the signed-in app, the new type roles, and one motion setup that honours reduced-motion. + +**Architecture:** One global CSS fix neutralises Radix's scroll-lock compensation, so every overlay stops nudging content. Sky tokens live in `globals.css` under `:root:has([data-app-theme="sky"])`. That puts them on `` whenever the app shell is mounted, so portaled menus and dialogs pick them up too. The public pages keep the forced dark theme. A dev-only harness page plus Playwright (Chromium and Firefox) proves all of it. + +**Tech Stack:** Next.js 14 app router, Tailwind v4 (`@theme inline`), shadcn/Radix, `next/font`, `motion` v12 (`motion/react`), `@playwright/test` (dev only). + +**Spec:** `docs/superpowers/specs/2026-10-20-ui-revamp-design.md` (sections 5, 6; phase 0 of section 10). Mockup: `docs/superpowers/specs/2026-10-20-ui-revamp/home-mockup.html`. + +## Global Constraints + +- Sky is light only and scoped to the signed-in `(app)` route group. Landing, login and onboarding stay dark, and the root layout's forced `dark` class remains. +- Fonts: Funnel Sans (UI and numbers), Newsreader (serif headlines and brief), IBM Plex Mono (labels, 10.5px, 0.12em tracking, uppercase). Load all of them through `next/font` with no FOUT-driven shift. +- Motion: 150–250ms, ease-out, opacity and transform only. Everything off under `prefers-reduced-motion`. Use the single library `motion` and remove `framer-motion`. +- Zero layout-shift rule: an interaction in one region may not change the size or position of another region. +- Playwright is a dev dependency and runs locally only. Do not add it to `.github/workflows/ci.yml`. +- Git: branch from `refactor`. Do not use "claude" in branch names. Open one PR into `refactor` on `coderconnoisseur/Lumen`. Keep `docs/CODEBASE-GUIDE.md` current. + +## Review Focus + +1. **Portaled overlays outside the app wrapper** (menus, dialogs, selects render into ``): they must use Sky colours, not the dark theme. Task 2 test `portaled menu is sky`. +2. **Firefox on Windows** (the owner's browser, where the bug was seen) has classic scrollbars. The shift test runs in Firefox as well as Chromium. Task 1 config. +3. **Muted text contrast:** the mockup's `#7c7a74` is only 3.9:1 on the page background. The plan uses `#6b6963` (4.9:1 on bg, 4.7:1 on sidebar). Task 2 test `muted text meets AA`. +4. **Public pages must stay dark:** Sky must not leak onto `/login`. Task 2 test `login stays dark`. +5. **Reduced motion:** transitions must collapse to near zero. Task 3 test `reduced motion kills transitions`. + +## File map + +| File | Responsibility | +|---|---| +| `frontend/playwright.config.ts` (new) | Two projects (chromium, firefox), dev server on port 3100 with dummy public env | +| `frontend/e2e/foundations.spec.ts` (new) | All Phase 0 checks | +| `frontend/src/app/dev/layout-shift/page.tsx` (new) | Dev-only harness: Sky wrapper, centred column, tall content, one of each overlay. Returns `notFound()` in production | +| `frontend/src/app/globals.css` | Scroll-lock neutraliser, Sky token block, label utility, reduced-motion rule, font theme vars | +| `frontend/src/app/layout.tsx` | Add IBM Plex Mono via `next/font` | +| `frontend/src/app/(app)/layout.tsx` | `data-app-theme="sky"` on the shell; `MotionConfig reducedMotion="user"` | +| `frontend/src/lib/motion.ts` (new) | Shared durations and easing | +| `frontend/src/components/ai-analytics/*.tsx` (7 files) | `framer-motion` → `motion/react` | +| `frontend/package.json` | `+@playwright/test` (dev), `-framer-motion`, script `test:e2e` | +| `docs/CODEBASE-GUIDE.md` | Note the theme scope, harness and e2e command | + +--- + +### Task 1: Layout-shift guard (harness, failing test, global fix) + +**Files:** +- Create: `frontend/playwright.config.ts`, `frontend/e2e/foundations.spec.ts`, `frontend/src/app/dev/layout-shift/page.tsx` +- Modify: `frontend/src/app/globals.css` (inside `@layer base`), `frontend/package.json` + +**Interfaces:** +- Produces: harness route `/dev/layout-shift` with test ids `probe` (the centred column), and `open-dropdown`, `open-dialog`, `open-select`, `open-popover`, `open-sheet` (triggers). It also has `transition-probe`, an element with `transition: opacity 200ms`, which Task 3 uses. The npm script `test:e2e` runs `playwright test`. Later tasks add tests to `e2e/foundations.spec.ts`. + +- [ ] **Step 1: Install and configure Playwright** + + 1. Run `npm i -D @playwright/test` and then `npx playwright install chromium firefox`. + 2. Add `"test:e2e": "playwright test"` to `package.json` scripts. + 3. Write `playwright.config.ts` with these settings: + - `testDir: "e2e"` + - `use.baseURL: "http://localhost:3100"` + - `projects: [{name:"chromium",use:devices["Desktop Chrome"]},{name:"firefox",use:devices["Desktop Firefox"]}]` + - `webServer: { command: "npx next dev -p 3100", url: "http://localhost:3100/dev/layout-shift", reuseExistingServer: false, timeout: 180_000, env: { NEXT_PUBLIC_SUPABASE_URL: "http://127.0.0.1:54321", NEXT_PUBLIC_SUPABASE_ANON_KEY: "e2e-dummy", NEXT_PUBLIC_BACKEND_URL: "http://127.0.0.1:9" } }` + + There is no `.env.local` in the repo. The dummy values only have to let `createClient` construct. If the Next dev error overlay still appears because a backend or Supabase call fails, make that call fail quietly at its source. Do not hide the overlay. + +- [ ] **Step 2: Write the harness page `src/app/dev/layout-shift/page.tsx`** + + - Make it a client component that renders inside a wrapper `
`. + - At render time, if `process.env.NODE_ENV === "production"`, call `notFound()`. + - Inside the wrapper, add `
`. Fill it with about 3000px of paragraphs so the page scrolls. + - Put one trigger of each kind at the top of `main`, using the existing wrappers in `@/components/ui/*`: DropdownMenu, Dialog, Select, Popover and Sheet, each with the test ids listed above. + - Add `
`. + +- [ ] **Step 3: Write the failing test** + +```ts +// e2e/foundations.spec.ts +import { test, expect } from "@playwright/test"; + +const overlays = [ + ["open-dropdown", '[role="menu"]'], + ["open-dialog", '[role="dialog"]'], + ["open-select", '[role="listbox"]'], + ["open-popover", "[data-radix-popper-content-wrapper]"], + ["open-sheet", '[role="dialog"]'], +] as const; + +for (const [trigger, surface] of overlays) { + test(`opening ${trigger} does not move the page`, async ({ page }) => { + await page.goto("/dev/layout-shift"); + const probe = page.getByTestId("probe"); + const before = await probe.boundingBox(); + await page.getByTestId(trigger).click(); + await expect(page.locator(surface).first()).toBeVisible(); + const after = await probe.boundingBox(); + expect(after!.x).toBeCloseTo(before!.x, 0); + expect(after!.width).toBeCloseTo(before!.width, 0); + }); +} +``` + +- [ ] **Step 4: Run it and confirm it fails** + + Run `npm run test:e2e -- -g "does not move"`. The expected result is FAIL on at least the modal overlays (dropdown, dialog, select, sheet), where `x` or `width` differs by roughly the scrollbar width. + + If nothing fails, the harness is not reproducing the bug. Check that the page really scrolls and that the overlays are modal before you go on. Do not skip this check. + +- [ ] **Step 5: Add the global fix in `globals.css` `@layer base`** + +```css +html { scrollbar-gutter: stable; } +/* Radix modals lock scroll via react-remove-scroll, which adds margin/padding equal to the scrollbar width and + shifts centred content. The gutter already reserves that space, so cancel the compensation. Higher specificity + than the runtime-injected `body[data-scroll-locked]` rule, which is appended after this sheet. */ +html body[data-scroll-locked] { margin-right: 0 !important; padding-right: 0 !important; } +``` + +- [ ] **Step 6: Run the tests and confirm they pass in both browsers** + + Run `npm run test:e2e -- -g "does not move"`. The expected result is 10 passed (5 overlays × 2 browsers). + + If any test still fails, debug it with superpowers:systematic-debugging before you change the approach. Do not patch individual components with `modal={false}` unless the root cause is component-specific. + +- [ ] **Step 7: Confirm by hand in the real app** + + Run `npm run dev` with the real env, start a demo session, open Ask Lumen and open the profile menu. The chat column must not move. Check in Firefox. + +- [ ] **Step 8: Commit** + +```bash +git add frontend/playwright.config.ts frontend/e2e frontend/src/app/dev frontend/src/app/globals.css frontend/package.json frontend/package-lock.json +git commit -m "fix: overlays no longer shift page content; add layout-shift e2e guard" +``` + +--- + +### Task 2: Sky visual system (tokens, fonts, label style) + +**Files:** +- Modify: `frontend/src/app/globals.css`, `frontend/src/app/layout.tsx`, `frontend/src/app/(app)/layout.tsx` +- Test: `frontend/e2e/foundations.spec.ts` + +**Interfaces:** +- Consumes: the harness from Task 1, whose wrapper already carries `data-app-theme="sky"`. +- Produces: + - The shadcn variables (`--background`, `--card`, …) resolve to Sky wherever `[data-app-theme="sky"]` is mounted. + - New tokens: `--good`, `--bad`, `--accent-soft`, `--band`. + - Tailwind utilities `bg-good`, `text-good`, `bg-bad`, `text-bad`, `bg-accent-soft`, `font-serif` (Newsreader), `font-label` (IBM Plex Mono), plus a `.label-mono` utility class. + - Later phases use only these names. + +- [ ] **Step 1: Write the failing tests** + +```ts +// helper in foundations.spec.ts: any CSS colour -> [r,g,b] via canvas (works for oklch) +async function rgb(page, css: string) { + return page.evaluate((c) => { const x = document.createElement("canvas").getContext("2d")!; + x.fillStyle = c; x.fillRect(0, 0, 1, 1); return Array.from(x.getImageData(0, 0, 1, 1).data.slice(0, 3)); }, css); +} +const ratio = (a: number[], b: number[]) => { const L = (c: number[]) => { const f = (v: number) => { v /= 255; + return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }; return 0.2126*f(c[0])+0.7152*f(c[1])+0.0722*f(c[2]); }; + const [h, l] = [L(a), L(b)].sort((x, y) => y - x); return (h + 0.05) / (l + 0.05); }; + +test("sky tokens apply to page", async ({ page }) => { + await page.goto("/dev/layout-shift"); + const bg = await page.evaluate(() => getComputedStyle(document.body).backgroundColor); + expect(await rgb(page, bg)).toEqual([244, 243, 239]); + expect(await page.evaluate(() => getComputedStyle(document.documentElement).colorScheme)).toBe("light"); +}); +test("portaled menu is sky", async ({ page }) => { + await page.goto("/dev/layout-shift"); + await page.getByTestId("open-dropdown").click(); + const bg = await page.locator('[role="menu"]').evaluate((e) => getComputedStyle(e).backgroundColor); + expect(await rgb(page, bg)).toEqual([255, 255, 255]); +}); +test("muted text meets AA", async ({ page }) => { + await page.goto("/dev/layout-shift"); + const v = await page.evaluate(() => { const s = getComputedStyle(document.documentElement); + return [s.getPropertyValue("--muted-foreground"), s.getPropertyValue("--background"), s.getPropertyValue("--sidebar")]; }); + const [m, bg, side] = await Promise.all(v.map((c) => rgb(page, c))); + expect(ratio(m, bg)).toBeGreaterThanOrEqual(4.5); + expect(ratio(m, side)).toBeGreaterThanOrEqual(4.5); +}); +test("login stays dark", async ({ page }) => { + await page.goto("/login"); + const [r] = await rgb(page, await page.evaluate(() => getComputedStyle(document.body).backgroundColor)); + expect(r).toBeLessThan(60); +}); +test("type roles load", async ({ page }) => { + await page.goto("/dev/layout-shift"); + expect(await page.evaluate(() => getComputedStyle(document.body).fontFamily)).toMatch(/Funnel/i); +}); +``` + +- [ ] **Step 2: Run them and confirm they fail** + + Run `npm run test:e2e -- -g "sky|portaled|muted|login|type roles"`. The expected result is FAIL on `sky tokens`, `portaled`, `muted` and `type roles`. `login stays dark` should already pass. + +- [ ] **Step 3: Add the Sky block to `globals.css`, after the `.dark {}` block** + + Use the selector `:root:has([data-app-theme="sky"])` and add `color-scheme: light !important;`. The `!important` is needed to beat the inline `style={{colorScheme:"dark"}}` on ``. Set these values (from the spec; `--muted-foreground` is darkened for contrast, see Review Focus 3): + +| Variable | Value | +|---|---| +| `--background` | `oklch(0.964 0.005 95)` (#f4f3ef) | +| `--foreground`, `--card-foreground`, `--popover-foreground`, `--secondary-foreground`, `--accent-foreground`, `--sidebar-foreground`, `--sidebar-accent-foreground` | `oklch(0.222 0.002 107)` (#1b1b1a) | +| `--card`, `--popover`, `--primary-foreground`, `--sidebar-primary-foreground` | `oklch(1 0 0)` | +| `--primary`, `--ring`, `--sidebar-primary`, `--sidebar-ring`, `--chart-1` | `oklch(0.564 0.185 261)` (#2f6fe0) | +| `--secondary`, `--muted`, `--accent`, `--input`, `--sidebar-accent` | `oklch(0.939 0.007 97)` (#ecebe6) | +| `--muted-foreground` | `#6b6963` | +| `--border`, `--sidebar-border` | `oklch(0.919 0.008 91)` (#e6e4de) | +| `--sidebar` | `oklch(0.948 0.007 97)` (#efeee9) | +| `--accent-soft` | `oklch(0.932 0.027 262)` (#dfe9fb) | +| `--good` | `oklch(0.569 0.119 155)` (#2e8b57) | +| `--bad`, `--destructive` | `oklch(0.588 0.150 40)` (#c4572f) | +| `--band` | `linear-gradient(180deg, #cfe2fb 0%, #e9f1fb 55%, #f4f3ef 100%)` | + + Leave `--chart-2` to `--chart-5` as they are. In the same block, set `font-family: var(--font-ui), ui-sans-serif, system-ui, sans-serif` on `body`. The `@layer base` body gradient is dark-tuned, so set `background-image: none` on the body for Sky. + +- [ ] **Step 4: Add the theme vars and the label utility** + + In `@theme inline`, add: + - `--color-good: var(--good)` + - `--color-bad: var(--bad)` + - `--color-accent-soft: var(--accent-soft)` + - `--font-serif: var(--font-newsreader), Georgia, serif` + - `--font-label: var(--font-plex-mono), ui-monospace, monospace` + + In `@layer utilities`, add `.label-mono { font-family: var(--font-plex-mono), ui-monospace, monospace; font-size: 10.5px; letter-spacing: 0.12em; text-transform: uppercase; font-weight: 500; }`. + +- [ ] **Step 5: Load IBM Plex Mono in `src/app/layout.tsx`** + + Call `IBM_Plex_Mono({ subsets: ["latin"], weight: ["400", "500"], variable: "--font-plex-mono" })` and add its `.variable` to the `` className. Add `"500"` to Newsreader's weights, because the brief and headlines use 400 and 500. + +- [ ] **Step 6: Mark the app shell in `src/app/(app)/layout.tsx`** + + Wrap the `` subtree in `
`. The `contents` class keeps it out of the layout. + +- [ ] **Step 7: Run the tests and confirm they pass** + + Run `npm run test:e2e`. Everything should pass in both browsers. Then look at `/dashboard` in a demo session. Old dark-tuned components will look rough on light, and that is expected because Phases 1 to 5 replace them. But nothing may be unreadable: no white-on-white text. List any spots you find in the PR description. + +- [ ] **Step 8: Commit** + +```bash +git add frontend/src/app/globals.css frontend/src/app/layout.tsx "frontend/src/app/(app)/layout.tsx" frontend/e2e/foundations.spec.ts +git commit -m "feat: Sky light theme scoped to the app shell, label/serif type roles" +``` + +--- + +### Task 3: Motion foundation (one library, shared timings, reduced motion) + +**Files:** +- Create: `frontend/src/lib/motion.ts` +- Modify: the 7 files in `frontend/src/components/ai-analytics/` that import `framer-motion`; `frontend/src/app/(app)/layout.tsx`; `frontend/src/app/globals.css`; `frontend/package.json` +- Test: `frontend/e2e/foundations.spec.ts` + +**Interfaces:** +- Produces, in `@/lib/motion`: + - `export const DURATION = { fast: 0.15, base: 0.2, slow: 0.25 } as const` (seconds, motion-style) + - `export const EASE_OUT = [0.22, 1, 0.36, 1] as const` + - `export const fadeUp = { initial: { opacity: 0, y: 6 }, animate: { opacity: 1, y: 0 }, transition: { duration: DURATION.base, ease: EASE_OUT } }` + - Later phases import only these. + +- [ ] **Step 1: Write the failing test** + +```ts +test("reduced motion kills transitions", async ({ browser }) => { + const ctx = await browser.newContext({ reducedMotion: "reduce" }); + const page = await ctx.newPage(); + await page.goto("/dev/layout-shift"); + const d = await page.getByTestId("transition-probe").evaluate((e) => parseFloat(getComputedStyle(e).transitionDuration)); + expect(d).toBeLessThan(0.01); + await ctx.close(); +}); +``` + + Playwright's `browser` fixture has no `baseURL` in a new context. Pass `baseURL` to `newContext` from `test.info().project.use.baseURL`. + +- [ ] **Step 2: Run it and confirm it fails** + + Run `npm run test:e2e -- -g "reduced motion"`. The expected result is FAIL, with a duration of 0.2. + +- [ ] **Step 3: Add the global reduced-motion rule to `globals.css`** + +```css +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; + transition-duration: .01ms !important; scroll-behavior: auto !important; } +} +``` + +- [ ] **Step 4: Create `src/lib/motion.ts` with the Interfaces exports** + + In `(app)/layout.tsx`, wrap the shell children in ``, imported from `motion/react`. + +- [ ] **Step 5: Swap the library** + + Replace `from "framer-motion"` with `from "motion/react"` in the 7 `ai-analytics` files. The API is the same. Then run `npm uninstall framer-motion`. + +- [ ] **Step 6: Verify** + + Run each of these: + - `npm run test:e2e`: all tests pass. + - `npx tsc --noEmit`: no errors. + - `npm run lint`: clean. + - `npm run build`: succeeds, and `/dev/layout-shift` returns 404 in `npm start`. + - `grep -r "framer-motion" src package.json`: no output. + +- [ ] **Step 7: Update `docs/CODEBASE-GUIDE.md`, then commit** + + Add three things to the guide: + - How the theme scope works: `data-app-theme="sky"` and `:root:has(...)`. + - The harness route and `npm run test:e2e`. + - The `@/lib/motion` constants. + +```bash +git add frontend docs/CODEBASE-GUIDE.md +git commit -m "feat: single motion library, shared timings, reduced-motion support" +``` + +--- + +### Task 4: Ship Phase 0 + +- [ ] **Step 1:** Push the branch, for example `ui-revamp/phase0-foundations` cut from `refactor`. Open a PR into `refactor` with the spec, this plan and the mockup files. In the description, list the tests and their output, plus the before and after of the manual profile-menu check. +- [ ] **Step 2:** Add a STAR entry to `docs/direction/STORY.md`. Situation: overlays shifted content app-wide. Result: 10/10 overlay shift checks pass in two browsers. + +--- + +## Later phases (each gets its own plan when we reach it) + +| Phase | Plan file (to write) | Depends on | +|---|---|---| +| 1 App shell: sidebar IA, icon-rail collapse + Ctrl/⌘B, avatar menu, redirects | `…-phase1-shell.md` | Phase 0 tokens and type | +| 2 Home UI from the mockup, empty states for new cards | `…-phase2-home-ui.md` | Phase 1 shell | +| 3 Home backend: summary + "Lumen found", activity, budget + `set_budget` proposals | `…-phase3-home-backend.md` | Phase 2 card contracts | +| 4 Ask Lumen: chat threads (migration), fresh chat + history, handoff, "Next:" fix | `…-phase4-ask.md` | Phase 1 shell | +| 5 Inner pages, with a mockup round each | per page | Phases 1–2 | +| 6 Performance with measured before/after | `…-phase6-perf.md` | all | diff --git a/docs/superpowers/specs/2026-10-20-ui-revamp-design.md b/docs/superpowers/specs/2026-10-20-ui-revamp-design.md new file mode 100644 index 0000000..7b9de5b --- /dev/null +++ b/docs/superpowers/specs/2026-10-20-ui-revamp-design.md @@ -0,0 +1,205 @@ +# Lumen inner-app UI/UX revamp: design + +Status: **DRAFT for owner review** · 2026-10-20 · supersedes the "no new design system" line in +`docs/direction/specs/SPEC-UX.md` (its evidence-first chat and review-queue goals still stand). +Reference mockup: [`2026-10-20-ui-revamp/home-mockup.html`](2026-10-20-ui-revamp/home-mockup.html) +(screenshot: `home-mockup.png`). Use the **Sky** variant; the Ink variant is rejected. + +## 1. Why + +The signed-in app still carries its November 2025 skeleton: a stock shadcn sidebar ("Financial command +center", "Workspace", "Compare"), tabs that no longer match what the app does (Analytics and Spending Views +open the same page, "Review (test)", a separate Import page), and an Overview that looks like every +AI-generated dashboard. The owner wants a full rethink (information architecture, layout, palette, motion), +not a re-skin. Inspiration: Origin Financial's app (useorigin.com): dense, warm, every card says something +specific, serif + mono-label typography, a sky motif that our landing page already uses. + +Success = opening Lumen feels like a calm, specific briefing on *your* money, not a template; nothing on screen +moves unless you asked it to; and it stays light on the free Vercel tier. + +## 2. Goals / non-goals / anti-goals + +**Goals** +- New information architecture and sidebar (section 3). +- A new Home built from the mockup (section 4). +- A design system: Sky palette, type scale, spacing, motion rules (sections 5–6), applied to every inner page. +- Ask Lumen touch-up: fresh chat on open + history list; Home Ask bar hands off smoothly (section 7). +- A **zero layout-shift rule** enforced app-wide, starting with the known bug (section 6.3). +- Frontend performance pass (section 9). + +**Non-goals** +- Bank/card linking, net worth, credit score, investments, financial-planner booking, paywalls. Every number + comes from imported statements, receipts and invoices already in Lumen. +- Landing page, login and onboarding (already redesigned; only re-tint to share tokens if trivial). +- The BYOK feature itself (another session builds it). This revamp only gives it a home: the avatar menu. + +**Anti-goals** (fail even if it works) +- Generic KPI-card grid, gradient-blob "AI dashboard" look. +- Any click on one region visibly nudging another region (the profile-menu bug class). +- Invented numbers or scores. A card with no real data shows an honest empty state, not a placeholder figure. +- Bouncy or slow animation; anything that ignores `prefers-reduced-motion`. + +## 3. Information architecture (owner-decided) + +``` +Lumen + Home +TRACK + Spending ← merges /analytics (+weekly/monthly/yearly tabs) and /ai-analytics charts + Bills & subscriptions ← recurring charges, payment calendar, reminders +PLAN + Budget (NEW) + Forecast (later; shown only once built) +─────────── + Receipts & statements 3 ← merges /documents, /import, /review; badge = items needing review + Ask Lumen ⌘K +``` + +- Group labels describe what the user does (owner rule: never name groups after backend concepts like + "AI" or "Data"). **Insights is not a tab**: its content becomes Home's "Lumen found" cards and the Spending + page. +- **Profile, settings, API keys (BYOK), theme, log out → top-right avatar menu.** The sidebar is navigation + only. +- **Collapse:** icon rail (~56px) with tooltips, toggled by a button and Ctrl/⌘+B, remembered across visits + (the existing shadcn sidebar cookie is fine). The content area animates width; content must not jump. +- Mobile (<768px): the sidebar becomes a sheet (existing shadcn behaviour). Builder's call on adding a + bottom tab bar. +- Old URLs (`/dashboard`, `/analytics`, `/ai-analytics`, `/chatbot`, `/documents`, `/import`, `/review`, + `/upload`) redirect to their new homes so bookmarks and the demo keep working. New route names are the + builder's call (suggested: `/home`, `/spending`, `/bills`, `/budget`, `/receipts`, `/ask`). + Until Phase 5 redesigns them, the new URLs render the existing page content (Spending = analytics + + ai-analytics stacked; Receipts = documents + import + review as tabs). Budget's nav item appears only once + its page exists (my call). + +## 4. Home (owner-approved content) + +Layout per the mockup: greeting row (left) + icon buttons and avatar (right); sub-tabs +**Overview · This month · Year**; a one- or two-sentence brief in Newsreader; then a two-column grid +(main ~1fr, side ~330px) over a sky-tinted header band that fades into the page. + +| Card | Content | Data | +|---|---|---| +| Brief | e.g. "October is running 9% lighter than September. The one thing worth a look: food delivery is up ₹2,140, and Netflix renews on Friday." | **Rule-based, no LLM per page load.** New backend: a summary endpoint picks the top fact or two from month-to-date vs last month, the biggest category change, and the next bill. | +| Spent this month | Cumulative spend line for this month vs last month (dashed) plus a dotted forecast to month-end, with bill pins on the future part. Range chips 1W/1M/3M/YTD. | Existing `/api/analytics/dashboard` + `/forecast`; recurring bills for the pins. | +| Lumen found | "N things worth ₹X this month", then up to 3 action cards: mono TAG, serif number-led headline, one line of why, a link. Kinds: unused/new subscriptions, category spikes, receipts needing review, duplicate charges. | New aggregation endpoint over the existing anomalies, insights and recurring detection and the review queue. ₹ "worth" only where a real saving exists; otherwise the headline carries no amount. | +| Spending calendar + Recent transactions | Month heat-grid (darker = more spent, amount in cell) beside the last 5 transactions. | Existing invoice/transaction data. | +| Budget **(NEW)** | "₹spent of ₹budget", progress bar, "₹left · N days · ~₹/day". Empty state → the draft-budget card. | New budget model + API (section 8). | +| Let Lumen draft your budget **(NEW)** | Promo card; drafts per-category limits from 3 months of spending; user approves each line before save (Lumen's propose-then-approve rule). | Reuses the agent proposal/approve flow. | +| Upcoming bills | Next 3 recurring charges with date chips. | Existing recurring detection / reminders. | +| Activity | Imports, receipts read, new subscriptions spotted, with relative times. | New lightweight feed from created_at timestamps; no new event table unless needed. | + +Deferred: the "+" ask-about-this button on every card (owner: maybe later). The Ask bar lives in the sidebar +("Ask anything ⌘K"); typing there and pressing Enter routes to Ask Lumen with the question already +running (section 7). + +## 5. Visual system: Sky (owner-decided) + +Tokens from the mockup, to be re-expressed as `oklch` CSS variables in `globals.css` (replacing the current +blue-grey dark theme): + +| Token | Value | Use | +|---|---|---| +| bg | `#f4f3ef` | page (warm paper) | +| side | `#efeee9` | sidebar | +| card | `#ffffff` | cards | +| line | `#e6e4de` | borders, dividers | +| text / mute | `#1b1b1a` / `#7c7a74` | body / secondary | +| accent / accentSoft | `#2f6fe0` / `#dfe9fb` | links, chart line, selection / promo cards | +| good / bad | `#2e8b57` / `#c4572f` | deltas, bill pins | +| band | `linear-gradient(#cfe2fb → #e9f1fb → bg)` | sky header band, Home only (builder may reuse on other page headers) | + +Light theme only for this revamp, scoped to the signed-in `(app)` route group; the root layout's forced +`dark` stays for landing/login/onboarding (my call). No "theme" item in the avatar menu yet. Ink (dark) is out; a later dark mode is possible because everything goes +through tokens. + +Type: **Funnel Sans** (UI, numbers), **Newsreader** (brief, card headlines, italics for emphasis), +**IBM Plex Mono** small caps-style labels (10.5px, 0.12em tracking) for card titles, tags and axis ticks. +Load fonts via `next/font` (self-hosted, no layout shift). Radius 14px for cards, 8px for controls. + +## 6. Interaction and motion (owner-decided: quiet and quick) + +### 6.1 Motion rules +- 150–250ms, ease-out; opacity/transform only (never animate layout properties except the sidebar width). +- Route change: content fades/slides 4–8px; the sidebar and header never re-animate. +- Numbers count up once on first load, not on every revisit. +- Ask handoff: the sidebar Ask input morphs into the chat composer (shared element / View Transitions API + where supported; plain fade fallback). +- All motion off under `prefers-reduced-motion`. +- Use **one** animation library (`motion`); drop the duplicate `framer-motion` import path. + +### 6.2 Feedback states +Every async card has a skeleton matching its final size (no jump when data arrives), an empty state with a +next step, and an inline error with retry. Abstaining is a designed state (from SPEC-UX). + +### 6.3 Zero layout-shift rule (owner requirement) +**Known bug:** opening the profile dropdown shifts the chat column left. Root cause: Radix menus/dialogs are +modal by default; `react-remove-scroll` hides the page scrollbar and adds compensating padding, so centered +content moves. This affects every DropdownMenu, Dialog, Select and Popover. +**Fix once, globally:** `html { scrollbar-gutter: stable; }` plus neutralising the scroll-lock padding +(`body[data-scroll-locked] { margin-right: 0 !important; padding-right: 0 !important; }`), and/or +`modal={false}` on non-blocking menus in the shared `ui/` wrappers. +**Rule going forward:** an interaction in one region may not change the size or position of another region. +Overlays float; reserved space for async content; images and fonts sized ahead of time. Verify with a +Playwright check that opens each menu/dialog and asserts that main-content bounding boxes don't change (CLS = 0). +Playwright is a dev-only dependency; the check runs locally against the dev server with demo data and is +**not** a CI gate for now (my call; promote to CI once a seeded signed-in CI app exists). + +## 7. Ask Lumen touch-up (owner-decided) + +- Opening Ask Lumen starts a **fresh chat** with suggested questions (drawn from the current "Lumen found" + items); past chats are in a collapsible history list on the side; one click resumes a chat. +- **Real threads (owner-decided):** add a conversation/thread id to `ChatMessage` (migration) plus endpoints + to list threads, create one, and load one. Existing flat history becomes a single "Earlier chats" thread. + This makes Phase 4 full-stack. +- From the sidebar Ask bar or ⌘K: Enter → navigate to Ask with the question already sent, so the answer + appears as the page settles (Google AI-Overview feel). **No streaming in this revamp (owner-decided):** + show a polished thinking state (work-trace steps animating in), then fade in the full answer. Token + streaming (SSE) is a separate follow-up task. +- Restyle to Sky; keep evidence-first behaviour (citations, work trace, proposal cards) from SPEC-UX. +- Fix the stray "Next:" artefact visible under answers (seen in the 2026-10-20 screenshot). + +## 8. New backend this implies + +1. **Home summary** endpoint: the brief sentences + "Lumen found" items, rule-based, cached per user per day + or invalidated on import. +2. **Budget**: per-user monthly budget with per-category limits (table + CRUD); "draft budget" as an agent + proposal the user approves. +3. **Activity feed**: recent events from existing timestamps. +4. **Chat threads** (section 7). + +Each ships with tests in the existing mocked-LLM suite style. + +Calls made while writing this (not owner-decided; owner may override): +- New endpoints go in FastAPI `backend/api/` (the newer pattern, where agent proposals live), reading the + analytics helpers as functions rather than calling the Flask routes. +- Draft budget = **one proposal per category** using a new `set_budget` proposal type, so the existing + approve/reject-per-proposal flow and card UI work unchanged. "Approve all" is a convenience button. Fits the AI-engineering story: the draft +budget is tool-calling + human approval. + +## 9. Performance pass (last phase) + +- Remove unused heavy dependencies from the app bundle: `three`, `postprocessing`, `lenis` (check landing-page + use first; lazy-load them there if still needed), `mathjs` if unused, the duplicate `framer-motion`. +- Code-split charts (`recharts`) with `next/dynamic`; server-render static shells where possible. +- Measure before/after (bundle size per route, Lighthouse LCP/CLS/INP) and log the numbers as a STAR entry + in `docs/direction/STORY.md`. + +## 10. Phased path (one PR per phase into `refactor`) + +| # | Phase | Contents | +|---|---|---| +| 0 | Foundations | Sky tokens, fonts via next/font, global layout-shift fix + Playwright CLS check, motion rules | +| 1 | App shell | New sidebar IA, icon-rail collapse, top-right avatar menu (settings / BYOK slot / log out), route redirects | +| 2 | Home UI | Mockup → components with real existing data; budget/brief/activity cards render empty states | +| 3 | Home backend | Summary + "Lumen found" endpoint, activity feed, budget model + draft-budget proposal; wire into Home | +| 4 | Ask Lumen | Chat threads (migration + endpoints), fresh chat + history list, Ask-bar handoff with thinking state, restyle, "Next:" fix | +| 5 | Inner pages | Spending, Bills & subscriptions, Budget, Receipts & statements: each gets a short mockup round with the owner before build | +| 6 | Performance | Section 9, with measured before/after | + +Keep `docs/CODEBASE-GUIDE.md` current in each PR. + +## 11. Left to the builder +- Exact route names, component file layout, and whether Home sub-tabs "This month" / "Year" ship in phase 2 or later. +- 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. diff --git a/docs/superpowers/specs/2026-10-20-ui-revamp/home-mockup.html b/docs/superpowers/specs/2026-10-20-ui-revamp/home-mockup.html new file mode 100644 index 0000000..3253f8b --- /dev/null +++ b/docs/superpowers/specs/2026-10-20-ui-revamp/home-mockup.html @@ -0,0 +1,214 @@ + + + + + +
+

Home v2: Origin's density, Lumen's data

+

Same screen in two colour moods. Sky = light paper + blue, close to Origin's app and our landing page sky. Ink = warm near-black + amber. Click one to pick; the full-size version is below. Cards marked NEW are features we'd build (UI then backend). Every + on a card opens Ask Lumen with that card as context. No bank linking anywhere: all numbers come from imported statements and receipts.

+
+ +
+
A · Sky — light, airy, Origin-like
+
B · Ink — dark, warm, private-ledger feel
+
+
Full size
+ + + + + diff --git a/frontend/e2e/foundations.spec.ts b/frontend/e2e/foundations.spec.ts new file mode 100644 index 0000000..461147b --- /dev/null +++ b/frontend/e2e/foundations.spec.ts @@ -0,0 +1,115 @@ +import { test, expect, type Page } from "@playwright/test"; + +const overlays = [ + ["open-dropdown", '[role="menu"]'], + ["open-dialog", '[role="dialog"]'], + ["open-select", '[role="listbox"]'], + ["open-popover", "[data-radix-popper-content-wrapper]"], + ["open-sheet", '[role="dialog"]'], +] as const; + +for (const [trigger, surface] of overlays) { + test(`opening ${trigger} does not move the page`, async ({ page }) => { + await page.goto("/dev/layout-shift"); + const probe = page.getByTestId("probe"); + const before = await probe.boundingBox(); + await page.getByTestId(trigger).click(); + await expect(page.locator(surface).first()).toBeVisible(); + const after = await probe.boundingBox(); + expect(after!.x).toBeCloseTo(before!.x, 0); + expect(after!.width).toBeCloseTo(before!.width, 0); + }); +} + +// any CSS colour (including oklch) -> [r,g,b] via a 1px canvas +async function rgb(page: Page, css: string) { + return page.evaluate((c) => { + const x = document.createElement("canvas").getContext("2d")!; + x.fillStyle = c; + x.fillRect(0, 0, 1, 1); + return Array.from(x.getImageData(0, 0, 1, 1).data.slice(0, 3)); + }, css); +} +const lum = (c: number[]) => { + const f = (v: number) => ((v /= 255) <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4); + return 0.2126 * f(c[0]) + 0.7152 * f(c[1]) + 0.0722 * f(c[2]); +}; +const ratio = (a: number[], b: number[]) => { + const [h, l] = [lum(a), lum(b)].sort((x, y) => y - x); + return (h + 0.05) / (l + 0.05); +}; + +test("sky tokens apply to page", async ({ page }) => { + await page.goto("/dev/layout-shift"); + const bg = await page.evaluate(() => getComputedStyle(document.body).backgroundColor); + expect(await rgb(page, bg)).toEqual([244, 243, 239]); + expect(await page.evaluate(() => getComputedStyle(document.documentElement).colorScheme)).toBe("light"); +}); + +test("portaled menu is sky", async ({ page }) => { + await page.goto("/dev/layout-shift"); + await page.getByTestId("open-dropdown").click(); + const bg = await page.locator('[role="menu"]').evaluate((e) => getComputedStyle(e).backgroundColor); + expect(await rgb(page, bg)).toEqual([255, 255, 255]); +}); + +test("muted text meets AA", async ({ page }) => { + await page.goto("/dev/layout-shift"); + const vars = await page.evaluate(() => { + const s = getComputedStyle(document.documentElement); + return ["--muted-foreground", "--background", "--sidebar"].map((v) => s.getPropertyValue(v).trim()); + }); + const [m, bg, side] = await Promise.all(vars.map((c) => rgb(page, c))); + expect(ratio(m, bg)).toBeGreaterThanOrEqual(4.5); + expect(ratio(m, side)).toBeGreaterThanOrEqual(4.5); +}); + +test("login stays dark", async ({ page }) => { + await page.goto("/login"); + const bg = await page.evaluate(() => getComputedStyle(document.body).backgroundColor); + const [r] = await rgb(page, bg); + expect(r).toBeLessThan(60); +}); + +test("type roles load", async ({ page }) => { + await page.goto("/dev/layout-shift"); + expect(await page.evaluate(() => getComputedStyle(document.body).fontFamily)).toMatch(/Funnel/i); +}); + +test("dark: variants are off inside sky", async ({ page }) => { + await page.goto("/dev/layout-shift"); + // SelectTrigger is `bg-transparent dark:bg-input/30`; in Sky it must stay transparent. + const bg = await page.getByTestId("open-select").evaluate((e) => getComputedStyle(e).backgroundColor); + expect(bg).toBe("rgba(0, 0, 0, 0)"); +}); + +test("reduced motion kills transitions", async ({ browser }) => { + const ctx = await browser.newContext({ reducedMotion: "reduce", baseURL: test.info().project.use.baseURL }); + const page = await ctx.newPage(); + await page.goto("/dev/layout-shift"); + const d = await page + .getByTestId("transition-probe") + .evaluate((e) => parseFloat(getComputedStyle(e).transitionDuration)); + expect(d).toBeLessThan(0.01); + await ctx.close(); +}); + +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"'); +}); + +test("dark .bui panels keep readable muted text inside sky", async ({ page }) => { + await page.goto("/dev/layout-shift"); + const [fg, bg] = await page.getByTestId("bui-probe").evaluate((e) => [ + getComputedStyle(e.querySelector("p")!).color, + getComputedStyle(e).backgroundColor, + ]); + expect(ratio(await rgb(page, fg), await rgb(page, bg))).toBeGreaterThanOrEqual(4.5); +}); + +test("public pages reserve no scrollbar gutter", async ({ page }) => { + await page.goto("/login"); + expect(await page.evaluate(() => getComputedStyle(document.documentElement).scrollbarGutter)).toBe("auto"); +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ac89e96..5c94b33 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -26,7 +26,6 @@ "axios": "^1.7.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "framer-motion": "^12.23.24", "lenis": "^1.3.26", "lucide-react": "^0.553.0", "mathjs": "^15.1.0", @@ -44,6 +43,7 @@ "three": "^0.181.1" }, "devDependencies": { + "@playwright/test": "^1.64.1", "@types/node": "^20.14.15", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", @@ -576,6 +576,23 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz", + "integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==", + "devOptional": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "playwright": "1.64.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -7624,6 +7641,35 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", + "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.64.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", + "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index a469f59..ba8cf05 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,8 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "test:e2e": "playwright test" }, "dependencies": { "@fontsource-variable/funnel-sans": "^5.3.0", @@ -27,7 +28,6 @@ "axios": "^1.7.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "framer-motion": "^12.23.24", "lenis": "^1.3.26", "lucide-react": "^0.553.0", "mathjs": "^15.1.0", @@ -45,6 +45,7 @@ "three": "^0.181.1" }, "devDependencies": { + "@playwright/test": "^1.64.1", "@types/node": "^20.14.15", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..547b4f9 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,27 @@ +import { defineConfig, devices } from "@playwright/test"; + +// Local-only UI checks (not wired into CI). Dummy public env lets the root providers construct without a backend. +export default defineConfig({ + testDir: "e2e", + use: { baseURL: "http://localhost:3100" }, + projects: [ + // Headless Chromium hides scrollbars by default, which hides the very scrollbar-width shifts we test for. + { + name: "chromium", + use: { ...devices["Desktop Chrome"], launchOptions: { ignoreDefaultArgs: ["--hide-scrollbars"] } }, + }, + // Opt-in: on the owner's Windows machine the Playwright Firefox build is blocked from executing. + ...(process.env.E2E_FIREFOX ? [{ name: "firefox", use: devices["Desktop Firefox"] }] : []), + ], + webServer: { + command: "npx next dev -p 3100", + url: "http://localhost:3100/dev/layout-shift", + reuseExistingServer: false, + timeout: 180_000, + env: { + NEXT_PUBLIC_SUPABASE_URL: "http://127.0.0.1:54321", + NEXT_PUBLIC_SUPABASE_ANON_KEY: "e2e-dummy", + NEXT_PUBLIC_BACKEND_URL: "http://127.0.0.1:9", + }, + }, +}); diff --git a/frontend/src/app/(app)/layout.tsx b/frontend/src/app/(app)/layout.tsx index d055299..7656b2e 100644 --- a/frontend/src/app/(app)/layout.tsx +++ b/frontend/src/app/(app)/layout.tsx @@ -1,6 +1,7 @@ "use client"; import { Suspense, type ReactNode } from "react"; +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"; @@ -11,9 +12,12 @@ import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/s // pages (URLs are unchanged; "(app)" is a route group), so only the content area swaps, like tabs on X. export default function AppLayout({ children }: { children: ReactNode }) { return ( + // Sky (globals.css) wraps everything, auth loader included, so the first server paint is already light. +
+ @@ -27,8 +31,10 @@ export default function AppLayout({ children }: { children: ReactNode }) { {children} + +
); } diff --git a/frontend/src/app/dev/layout-shift/page.tsx b/frontend/src/app/dev/layout-shift/page.tsx new file mode 100644 index 0000000..d218a78 --- /dev/null +++ b/frontend/src/app/dev/layout-shift/page.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { notFound } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet"; + +// Dev-only harness for e2e/foundations.spec.ts: one of each overlay above a scrolling, centred column, inside the +// app theme scope. Lets us prove overlays never move the page without needing a signed-in session. +export default function LayoutShiftHarness() { + if (process.env.NODE_ENV === "production") notFound(); + + return ( +
+
+
+ + + + + + Item + + + + + + + + Dialog + + + + + + + + Popover + + + + + + + Sheet + + +
+
+
+

Dark .bui panel text inside the Sky shell

+
+ {Array.from({ length: 60 }, (_, i) => ( +

+ Filler paragraph {i + 1} so the page scrolls and has a scrollbar to lose. +

+ ))} +
+
+ ); +} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 671b73d..06a2c94 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -1,7 +1,8 @@ @import "tailwindcss"; @import "./beautiful-ui.css"; -@custom-variant dark (&:is(.dark *)); +/* is always `.dark` (public pages), but it doesn't count while the Sky app shell is mounted. */ +@custom-variant dark (&:is(.dark:not(:has([data-app-theme="sky"])) *)); :root { --background: oklch(0.17 0.01 264); @@ -39,7 +40,10 @@ --sidebar-ring: oklch(0.68 0.11 244); } -.dark { +/* `.bui` panels (Ask Lumen, until its Phase 4 restyle) stay dark inside the Sky shell: restate the dark tokens on them. + :where() keeps this at zero specificity so beautiful-ui.css's own `.bui` tokens (e.g. --accent) still win. */ +.dark, +:where(.bui) { --background: oklch(0.17 0.01 264); --foreground: oklch(0.98 0 0); --card: oklch(0.21 0.01 264); @@ -74,7 +78,54 @@ --sidebar-ring: oklch(0.68 0.11 244); } +/* Sky: the signed-in app's light theme. Applied to whenever the app shell ([data-app-theme="sky"]) is mounted, + so portaled menus and dialogs get it too; landing/login/onboarding keep the forced dark theme above. + Values: docs/superpowers/specs/2026-10-20-ui-revamp-design.md section 5 (muted darkened to #6b6963 for AA). */ +:root:has([data-app-theme="sky"]) { + color-scheme: light !important; + --background: oklch(0.964 0.005 95); + --foreground: oklch(0.222 0.002 107); + --card: oklch(1 0 0); + --card-foreground: oklch(0.222 0.002 107); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.222 0.002 107); + --primary: oklch(0.564 0.185 261); + --primary-foreground: oklch(1 0 0); + --secondary: oklch(0.939 0.007 97); + --secondary-foreground: oklch(0.222 0.002 107); + --muted: oklch(0.939 0.007 97); + --muted-foreground: #6b6963; + --accent: oklch(0.939 0.007 97); + --accent-foreground: oklch(0.222 0.002 107); + --destructive: oklch(0.588 0.15 40); + --border: oklch(0.919 0.008 91); + --input: oklch(0.939 0.007 97); + --ring: oklch(0.564 0.185 261); + --chart-1: oklch(0.564 0.185 261); + --sidebar: oklch(0.948 0.007 97); + --sidebar-foreground: oklch(0.222 0.002 107); + --sidebar-primary: oklch(0.564 0.185 261); + --sidebar-primary-foreground: oklch(1 0 0); + --sidebar-accent: oklch(0.939 0.007 97); + --sidebar-accent-foreground: oklch(0.222 0.002 107); + --sidebar-border: oklch(0.919 0.008 91); + --sidebar-ring: oklch(0.564 0.185 261); + --accent-soft: oklch(0.932 0.027 262); + --good: oklch(0.569 0.119 155); + --bad: oklch(0.588 0.15 40); + --band: linear-gradient(180deg, #cfe2fb 0%, #e9f1fb 55%, #f4f3ef 100%); +} +:root:has([data-app-theme="sky"]) body { + font-family: var(--font-ui), ui-sans-serif, system-ui, sans-serif; + background-image: none; +} + @theme inline { + --color-good: var(--good); + --color-bad: var(--bad); + --color-accent-soft: var(--accent-soft); + --font-serif: var(--font-newsreader), Georgia, serif; + --font-label: var(--font-plex-mono), ui-monospace, monospace; --font-sans: "Inter", "Inter Fallback", ui-sans-serif, system-ui, sans-serif; --font-mono: "JetBrains Mono", "JetBrains Mono Fallback", ui-monospace, monospace; --font-script: "Dancing Script", cursive; @@ -125,6 +176,17 @@ body { overflow-x: hidden; } + /* 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"]) { + scrollbar-gutter: stable; + } + /* Radix modals lock scroll via react-remove-scroll, which adds margin/padding equal to the scrollbar width and + shifts centred content. The gutter already reserves that space, so cancel the compensation (layered + !important outranks the runtime-injected unlayered rule). */ + html body[data-scroll-locked] { + margin-right: 0 !important; + padding-right: 0 !important; + } body { @apply bg-background text-foreground; background-image: @@ -171,6 +233,14 @@ animation: slide-out-to-right-full 0.2s ease-in; } + .label-mono { + font-family: var(--font-plex-mono), ui-monospace, monospace; + font-size: 10.5px; + letter-spacing: 0.12em; + text-transform: uppercase; + font-weight: 500; + } + .scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; @@ -269,6 +339,17 @@ } } +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + /* Adding horizontal scroll animation for trust indicators */ @keyframes scroll { 0% { diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 69e1b6e..7e92c84 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -1,5 +1,5 @@ import type { Metadata, Viewport } from "next"; -import { Inter, JetBrains_Mono, Newsreader } from "next/font/google"; +import { IBM_Plex_Mono, Inter, JetBrains_Mono, Newsreader } from "next/font/google"; import localFont from "next/font/local"; import "./globals.css"; import { AuthProvider } from "@/components/auth/auth-provider"; @@ -21,11 +21,18 @@ const jetBrainsMono = JetBrains_Mono({ // Display serif (light, editorial headlines) and the uppercase label mono of the new look. const newsreader = Newsreader({ subsets: ["latin"], - weight: ["300", "400"], + weight: ["300", "400", "500"], style: ["normal", "italic"], variable: "--font-newsreader", }); +// In-app card titles, tags and axis ticks (the `.label-mono` / `font-label` style). +const plexMono = IBM_Plex_Mono({ + subsets: ["latin"], + weight: ["400", "500"], + variable: "--font-plex-mono", +}); + // Funnel Sans (OFL, self-hosted from @fontsource-variable/funnel-sans): text, buttons and labels on the landing, // sign-in and onboarding pages, and the label style everywhere (it replaced Hanken Grotesk and Roboto Mono). const funnelSans = localFont({ @@ -134,7 +141,7 @@ export default function RootLayout({ {/* every signed-in page talks to these two right away */} diff --git a/frontend/src/components/ai-analytics/aiSuggestionsCard.tsx b/frontend/src/components/ai-analytics/aiSuggestionsCard.tsx index 49eb6f5..8ffb6e3 100644 --- a/frontend/src/components/ai-analytics/aiSuggestionsCard.tsx +++ b/frontend/src/components/ai-analytics/aiSuggestionsCard.tsx @@ -11,7 +11,7 @@ import { Target, Zap, } from "lucide-react"; -import { motion } from "framer-motion"; +import { motion } from "motion/react"; import { Badge } from "@/components/ui/badge"; import { useState, useEffect } from "react"; import { aiAnalyticsApi } from "@/lib/api/client"; diff --git a/frontend/src/components/ai-analytics/anomalyDetectionCard.tsx b/frontend/src/components/ai-analytics/anomalyDetectionCard.tsx index 4f70ace..2cf7acb 100644 --- a/frontend/src/components/ai-analytics/anomalyDetectionCard.tsx +++ b/frontend/src/components/ai-analytics/anomalyDetectionCard.tsx @@ -2,7 +2,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { AlertTriangle, Shield, Eye } from "lucide-react"; -import { motion } from "framer-motion"; +import { motion } from "motion/react"; import { Badge } from "@/components/ui/badge"; import { useEffect, useState } from "react"; import { useMoney } from "@/lib/format"; diff --git a/frontend/src/components/ai-analytics/categoryPieChart.tsx b/frontend/src/components/ai-analytics/categoryPieChart.tsx index 98176ab..f61e11e 100644 --- a/frontend/src/components/ai-analytics/categoryPieChart.tsx +++ b/frontend/src/components/ai-analytics/categoryPieChart.tsx @@ -2,7 +2,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { PieChart as PieChartIcon } from "lucide-react"; -import { motion } from "framer-motion"; +import { motion } from "motion/react"; import { useState, useEffect } from "react"; import { useMoney } from "@/lib/format"; import { transactionApi, tokenManager } from "@/lib/api/client"; diff --git a/frontend/src/components/ai-analytics/paymentCalendarCard.tsx b/frontend/src/components/ai-analytics/paymentCalendarCard.tsx index 42efc2f..f562382 100644 --- a/frontend/src/components/ai-analytics/paymentCalendarCard.tsx +++ b/frontend/src/components/ai-analytics/paymentCalendarCard.tsx @@ -6,7 +6,7 @@ import { ChevronLeft, ChevronRight, } from "lucide-react"; -import { motion } from "framer-motion"; +import { motion } from "motion/react"; import { useState, useEffect } from "react"; import { analyticsApi } from "@/lib/api/client"; diff --git a/frontend/src/components/ai-analytics/quickStatsCard.tsx b/frontend/src/components/ai-analytics/quickStatsCard.tsx index 5ce0347..7f942d2 100644 --- a/frontend/src/components/ai-analytics/quickStatsCard.tsx +++ b/frontend/src/components/ai-analytics/quickStatsCard.tsx @@ -2,7 +2,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { TrendingUp, DollarSign, Calendar, AlertCircle } from "lucide-react"; -import { motion } from "framer-motion"; +import { motion } from "motion/react"; import { useEffect, useState } from "react"; import { aiAnalyticsApi } from "@/lib/api/client"; diff --git a/frontend/src/components/ai-analytics/reminderDetailsCard.tsx b/frontend/src/components/ai-analytics/reminderDetailsCard.tsx index c861284..4d14355 100644 --- a/frontend/src/components/ai-analytics/reminderDetailsCard.tsx +++ b/frontend/src/components/ai-analytics/reminderDetailsCard.tsx @@ -2,7 +2,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Bell, TrendingUp, Calendar } from "lucide-react"; -import { motion } from "framer-motion"; +import { motion } from "motion/react"; import { Badge } from "@/components/ui/badge"; import { useState, useEffect } from "react"; import { useMoney } from "@/lib/format"; diff --git a/frontend/src/components/ai-analytics/spendingTrendGraph.tsx b/frontend/src/components/ai-analytics/spendingTrendGraph.tsx index 2158524..fa5ed04 100644 --- a/frontend/src/components/ai-analytics/spendingTrendGraph.tsx +++ b/frontend/src/components/ai-analytics/spendingTrendGraph.tsx @@ -2,7 +2,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { TrendingUp, Activity } from "lucide-react"; -import { motion } from "framer-motion"; +import { motion } from "motion/react"; import { useState, useEffect } from "react"; import { useMoney } from "@/lib/format"; import { useProfile } from "@/components/profile/profile-provider"; diff --git a/frontend/src/lib/motion.ts b/frontend/src/lib/motion.ts new file mode 100644 index 0000000..f18f9d6 --- /dev/null +++ b/frontend/src/lib/motion.ts @@ -0,0 +1,12 @@ +// Shared motion for the signed-in app: quiet and quick (150–250ms, ease-out, opacity/transform only). +// Reduced motion is handled globally (MotionConfig in the app layout + a CSS rule in globals.css). + +export const DURATION = { fast: 0.15, base: 0.2, slow: 0.25 } as const; + +export const EASE_OUT = [0.22, 1, 0.36, 1] as const; + +export const fadeUp = { + initial: { opacity: 0, y: 6 }, + animate: { opacity: 1, y: 0 }, + transition: { duration: DURATION.base, ease: EASE_OUT }, +};