From 369848f6035407ac4162edae4a5d4b06742b4003 Mon Sep 17 00:00:00 2001 From: Dmytro Harastovych Date: Mon, 27 Jul 2026 14:44:36 +0300 Subject: [PATCH 01/23] docs: specify three-state theme behavior --- docs/frontend.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/frontend.md b/docs/frontend.md index a23bd9c..990658c 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -41,3 +41,15 @@ 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`. +- The resolved theme is exposed as a `data-theme` attribute on the `` element. This attribute is always concrete (`light` or `dark`) and never holds the literal value `system`. +- The resolved theme must be applied before first paint. A blocking inline script in `` reads the preference and sets `data-theme` during HTML parsing, so a reloaded page never flashes the wrong theme. +- Changing the theme in one tab propagates to other open tabs via the `storage` event. +- Theme is client-only state. It is not part of the domain, is never sent over `GET /api/stream`, and never reaches the in-memory store or any server route. This is deliberate: theme is a per-browser presentation concern, unlike subscription state which is server-owned and synchronized over SSE. +- The theme control is available in the dashboard header. From 360f3656e664bd07721f3163afa59595103865c4 Mon Sep 17 00:00:00 2001 From: Dmytro Harastovych Date: Mon, 27 Jul 2026 14:49:14 +0300 Subject: [PATCH 02/23] feat: add pure theme preference logic --- src/features/theme/theme.test.ts | 57 ++++++++++++++++++++++++++++++++ src/features/theme/theme.ts | 30 +++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 src/features/theme/theme.test.ts create mode 100644 src/features/theme/theme.ts diff --git a/src/features/theme/theme.test.ts b/src/features/theme/theme.test.ts new file mode 100644 index 0000000..3b041f3 --- /dev/null +++ b/src/features/theme/theme.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { + nextPreference, + parseStoredPreference, + resolveTheme, + THEME_PREFERENCES, + THEME_STORAGE_KEY, +} from "./theme"; + +describe("THEME_STORAGE_KEY", () => { + it("uses the namespaced key from the spec", () => { + expect(THEME_STORAGE_KEY).toBe("subscriptions:theme"); + }); +}); + +describe("parseStoredPreference", () => { + it("accepts the three valid preferences", () => { + expect(parseStoredPreference("system")).toBe("system"); + expect(parseStoredPreference("light")).toBe("light"); + expect(parseStoredPreference("dark")).toBe("dark"); + }); + + it("falls back to system for a missing value", () => { + expect(parseStoredPreference(null)).toBe("system"); + }); + + it("falls back to system for unparseable values", () => { + expect(parseStoredPreference("")).toBe("system"); + expect(parseStoredPreference("DARK")).toBe("system"); + expect(parseStoredPreference("midnight")).toBe("system"); + expect(parseStoredPreference("{}")).toBe("system"); + }); +}); + +describe("resolveTheme", () => { + it("follows the system signal when preference is system", () => { + expect(resolveTheme("system", true)).toBe("dark"); + expect(resolveTheme("system", false)).toBe("light"); + }); + + it("lets an explicit preference override the system signal", () => { + expect(resolveTheme("light", true)).toBe("light"); + expect(resolveTheme("dark", false)).toBe("dark"); + }); +}); + +describe("nextPreference", () => { + it("cycles system -> light -> dark -> system", () => { + expect(nextPreference("system")).toBe("light"); + expect(nextPreference("light")).toBe("dark"); + expect(nextPreference("dark")).toBe("system"); + }); + + it("cycles through every declared preference", () => { + expect(THEME_PREFERENCES).toEqual(["system", "light", "dark"]); + }); +}); diff --git a/src/features/theme/theme.ts b/src/features/theme/theme.ts new file mode 100644 index 0000000..3f8ba27 --- /dev/null +++ b/src/features/theme/theme.ts @@ -0,0 +1,30 @@ +export const THEME_STORAGE_KEY = "subscriptions:theme"; + +export type ThemePreference = "system" | "light" | "dark"; +export type ResolvedTheme = "light" | "dark"; + +export const THEME_PREFERENCES: readonly ThemePreference[] = [ + "system", + "light", + "dark", +]; + +export function parseStoredPreference(raw: string | null): ThemePreference { + return raw === "light" || raw === "dark" || raw === "system" ? raw : "system"; +} + +export function resolveTheme( + preference: ThemePreference, + systemPrefersDark: boolean, +): ResolvedTheme { + if (preference === "system") { + return systemPrefersDark ? "dark" : "light"; + } + + return preference; +} + +export function nextPreference(current: ThemePreference): ThemePreference { + const index = THEME_PREFERENCES.indexOf(current); + return THEME_PREFERENCES[(index + 1) % THEME_PREFERENCES.length]; +} From 0b59f3fc37a77db5be1ecd90005c21456bce1690 Mon Sep 17 00:00:00 2001 From: Dmytro Harastovych Date: Mon, 27 Jul 2026 14:49:50 +0300 Subject: [PATCH 03/23] feat: add semantic color tokens for theming --- src/app/globals.css | 88 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 85 insertions(+), 3 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index 83fecd6..b015b9b 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,7 +1,89 @@ @import "tailwindcss"; +@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *)); + :root { - --background: #f6f7f8; + --canvas: var(--color-slate-50); + --surface: var(--color-white); + --surface-muted: var(--color-slate-100); + --surface-inverted: var(--color-slate-950); + + --content: var(--color-slate-950); + --content-muted: var(--color-slate-500); + --content-subtle: var(--color-slate-400); + --content-inverted: var(--color-white); + + --border-subtle: var(--color-slate-200); + --border-strong: var(--color-slate-400); + + --warn-surface: var(--color-amber-50); + --warn-border: var(--color-amber-400); + --warn-content: var(--color-amber-900); + + --success-surface: var(--color-emerald-100); + --success-border: var(--color-emerald-300); + --success-content: var(--color-emerald-900); + + --danger-surface: var(--color-rose-50); + --danger-border: var(--color-rose-300); + --danger-content: var(--color-rose-900); + --danger-solid: var(--color-rose-900); +} + +[data-theme="dark"] { + --canvas: var(--color-slate-950); + --surface: var(--color-slate-900); + --surface-muted: var(--color-slate-800); + --surface-inverted: var(--color-slate-100); + + --content: var(--color-slate-100); + --content-muted: var(--color-slate-400); + --content-subtle: var(--color-slate-500); + --content-inverted: var(--color-slate-950); + + --border-subtle: var(--color-slate-700); + --border-strong: var(--color-slate-600); + + --warn-surface: var(--color-amber-950); + --warn-border: var(--color-amber-600); + --warn-content: var(--color-amber-200); + + --success-surface: var(--color-emerald-950); + --success-border: var(--color-emerald-700); + --success-content: var(--color-emerald-200); + + --danger-surface: var(--color-rose-950); + --danger-border: var(--color-rose-700); + --danger-content: var(--color-rose-300); + --danger-solid: var(--color-rose-800); +} + +@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-subtle: var(--content-subtle); + --color-content-inverted: var(--content-inverted); + + --color-border: var(--border-subtle); + --color-border-strong: var(--border-strong); + + --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); } html, @@ -23,8 +105,8 @@ button:disabled { @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 { From 5ca46467c721832f0ea097cade448188620f9157 Mon Sep 17 00:00:00 2001 From: Dmytro Harastovych Date: Mon, 27 Jul 2026 14:50:17 +0300 Subject: [PATCH 04/23] feat: apply stored theme before first paint --- src/app/layout.tsx | 10 ++++++---- src/features/theme/theme-script.tsx | 9 +++++++++ 2 files changed, 15 insertions(+), 4 deletions(-) create mode 100644 src/features/theme/theme-script.tsx diff --git a/src/app/layout.tsx b/src/app/layout.tsx index ea5c594..eecab6c 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import { ThemeScript } from "../features/theme/theme-script"; import "./globals.css"; export const metadata: Metadata = { @@ -12,10 +13,11 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - - - {children} - + + + + + {children} ); } diff --git a/src/features/theme/theme-script.tsx b/src/features/theme/theme-script.tsx new file mode 100644 index 0000000..761b149 --- /dev/null +++ b/src/features/theme/theme-script.tsx @@ -0,0 +1,9 @@ +import { THEME_STORAGE_KEY } from "./theme"; + +const THEME_SCRIPT = `(function(){try{var p=localStorage.getItem(${JSON.stringify( + THEME_STORAGE_KEY, +)});if(p!=="light"&&p!=="dark")p="system";var d=p==="dark"||(p==="system"&&window.matchMedia("(prefers-color-scheme: dark)").matches);document.documentElement.setAttribute("data-theme",d?"dark":"light")}catch(e){}})()`; + +export function ThemeScript() { + return