From 012385e9e1680e37d5234f963838678b0e5d8d22 Mon Sep 17 00:00:00 2001 From: Vijayakanth Manoharan Date: Mon, 23 Mar 2026 22:04:20 -0400 Subject: [PATCH] Add dark mode with theme toggle and OpenSpec change - Add @custom-variant dark in globals.css for class-based dark mode - Add ThemeToggle component with localStorage persistence and system preference fallback - Inject blocking inline script in layout to prevent flash on load - Apply dark: variants across all components (layout, site-nav, home-dashboard, expense-workspace, paycheck-workspace, recurring-expense-manager) and page headers - Create openspec/changes/theming-dark-mode with proposal, design, and tasks artifacts Co-Authored-By: Claude Sonnet 4.6 --- AGENT_HANDOFF.md | 80 ++++++++ next-env.d.ts | 2 +- .../changes/theming-dark-mode/.openspec.yaml | 2 + openspec/changes/theming-dark-mode/design.md | 54 ++++++ .../changes/theming-dark-mode/proposal.md | 27 +++ openspec/changes/theming-dark-mode/tasks.md | 25 +++ src/app/add-expense/page.tsx | 6 +- src/app/globals.css | 7 + src/app/income/page.tsx | 6 +- src/app/layout.tsx | 29 ++- src/components/expense-workspace.tsx | 58 +++--- src/components/home-dashboard.tsx | 174 +++++++++--------- src/components/paycheck-workspace.tsx | 84 ++++----- src/components/recurring-expense-manager.tsx | 50 ++--- src/components/site-nav.tsx | 6 +- src/components/theme-toggle.tsx | 44 +++++ tsconfig.tsbuildinfo | 1 + 17 files changed, 457 insertions(+), 198 deletions(-) create mode 100644 AGENT_HANDOFF.md create mode 100644 openspec/changes/theming-dark-mode/.openspec.yaml create mode 100644 openspec/changes/theming-dark-mode/design.md create mode 100644 openspec/changes/theming-dark-mode/proposal.md create mode 100644 openspec/changes/theming-dark-mode/tasks.md create mode 100644 src/components/theme-toggle.tsx create mode 100644 tsconfig.tsbuildinfo diff --git a/AGENT_HANDOFF.md b/AGENT_HANDOFF.md new file mode 100644 index 0000000..1e2dc75 --- /dev/null +++ b/AGENT_HANDOFF.md @@ -0,0 +1,80 @@ +# Monthy Tracker Agent Handoff + +Use this file as the starting point for any new agent. It summarizes the repo state, the important files, and the non-obvious behavior so you can continue without re-discovering basics. + +## What this app is + +- Single-user, local-first monthly expense tracker. +- Stack: Next.js App Router, Prisma, SQLite, Ollama for offline AI. +- Main views: dashboard, add expense, income/paychecks. +- Goal: track expenses and paychecks, then generate private monthly summaries and recommendations. + +## Current state + +- Dashboard data is loaded from `/dashboard?month=YYYY-MM`. +- Monthly insight generation lives in `src/lib/insights.ts`. +- The app is currently working with the local dev DB at `file:./prisma/dev.db`. +- Seed data exists for February 2026 and March 2026. +- The frontend shows the current month by default, so if data looks missing, check the active month selector first. + +## Important files + +- `src/components/home-dashboard.tsx` - dashboard UI, fetches dashboard and Ollama status. +- `src/lib/dashboard.ts` - aggregates totals, breakdowns, recent expenses, and comparisons. +- `src/lib/insights.ts` - builds the model prompt, calls Ollama, stores fallback/model insights. +- `src/app/dashboard/route.ts` - dashboard API route. +- `src/app/backup/database/route.ts` - SQLite backup endpoint. +- `src/lib/date.ts` - month/date helpers; month filtering uses local date strings. +- `prisma/schema.prisma` - data model source of truth. +- `prisma/seed.ts` - local seed script. +- `README.md` - setup and runtime notes. +- `openspec/changes/monthly-expense-tracker-v1/` - design/spec/task source for the product. + +## Key behavior to know + +- Dashboard aggregates all records, then filters them to the selected month in memory. +- `getCurrentMonthKey()` uses local machine time. +- `generateMonthlyInsight()` in `src/lib/insights.ts`: + - builds a structured prompt from dashboard totals, category breakdown, recent expenses, and chart points + - expects strict JSON with `summary` and `recommendations` + - falls back when activity is sparse or Ollama is unavailable + - stores the final result in `MonthlyInsight` +- The fallback message is intentionally safe and short when data is too sparse. + +## Known gotchas + +- If dashboard values appear missing, confirm the selected month matches the seeded data. +- If Docker backup fails, check `DATABASE_URL`; the backup route reads the SQLite path from the env var. +- Prisma migration ordering matters because migration folders are applied lexicographically. +- In Docker Compose on Linux, `extra_hosts: host-gateway` is needed for host Ollama access. + +## Current enhancement target + +- The next useful improvement is making the generated monthly summary more helpful and more structured. +- Best place to change it: `src/lib/insights.ts`. +- Likely changes: + - strengthen the prompt with clearer sections or required observations + - improve the fallback logic for sparse months + - optionally store more structured fields in `MonthlyInsight` + - update the dashboard card rendering in `src/components/home-dashboard.tsx` if the output shape changes + +## Useful commands + +```bash +npm run dev +npx prisma migrate deploy +npx prisma db seed +npm test +``` + +If you need to verify the dashboard API directly: + +```bash +curl "http://localhost:3000/dashboard?month=2026-03" +``` + +## Recommended next step for another agent + +1. Read `src/lib/insights.ts`, `src/lib/dashboard.ts`, and `prisma/schema.prisma`. +2. Decide whether to improve the insight prompt, the stored data shape, or the dashboard rendering. +3. Make the change, then verify with the dashboard API and a generated insight. diff --git a/next-env.d.ts b/next-env.d.ts index c4b7818..9edff1c 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/openspec/changes/theming-dark-mode/.openspec.yaml b/openspec/changes/theming-dark-mode/.openspec.yaml new file mode 100644 index 0000000..0a32546 --- /dev/null +++ b/openspec/changes/theming-dark-mode/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-23 diff --git a/openspec/changes/theming-dark-mode/design.md b/openspec/changes/theming-dark-mode/design.md new file mode 100644 index 0000000..04a1530 --- /dev/null +++ b/openspec/changes/theming-dark-mode/design.md @@ -0,0 +1,54 @@ +## Context + +The project uses Next.js with Tailwind CSS v4 and a warm stone/amber palette. All styling is done via Tailwind utility classes directly in JSX. There are no separate CSS modules for components. The root layout owns the `` and `` tags, making it the natural place to manage the theme class and prevent flash-of-unstyled-content. + +## Goals / Non-Goals + +**Goals:** +- Implement class-based dark mode that works with Tailwind v4's `@custom-variant` API. +- Prevent theme flash on page load with an inline blocking script. +- Persist user preference in `localStorage` under the key `theme`. +- Fall back to system preference (`prefers-color-scheme`) when no preference is saved. +- Keep the dark palette warm and consistent with the existing amber/stone design language. + +**Non-Goals:** +- Server-side theme rendering or cookies (local-only app, no SSR theme concerns beyond flash prevention). +- Per-page or per-component theme overrides. +- Animated theme transitions beyond the existing `transition-duration: 180ms` on interactive elements. + +## Decisions + +### Use Tailwind v4 `@custom-variant` for class-based dark mode +- Rationale: Tailwind v4's default dark mode is media-query-based. Adding `@custom-variant dark (&:where(.dark, .dark *))` in `globals.css` enables the `dark:` prefix to respond to a class on any ancestor, which is the standard pattern for toggle-able dark mode. +- Alternative considered: Continue using media-query dark mode with no toggle. Rejected because users cannot override system preference. + +### Store theme preference in `localStorage` +- Rationale: Simple, synchronous, requires no server or cookies. Reads correctly in the blocking inline script. +- Alternative considered: Cookies for SSR. Rejected because this app is local-first and has no meaningful SSR theme benefit. + +### Inject an inline blocking script in `` to set the `dark` class before first paint +- Rationale: Prevents the flash where the page briefly renders in light mode before React hydrates and reads `localStorage`. The script is small and runs synchronously. +- Alternative considered: Set the class in a React `useEffect`. Rejected because `useEffect` runs after paint, causing a visible flash. + +### Warm deep-stone dark palette +- Rationale: The light theme is built on warm stone and amber tones. The dark theme mirrors this with deep stone backgrounds (`stone-950`, `stone-900`, `stone-800`) and dimmed amber/emerald accents, keeping the visual identity coherent. +- Alternative considered: Neutral dark greys. Rejected because they clash with the warm amber accents and feel disconnected from the brand. + +## Risks / Trade-offs + +- [Inline script adds a small amount of render-blocking HTML] → Acceptable; the script is under 200 bytes and only runs once per page load. +- [LocalStorage is unavailable in some privacy modes] → The script wraps the read in a try/catch and falls back to system preference. +- [Many components have hardcoded warm background strings like `bg-[#fffaf2]`] → These are replaced with equivalent Tailwind tokens plus `dark:` overrides so the mapping is explicit and maintainable. + +## Migration Plan + +1. Update `globals.css` with `@custom-variant dark` and dark-mode CSS variable overrides. +2. Create the `ThemeToggle` client component. +3. Update `layout.tsx` to add the blocking script and render `ThemeToggle` in the header. +4. Update `site-nav.tsx` with dark-mode nav styles. +5. Update `home-dashboard.tsx`, `expense-workspace.tsx`, `paycheck-workspace.tsx`, and `recurring-expense-manager.tsx` with `dark:` variants. +6. Update page-level header text that uses hardcoded colour classes. + +## Open Questions + +- Should the toggle also expose a "System" option (three-way: Light / Dark / System) rather than a binary flip? Deferred to a follow-up; the initialisation script already handles system fallback on first visit. diff --git a/openspec/changes/theming-dark-mode/proposal.md b/openspec/changes/theming-dark-mode/proposal.md new file mode 100644 index 0000000..68efaf1 --- /dev/null +++ b/openspec/changes/theming-dark-mode/proposal.md @@ -0,0 +1,27 @@ +## Why + +The app currently ships with a warm light theme only. Adding a system-aware dark mode with a manual toggle removes eye strain in low-light conditions and meets baseline accessibility expectations for a personal finance tool used at any hour. + +## What Changes + +- Add a class-based dark mode variant using Tailwind v4 `@custom-variant` so all `dark:` utilities are controlled by a `dark` class on ``. +- Add a `ThemeToggle` component that persists the user's preference in `localStorage` and initialises the correct class before first paint to prevent flash. +- Update the root layout to inject the inline initialisation script and render the toggle inside the existing header. +- Apply `dark:` variants to all UI components to produce a warm, deep-stone dark palette consistent with the existing amber/stone design language. + +## Capabilities + +### New Capabilities +- `dark-mode`: Users can switch between light and dark themes with a toggle that persists across sessions. System preference is respected on first visit. + +### Modified Capabilities +- `expense-tracking`: Expense workspace and recurring expense manager now render correctly in both themes. +- `paycheck-tracking`: Paycheck workspace now renders correctly in both themes. +- `monthly-dashboard`: Dashboard sections, insight cards, category bars, and stat cards now render correctly in both themes. + +## Impact + +- Affected code: `globals.css`, `layout.tsx`, all components under `src/components/`, and page header text in `src/app/`. +- APIs: None. +- Dependencies: None — uses Tailwind v4 built-ins and the Web Storage API. +- Systems: No server-side changes required; theme state is fully client-side. diff --git a/openspec/changes/theming-dark-mode/tasks.md b/openspec/changes/theming-dark-mode/tasks.md new file mode 100644 index 0000000..ff9639d --- /dev/null +++ b/openspec/changes/theming-dark-mode/tasks.md @@ -0,0 +1,25 @@ +## 1. CSS and variant setup + +- [x] 1.1 Add `@custom-variant dark (&:where(.dark, .dark *))` to `globals.css` and update `:root` / `@theme` blocks with dark-mode CSS variable overrides. + +## 2. Theme toggle infrastructure + +- [x] 2.1 Create `src/components/theme-toggle.tsx` — a client component that reads and writes `localStorage` theme preference, toggles the `dark` class on ``, and renders a sun/moon button. +- [x] 2.2 Update `src/app/layout.tsx` to inject the inline blocking script in `` and render `ThemeToggle` in the header alongside `SiteNav`. + +## 3. Component dark-mode styles + +- [x] 3.1 Update `src/components/site-nav.tsx` with `dark:` variants for link backgrounds, borders, and text. +- [x] 3.2 Update `src/components/home-dashboard.tsx` with `dark:` variants for all section cards, stat tiles, insight blocks, progress bars, and empty states. +- [x] 3.3 Update `src/components/expense-workspace.tsx` with `dark:` variants for the form card, list card, inputs, and expense articles. +- [x] 3.4 Update `src/components/paycheck-workspace.tsx` with `dark:` variants for the schedule panel, form, and paycheck list. +- [x] 3.5 Update `src/components/recurring-expense-manager.tsx` with `dark:` variants for the panel, inline form, and definition articles. + +## 4. Page header text + +- [x] 4.1 Update hardcoded text colours in `src/app/add-expense/page.tsx` and `src/app/income/page.tsx` with `dark:` overrides. + +## 5. Verification + +- [ ] 5.1 Visually verify light and dark modes across the dashboard, add-expense, and income pages in the browser. +- [ ] 5.2 Verify that theme preference persists across page refreshes and that there is no flash of the wrong theme on load. diff --git a/src/app/add-expense/page.tsx b/src/app/add-expense/page.tsx index a8d5f7a..130b6e2 100644 --- a/src/app/add-expense/page.tsx +++ b/src/app/add-expense/page.tsx @@ -10,9 +10,9 @@ export default function AddExpensePage() { return (
-

