Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
369848f
docs: specify three-state theme behavior
hitpopdimestop Jul 27, 2026
360f365
feat: add pure theme preference logic
hitpopdimestop Jul 27, 2026
0b59f3f
feat: add semantic color tokens for theming
hitpopdimestop Jul 27, 2026
5ca4646
feat: apply stored theme before first paint
hitpopdimestop Jul 27, 2026
cfcb1cd
feat: add three-state theme toggle with cross-tab sync
hitpopdimestop Jul 27, 2026
ca51d9e
refactor: migrate shared button classes to theme tokens
hitpopdimestop Jul 27, 2026
78f68ca
refactor: migrate dashboard components to theme tokens
hitpopdimestop Jul 27, 2026
aa61c3f
fix: re-apply resolved theme after hydration
hitpopdimestop Jul 27, 2026
b2d0a38
test: cover theme persistence and cross-tab sync
hitpopdimestop Jul 27, 2026
219a1d0
feat: verify dark mode contrast and document theme feature
hitpopdimestop Jul 27, 2026
196c2cc
refactor: resolve theme in CSS instead of a blocking script
hitpopdimestop Jul 27, 2026
1282b5a
refactor: resolve theme in CSS and switch instantly
hitpopdimestop Jul 27, 2026
ae67032
docs: correct theme documentation after the CSS rewrite
hitpopdimestop Jul 27, 2026
763f1d5
style: trim theme comments to match the codebase
hitpopdimestop Jul 27, 2026
23e51b8
docs: plan theme review fixes
hitpopdimestop Jul 27, 2026
a3a522d
fix theme fallback when storage writes fail
hitpopdimestop Jul 27, 2026
127ce82
fix danger button contrast
hitpopdimestop Jul 27, 2026
58a9061
docs: refine subtle contrast plan
hitpopdimestop Jul 27, 2026
48e79b5
fix subtle text contrast
hitpopdimestop Jul 27, 2026
6baf1cc
test avoid shared dashboard state
hitpopdimestop Jul 27, 2026
e5d1af8
refactor: merge content-subtle, move contrast maths out of e2e
hitpopdimestop Jul 28, 2026
c8880fe
docs: record theme and contrast coverage in the testing strategy
hitpopdimestop Jul 28, 2026
4d910a7
docs: explain why theme stays off the domain event stream
hitpopdimestop Jul 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions docs/architecture-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions docs/frontend.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<html>` 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.
8 changes: 8 additions & 0 deletions docs/testing-strategy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -103,10 +106,15 @@ 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

- Prefer deterministic tests over timer-based sleeps.
- 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.
34 changes: 34 additions & 0 deletions e2e/contrast.ts
Original file line number Diff line number Diff line change
@@ -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);
}
12 changes: 11 additions & 1 deletion e2e/dashboard.spec.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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();

Expand All @@ -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();
});
184 changes: 184 additions & 0 deletions e2e/theme.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
Loading