diff --git a/README.md b/README.md index ed1c5ad..c4aca6a 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ The human role was architecture, specification, and review; the agent wrote the - `src/app/` for the Next App Router entrypoints: the SSR dashboard page, layout, globals, and API routes. - `src/features/dashboard/` for the dashboard frontend feature: components, hooks, reducer/state, URL filter state, formatting, and client-side tests. +- `src/features/theme/` for the client-side theme: preference parsing and persistence, the hook that applies it to the document, and the three-state toggle. Resolving light versus dark is CSS's job, not this module's. - `src/server/subscriptions/` for the server-only billing runtime and adapters: in-memory store, GraphQL execution, SSE, cursors, HTTP helpers, and seed data. - `src/shared/subscriptions/` for cross-boundary contract code shared by client and server: DTO types, GraphQL documents, constants, and input rules. - `public/` for static assets such as the demo user avatar. diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index bea210a..987f486 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -140,6 +140,7 @@ Keep the codebase split by runtime ownership: - `src/app/`: Next App Router entrypoints, including `src/app/page.tsx` and the HTTP route handlers under `src/app/api/` - `src/features/dashboard/`: dashboard-specific client code such as components, hooks, reducer/state, virtualization, and URL state handling +- `src/features/theme/`: client-only theme preference — parsing, `localStorage` persistence, cross-tab sync, and the toggle. It sits outside the dashboard's client-state layers because it is presentation state rather than server-derived data, and it never reaches the store or any route. Resolving light versus dark belongs to CSS; see `docs/frontend.md` - `src/server/subscriptions/`: server-only billing runtime, in-memory store, GraphQL execution, SSE, cursor helpers, and seed data - `src/shared/subscriptions/`: cross-boundary contract code that is safe to share between client and server, such as types, constants, GraphQL documents, and input rules - `public/`: static assets used by the UI diff --git a/docs/frontend.md b/docs/frontend.md index a23bd9c..c4fb7f7 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -41,3 +41,18 @@ Expected UI: - After a successful create command in the acting tab, the client should immediately refetch the first subscription slice for the current filter and replace the local subscription list plus cursor chain. - This create-specific refetch is tied to the local mutation result, not to the later `subscription.created` SSE event. - After that refetch, if the later `subscription.created` SSE event refers to a subscription that already exists in the local list, the acting tab should not mark the list stale again. + +## Theme + +- The app supports light and dark presentation with a three-state user preference: `system`, `light`, and `dark`. +- `system` follows the operating system setting and continues tracking it live if the OS setting changes while the page is open. +- The preference is persisted in `localStorage` under the key `subscriptions:theme`. +- An invalid, missing, or unparseable stored value falls back to `system`. +- Theme resolution belongs to CSS, not JavaScript. `:root` declares `color-scheme: light dark`, and every token is defined once with `light-dark()`, so the operating system preference is honoured during initial parse with no script involved. +- An explicit preference is expressed as a `data-theme` attribute on `` holding `light` or `dark`, which narrows `color-scheme` to that single scheme. `system` is expressed by the **absence** of the attribute; it never holds the literal value `system`. +- There is no blocking inline script. A visitor on the default `system` preference — the common case — receives a correctly themed page from the CSS alone, even with JavaScript disabled. +- A visitor who has explicitly overridden their operating system setting sees their OS theme until hydration applies the stored preference. This is accepted deliberately: the intermediate state matches the browser's own canvas, scrollbars, and form controls, because `color-scheme` governs those too. +- Theme changes are applied instantly and are not animated, whether they come from a deliberate switch, from adopting a stored preference on load, or from another tab. Because the tokens resolve through `light-dark()`, which follows the non-animatable `color-scheme`, a CSS transition has nothing to interpolate; a view transition was tried and removed as not worth its failure modes. +- Changing the theme in one tab propagates to other open tabs via the `storage` event. +- Theme is per-browser client state and must not travel through the domain event stream. Cross-tab sync uses the `storage` event, not `GET /api/stream`. This matters because the in-memory store is a single process-wide singleton with one global listener set — an event broadcast from it reaches every connected visitor, so routing theme through it would let one visitor change everyone's theme. +- The theme control is available in the dashboard header. diff --git a/docs/testing-strategy.md b/docs/testing-strategy.md index fea474c..e192da6 100644 --- a/docs/testing-strategy.md +++ b/docs/testing-strategy.md @@ -90,6 +90,9 @@ Cover: - transaction highlighting - replay-expired UI state - offline toggle disconnect and reconnect behavior +- theme preference parsing, including the fallback to `system` for missing or unparseable stored values +- theme preference held for the session when `localStorage` writes fail, so the control still works where storage is unavailable +- a theme change from another tab applied even when `localStorage` reads fail ## End-to-End Coverage @@ -103,6 +106,9 @@ Minimum useful scenarios: 4. cancel in one tab propagates to another tab 5. offline tab reconnects and replays missed events 6. replay can no longer continue and the UI requires refresh +7. a theme choice persists across reload and propagates to another tab +8. the page loads with no console errors under each theme preference, since hydration mismatches surface only there and are invisible to assertions on the DOM +9. semantic helper text meets WCAG AA contrast in both explicit themes, on normal and inverted surfaces alike ## Practical Guidance @@ -110,3 +116,5 @@ Minimum useful scenarios: - Drive recurring billing through a testable scheduler step rather than real intervals in most tests. - Keep domain assertions separate from transport assertions. - Treat TDD as contract-first work: stabilize domain behavior and API shape before building the interactive frontend on top of it. +- Normalize colors to sRGB before measuring contrast. `getComputedStyle` returns `lab()` or `oklch()` in current browsers, so painting the value to a canvas and reading it back is what makes a ratio meaningful; the contrast helper rejects anything that is not `rgb()` rather than parsing digits out of it and returning a confident wrong answer. +- Keep test tooling split by dependency: pure helpers live in `test-utils/` with vitest coverage, and Playwright-specific helpers stay in `e2e/`. `*.test.ts` is vitest, `*.spec.ts` is Playwright, so a pure assertion never drags in a production build. diff --git a/e2e/contrast.ts b/e2e/contrast.ts new file mode 100644 index 0000000..7aece7c --- /dev/null +++ b/e2e/contrast.ts @@ -0,0 +1,34 @@ +import type { Locator } from "@playwright/test"; + +export async function renderedColors( + foreground: Locator, + background: Locator, +) { + const backgroundElement = await background.elementHandle(); + + if (!backgroundElement) { + throw new Error("Expected a visible contrast background."); + } + + return foreground.evaluate((element, backgroundNode) => { + const context = document.createElement("canvas").getContext("2d"); + + if (!context) { + throw new Error("Expected a canvas context for color normalization."); + } + + const toRgb = (color: string) => { + context.clearRect(0, 0, 1, 1); + context.fillStyle = color; + context.fillRect(0, 0, 1, 1); + const [red, green, blue] = context.getImageData(0, 0, 1, 1).data; + + return `rgb(${red}, ${green}, ${blue})`; + }; + + return { + backgroundColor: toRgb(getComputedStyle(backgroundNode).backgroundColor), + color: toRgb(getComputedStyle(element).color), + }; + }, backgroundElement); +} diff --git a/e2e/dashboard.spec.ts b/e2e/dashboard.spec.ts index b9e2bb7..ead6adb 100644 --- a/e2e/dashboard.spec.ts +++ b/e2e/dashboard.spec.ts @@ -1,4 +1,6 @@ import { expect, test, type Page } from "@playwright/test"; +import { renderedColors } from "./contrast"; +import { contrastRatio } from "../test-utils/contrast"; async function createSubscription( page: Page, @@ -82,7 +84,7 @@ test("offline reconnect replays missed events and marks the list stale", async ( }); test("expired replay switches the UI into reload-required", async ({ browser }) => { - const context = await browser.newContext(); + const context = await browser.newContext({ colorScheme: "dark" }); const pageA = await context.newPage(); const pageB = await context.newPage(); @@ -100,5 +102,13 @@ test("expired replay switches the UI into reload-required", async ({ browser }) await pageB.getByRole("button", { name: /go online/i }).click(); await expect(pageB.getByTestId("reload-required")).toBeVisible({ timeout: 7000 }); + const reloadButton = pageB.getByRole("button", { name: "Reload page" }); + const { backgroundColor, color } = await renderedColors( + reloadButton, + reloadButton, + ); + + expect(contrastRatio(color, backgroundColor)).toBeGreaterThanOrEqual(4.5); + await context.close(); }); diff --git a/e2e/theme.spec.ts b/e2e/theme.spec.ts new file mode 100644 index 0000000..36f6793 --- /dev/null +++ b/e2e/theme.spec.ts @@ -0,0 +1,184 @@ +import { expect, test, type Page } from "@playwright/test"; +import { renderedColors } from "./contrast"; +import { contrastRatio } from "../test-utils/contrast"; + +function bodyBackground(page: Page) { + return page.evaluate(() => getComputedStyle(document.body).backgroundColor); +} + +function colorScheme(page: Page) { + return page.evaluate( + () => getComputedStyle(document.documentElement).colorScheme, + ); +} + +test("toggling the theme updates the document and persists across reload", async ({ + browser, +}) => { + const context = await browser.newContext(); + const page = await context.newPage(); + + await page.goto("/"); + + const toggle = page.getByTestId("theme-toggle"); + await expect(toggle).toHaveAttribute("data-preference", "system"); + await expect(page.locator("html")).not.toHaveAttribute("data-theme", /.*/); + + await toggle.click(); + await expect(toggle).toHaveAttribute("data-preference", "light"); + await expect(page.locator("html")).toHaveAttribute("data-theme", "light"); + + await toggle.click(); + await expect(toggle).toHaveAttribute("data-preference", "dark"); + await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); + + await page.reload(); + await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); + await expect(page.getByTestId("theme-toggle")).toHaveAttribute( + "data-preference", + "dark", + ); + + await context.close(); +}); + +test("the operating system preference is honoured by CSS alone", async ({ + browser, +}) => { + const darkContext = await browser.newContext({ colorScheme: "dark" }); + const darkPage = await darkContext.newPage(); + await darkPage.goto("/"); + + await expect(darkPage.locator("html")).not.toHaveAttribute("data-theme", /.*/); + expect(await colorScheme(darkPage)).toBe("light dark"); + const darkBackground = await bodyBackground(darkPage); + await darkContext.close(); + + const lightContext = await browser.newContext({ colorScheme: "light" }); + const lightPage = await lightContext.newPage(); + await lightPage.goto("/"); + + await expect(lightPage.locator("html")).not.toHaveAttribute( + "data-theme", + /.*/, + ); + expect(await colorScheme(lightPage)).toBe("light dark"); + const lightBackground = await bodyBackground(lightPage); + await lightContext.close(); + + expect(darkBackground).not.toBe(lightBackground); +}); + +test("an explicit preference overrides the operating system setting", async ({ + browser, +}) => { + const context = await browser.newContext({ colorScheme: "dark" }); + await context.addInitScript(() => { + window.localStorage.setItem("subscriptions:theme", "light"); + }); + + const page = await context.newPage(); + await page.goto("/"); + + await expect(page.locator("html")).toHaveAttribute("data-theme", "light"); + expect(await colorScheme(page)).toBe("light"); + + await context.close(); +}); + +test("semantic helper text meets AA contrast on normal and inverted surfaces in explicit themes", async ({ + browser, +}) => { + for (const preference of ["light", "dark"] as const) { + const context = await browser.newContext(); + await context.addInitScript((value) => { + window.localStorage.setItem("subscriptions:theme", value); + }, preference); + + const page = await context.newPage(); + await page.goto("/"); + + const transactionHelper = page.getByText( + /^(?:End of history|Scroll for older history)$/, + ); + const transactionPanel = page + .locator("section") + .filter({ has: page.getByRole("heading", { name: "Transactions" }) }); + const transactionColors = await renderedColors(transactionHelper, transactionPanel); + + expect( + contrastRatio(transactionColors.color, transactionColors.backgroundColor), + `transaction helper contrast in explicit ${preference} mode`, + ).toBeGreaterThanOrEqual(4.5); + + await page.getByRole("button", { name: /^Pause / }).first().click(); + const selectedDescription = page.getByText("Resume quickly after a short pause."); + const selectedOption = page.getByRole("button", { + name: /1 second resume quickly after a short pause/i, + }); + const selectedColors = await renderedColors(selectedDescription, selectedOption); + + expect( + contrastRatio(selectedColors.color, selectedColors.backgroundColor), + `selected pause description contrast in explicit ${preference} mode`, + ).toBeGreaterThanOrEqual(4.5); + + await context.close(); + } +}); + +test("the page loads without console errors in every theme", async ({ + browser, +}) => { + // Hydration mismatches surface only as console errors, never in the DOM. + for (const preference of ["system", "light", "dark"] as const) { + const context = await browser.newContext(); + await context.addInitScript((value) => { + if (value === "system") { + window.localStorage.removeItem("subscriptions:theme"); + return; + } + + window.localStorage.setItem("subscriptions:theme", value); + }, preference); + + const page = await context.newPage(); + const errors: string[] = []; + page.on("console", (message) => { + if (message.type() === "error") { + errors.push(message.text()); + } + }); + page.on("pageerror", (error) => errors.push(error.message)); + + await page.goto("/"); + await expect(page.getByTestId("theme-toggle")).toHaveAttribute( + "data-preference", + preference, + ); + + expect(errors, `console errors with preference "${preference}"`).toEqual([]); + await context.close(); + } +}); + +test("theme change in one tab propagates to another tab", async ({ browser }) => { + const context = await browser.newContext(); + const pageA = await context.newPage(); + const pageB = await context.newPage(); + + await pageA.goto("/"); + await pageB.goto("/"); + + await pageA.getByTestId("theme-toggle").click(); + await pageA.getByTestId("theme-toggle").click(); + await expect(pageA.locator("html")).toHaveAttribute("data-theme", "dark"); + + await expect(pageB.locator("html")).toHaveAttribute("data-theme", "dark"); + await expect(pageB.getByTestId("theme-toggle")).toHaveAttribute( + "data-preference", + "dark", + ); + + await context.close(); +}); diff --git a/src/app/globals.css b/src/app/globals.css index 83fecd6..eff7e19 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,7 +1,91 @@ @import "tailwindcss"; +/* + * `color-scheme` drives theming: `light dark` follows the OS, an explicit + * `data-theme` narrows it, and `light-dark()` resolves each token against + * whichever is active — so every colour is declared once. It also themes the + * browser's own canvas and scrollbars, which is what avoids a flash on reload. + */ :root { - --background: #f6f7f8; + color-scheme: light dark; + + --canvas: light-dark(var(--color-slate-50), var(--color-slate-950)); + --surface: light-dark(var(--color-white), var(--color-slate-900)); + --surface-muted: light-dark(var(--color-slate-100), var(--color-slate-800)); + --surface-inverted: light-dark(var(--color-slate-950), var(--color-slate-100)); + + --content: light-dark(var(--color-slate-950), var(--color-slate-100)); + --content-muted: light-dark(var(--color-slate-500), var(--color-slate-400)); + --content-inverted: light-dark(var(--color-white), var(--color-slate-950)); + --content-inverted-muted: light-dark(var(--color-slate-400), var(--color-slate-600)); + + --border-subtle: light-dark(var(--color-slate-200), var(--color-slate-700)); + --border-strong: light-dark(var(--color-slate-400), var(--color-slate-600)); + + /* A modal scrim must dim the page in both themes, so it does not invert. */ + --scrim: var(--color-slate-950); + + --warn-surface: light-dark(var(--color-amber-50), var(--color-amber-950)); + --warn-border: light-dark(var(--color-amber-400), var(--color-amber-600)); + --warn-content: light-dark(var(--color-amber-900), var(--color-amber-200)); + + --success-surface: light-dark( + var(--color-emerald-100), + var(--color-emerald-950) + ); + --success-border: light-dark( + var(--color-emerald-300), + var(--color-emerald-700) + ); + --success-content: light-dark( + var(--color-emerald-900), + var(--color-emerald-200) + ); + + --danger-surface: light-dark(var(--color-rose-50), var(--color-rose-950)); + --danger-border: light-dark(var(--color-rose-300), var(--color-rose-700)); + --danger-content: light-dark(var(--color-rose-900), var(--color-rose-300)); + --danger-solid: light-dark(var(--color-rose-900), var(--color-rose-800)); + --danger-solid-content: var(--color-white); +} + +[data-theme="light"] { + color-scheme: light; +} + +[data-theme="dark"] { + color-scheme: dark; +} + +@theme inline { + --color-canvas: var(--canvas); + --color-surface: var(--surface); + --color-surface-muted: var(--surface-muted); + --color-surface-inverted: var(--surface-inverted); + + --color-content: var(--content); + --color-content-muted: var(--content-muted); + --color-content-inverted: var(--content-inverted); + --color-content-inverted-muted: var(--content-inverted-muted); + + --color-border: var(--border-subtle); + --color-border-strong: var(--border-strong); + + --color-scrim: var(--scrim); + + --color-warn-surface: var(--warn-surface); + --color-warn-border: var(--warn-border); + --color-warn-content: var(--warn-content); + + --color-success-surface: var(--success-surface); + --color-success-border: var(--success-border); + --color-success-content: var(--success-content); + + --color-danger-surface: var(--danger-surface); + --color-danger-border: var(--danger-border); + --color-danger-content: var(--danger-content); + --color-danger-solid: var(--danger-solid); + --color-danger-solid-content: var(--danger-solid-content); } html, @@ -21,10 +105,13 @@ button:disabled { cursor: not-allowed; } +/* Theme changes are instant: `light-dark()` follows the non-animatable + * `color-scheme`, so a transition has nothing to interpolate. */ + @keyframes transaction-card-surface-enter { from { - background-color: #dcfce7; - border-color: #86efac; + background-color: var(--color-success-surface); + border-color: var(--color-success-border); } to { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index ea5c594..642b25e 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -13,9 +13,7 @@ export default function RootLayout({ }>) { return ( - - {children} - + {children} ); } diff --git a/src/features/dashboard/components/create-subscription-form.tsx b/src/features/dashboard/components/create-subscription-form.tsx index 5995f27..08b16e2 100644 --- a/src/features/dashboard/components/create-subscription-form.tsx +++ b/src/features/dashboard/components/create-subscription-form.tsx @@ -27,27 +27,27 @@ export function CreateSubscriptionForm({ }: CreateSubscriptionFormProps) { return (
-

+

New demo subscriptions are charged immediately and then join the fixed billing cadence.

-