Add Expense

-

Capture spending while it still feels fresh.

-

+

Add Expense

+

Capture spending while it still feels fresh.

+

Enter the shop name and the app can auto-fill a category locally for known merchants, with offline AI help for unfamiliar ones.

diff --git a/src/app/globals.css b/src/app/globals.css index 7d833db..379b125 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,10 +1,17 @@ @import "tailwindcss"; +@custom-variant dark (&:where(.dark, .dark *)); + :root { --background: #ffffff; --foreground: #171717; } +.dark { + --background: #1a1714; + --foreground: #f5f0eb; +} + @theme inline { --color-background: #fbfaf7; --color-foreground: #1c1917; diff --git a/src/app/income/page.tsx b/src/app/income/page.tsx index 388fe56..c699634 100644 --- a/src/app/income/page.tsx +++ b/src/app/income/page.tsx @@ -8,9 +8,9 @@ export default function IncomePage() { return (
-

Income & Paychecks

-

Capture income on real pay dates, not rough monthly averages.

-

+

Income & Paychecks

+

Capture income on real pay dates, not rough monthly averages.

+

This slice tracks each paycheck as a distinct event so later dashboard and AI guidance can reason about cash timing accurately.

diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 3701781..29fe304 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { Fraunces, Manrope } from "next/font/google"; import { SiteNav } from "@/components/site-nav"; +import { ThemeToggle } from "@/components/theme-toggle"; import "./globals.css"; @@ -20,6 +21,18 @@ export const metadata: Metadata = { description: "Local-first monthly expense tracking with AI insights.", }; +const themeScript = ` +(function() { + try { + var saved = localStorage.getItem('theme'); + var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + if (saved === 'dark' || (!saved && prefersDark)) { + document.documentElement.classList.add('dark'); + } + } catch(e) {} +})(); +`; + export default function RootLayout({ children, }: Readonly<{ @@ -30,14 +43,20 @@ export default function RootLayout({ lang="en" className={`${headingFont.variable} ${bodyFont.variable} h-full antialiased`} > - + +