`. 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/frontend/e2e/foundations.spec.ts b/frontend/e2e/foundations.spec.ts
index d3c8f37..62df830 100644
--- a/frontend/e2e/foundations.spec.ts
+++ b/frontend/e2e/foundations.spec.ts
@@ -82,3 +82,14 @@ test("dark: variants are off inside sky", async ({ page }) => {
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();
+});
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 2749b01..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",
diff --git a/frontend/package.json b/frontend/package.json
index 9ce33cb..ba8cf05 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -28,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",
diff --git a/frontend/src/app/(app)/layout.tsx b/frontend/src/app/(app)/layout.tsx
index 5c3b1a2..f042b1c 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";
@@ -16,6 +17,7 @@ export default function AppLayout({ children }: { children: ReactNode }) {
{/* turns on the Sky light theme (globals.css) for the whole signed-in app, portals included */}
+
@@ -29,6 +31,7 @@ export default function AppLayout({ children }: { children: ReactNode }) {
{children}
+
diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css
index 13004c2..290670a 100644
--- a/frontend/src/app/globals.css
+++ b/frontend/src/app/globals.css
@@ -335,6 +335,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/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 },
+};
From 196cb75ae2624844c4aef08d4fb569be63f6759f Mon Sep 17 00:00:00 2001
From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com>
Date: Tue, 20 Oct 2026 01:21:53 +0530
Subject: [PATCH 5/6] docs: STORY entry for UI revamp Phase 0
---
docs/direction/STORY.md | 15 +++++++++++++++
1 file changed, 15 insertions(+)
diff --git a/docs/direction/STORY.md b/docs/direction/STORY.md
index 18e9880..d7a1a86 100644
--- a/docs/direction/STORY.md
+++ b/docs/direction/STORY.md
@@ -287,3 +287,18 @@ with the numbers and the files that prove them. Newest last. Raw numbers live in
- **R:** Backend suite 857 -> 898 passed. Zero-leak tests (log capture and responses) in `test_api_keys.py` and
`test_byok_client.py` show no key, or part of one, reaches a log line or response. Story point: failing loudly
on a bad key beats quietly answering with a worse model.
+
+## 25. 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.
From 23344421a697fb076aa14a1b5866d6f55229cb98 Mon Sep 17 00:00:00 2001
From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com>
Date: Tue, 20 Oct 2026 01:26:44 +0530
Subject: [PATCH 6/6] fix: Sky from first paint, dark .bui panels keep dark
tokens, gutter only in-app
---
frontend/e2e/foundations.spec.ts | 20 ++++++++++++++++++++
frontend/src/app/(app)/layout.tsx | 6 +++---
frontend/src/app/dev/layout-shift/page.tsx | 3 +++
frontend/src/app/globals.css | 8 ++++++--
4 files changed, 32 insertions(+), 5 deletions(-)
diff --git a/frontend/e2e/foundations.spec.ts b/frontend/e2e/foundations.spec.ts
index 62df830..461147b 100644
--- a/frontend/e2e/foundations.spec.ts
+++ b/frontend/e2e/foundations.spec.ts
@@ -93,3 +93,23 @@ test("reduced motion kills transitions", async ({ browser }) => {
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/src/app/(app)/layout.tsx b/frontend/src/app/(app)/layout.tsx
index f042b1c..7656b2e 100644
--- a/frontend/src/app/(app)/layout.tsx
+++ b/frontend/src/app/(app)/layout.tsx
@@ -12,11 +12,11 @@ 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.
+
- {/* turns on the Sky light theme (globals.css) for the whole signed-in app, portals included */}
-
@@ -32,9 +32,9 @@ export default function AppLayout({ children }: { children: ReactNode }) {
-
+
);
}
diff --git a/frontend/src/app/dev/layout-shift/page.tsx b/frontend/src/app/dev/layout-shift/page.tsx
index df01583..d218a78 100644
--- a/frontend/src/app/dev/layout-shift/page.tsx
+++ b/frontend/src/app/dev/layout-shift/page.tsx
@@ -62,6 +62,9 @@ export default function LayoutShiftHarness() {
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 290670a..06a2c94 100644
--- a/frontend/src/app/globals.css
+++ b/frontend/src/app/globals.css
@@ -40,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);
@@ -173,7 +176,8 @@
body {
overflow-x: hidden;
}
- html {
+ /* 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