Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
58 changes: 52 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,52 @@ This is **Identity System v1** — see [Brand assets](#brand-assets):
`next/font/google`.
- **Dark theme** — a token-level flip in `globals.css`. It follows
`prefers-color-scheme` by default; a stored preference stamps `data-theme` on
`<html>` and wins in both directions (`ThemeToggle` in the navbar, with a
no-FOUC script in the layout).
`<html>` and wins in both directions. Theme state is owned by
`<ThemeProvider>` (`src/components/theme/`) via the `useTheme` hook — the
per-user choice is persisted to localStorage, the OS preference is followed
live until a choice is made, and a no-FOUC script in the layout paints the
initial theme before React hydrates (`ThemeToggle` in the navbar).

#### Theme usage (light & dark mode)

`src/lib/theme.ts` is the single source of truth for theme logic: the `Theme`
type, the `THEME_STORAGE_KEY` constant, storage get/set, system-preference
resolution and listening, and the shared no-FOUC `themeScript`. Components read
`resolvedTheme` and call `setTheme` / `toggleTheme` through the `useTheme()`
hook from `src/components/theme/`.

Two wiring steps are required in the root layout
(`src/app/layout.tsx`) — both are already in place; keep them if you ever
rewrite the layout:

1. **Paint before React hydrates (no flash).** Insert the exported inline
script in `<head>` via React's safe `<script dangerouslySetInnerHTML>`
API. It stamps `<html data-theme>` + `color-scheme` from the stored
choice, falling back to the OS preference:

```tsx
import { themeScript } from "@/lib/theme";

// inside <head>:
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
```

2. **Wrap the whole app in `<ThemeProvider>`** so every theme-aware
component reads a consistent `resolvedTheme` (and re-renders on change)
from its very first render:

```tsx
<ThemeProvider>
{/* Navbar (with <ThemeToggle />), main, footer, toasts … */}
</ThemeProvider>
```

The per-user choice is persisted to localStorage under `THEME_STORAGE_KEY`
and wins over the OS preference; until a choice is made the app follows
`prefers-color-scheme` live. The navy/gold/terracotta **color tokens** and
their dark-mode overrides live in `src/app/globals.css` (the `@theme inline`
block plus `data-theme` overrides) — update them there if the brand palette
ever changes.

## Brand assets

Expand Down Expand Up @@ -242,10 +286,12 @@ npx eslint . # lint
npx next build # production build, also runs a TypeScript check
```

There's no dedicated unit/integration test suite yet — verification today is
type-checking, linting, a production build, and Playwright-driven smoke
testing of every route (checking for console errors, layout overflow, and
broken images) done ad hoc during review rather than committed as a CI suite.
Unit tests live in `src/lib/test/` (theme system, timezone, slot locking,
escrow funding, identity verification) and run with `npm test`. Verification
today is `npm test`, type-checking, linting, a production build, and
Playwright-driven smoke testing of every route (checking for console errors,
layout overflow, and broken images) done ad hoc during review rather than
committed as a CI suite.

## Deployment

Expand Down
57 changes: 49 additions & 8 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,57 @@
# Transaction Notification Center — Implementation Checklist ✓
# Design-System Theme Provider (navy/gold/terracotta) — Implementation Checklist ✓

## Objective
Codify the navy/gold/terracotta identity system into a theme provider with
accessible light and dark modes persisted per user (issue #30).

## Files to Create
- [x] `src/lib/notifications.ts` — Types, interfaces, and helper functions
- [x] `src/components/notifications/useNotifications.tsx` — React Context, Provider, and hook
- [x] `src/components/notifications/NotificationToast.tsx` — Floating toast component
- [x] `src/components/notifications/NotificationCenter.tsx` — Dropdown panel with notification list
- [x] `src/lib/theme.ts` — Theme system single source of truth: `Theme` type,
`THEME_STORAGE_KEY`, storage get/set, `getSystemTheme`, `applyTheme`,
`listenForSystemTheme`, `resolveTheme`, and the shared no-FOUC
`themeScript`.
- [x] `src/components/theme/ThemeProvider.tsx` — React Context provider +
`useTheme` hook (stored choice wins, OS preference followed live until
the user picks).
- [x] `src/components/theme/index.ts` — Barrel export (matches
`notifications/index.ts`).
- [x] `src/lib/test/theme.test.ts` — Unit tests (storage, resolution,
application, system listening, script).

## Files to Edit
- [x] `src/app/layout.tsx` — Wrap with NotificationProvider
- [x] `src/components/Navbar.tsx` — Add bell icon with unread count badge
- [x] `src/components/ThemeToggle.tsx` — Consume `useTheme`; added
`aria-pressed`; visuals unchanged.
- [x] `src/app/layout.tsx` — Wrap app in `<ThemeProvider>`; inline no-FOUC
script now imported from `@/lib/theme` (single source of truth).

## Architectural Decisions
- **No new dependencies.** Uses React Context (existing pattern from
`NotificationProvider`), `react-icons` (already a dep), and the existing
Tailwind v4 token setup.
- **Tokens stay in CSS.** The navy/gold/terracotta tokens and their dark-mode
overrides live in `src/app/globals.css` via `@theme inline` + `data-theme`
(this was already in place). `src/lib/theme.ts` owns the *logic* around
those tokens.
- **Persistence is per-user via localStorage** (key `theme`) — the same
mechanism the previous standalone toggle used, so existing stored choices
keep working. No auth/session exists yet, so browser-local is the right
scope; a per-account key can be layered on later.
- **Dark mode is a first-class, accessible mode:** `color-scheme` is set so
native controls adapt, focus rings use `outline-gold`, and the toggle
exposes `aria-label` + `aria-pressed`.
- **No flash of wrong theme (FOUC):** the root layout's inline
`themeScript` (now imported from `@/lib/theme`) paints the correct theme
before React hydrates; the provider only keeps React state in sync.
- **OS-follow by default:** until the user makes an explicit choice the
provider follows `prefers-color-scheme` live; choosing persists and stops
following.

## Verification
- [x] `npm run lint` — No linting errors
- [x] `npm run build` — Production build succeeds (17 routes)
- [x] `npm run typecheck` — No TypeScript errors
- [x] `npm test` — All tests pass (incl. `theme.test.ts`)
- [x] `npm run build` — Production build succeeds

## CI Note
The issue's "add caching for npm dependencies in CI" task was already
satisfied: `.github/workflows/ci.yml` uses `actions/setup-node` with
`cache: npm`.
28 changes: 18 additions & 10 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,16 @@ import "leaflet/dist/leaflet.css";
import "./globals.css";
import Navbar from "@/components/Navbar";
import Footer from "@/components/Footer";
import { ThemeProvider } from "@/components/theme";
import { NotificationProvider } from "@/components/notifications/useNotifications";
import NotificationToast from "@/components/notifications/NotificationToast";
import { themeScript } from "@/lib/theme";

const inter = Inter({
variable: "--font-inter",
subsets: ["latin"],
});

// Runs before paint so the correct theme is applied with no flash: honour a
// stored preference, otherwise fall back to the OS setting (handled in CSS).
const themeScript = `(function(){try{var t=localStorage.getItem('theme');var r=document.documentElement;if(t==='light'||t==='dark'){r.setAttribute('data-theme',t);r.style.colorScheme=t;}else{r.style.colorScheme=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';}}catch(e){}})();`;

export const metadata: Metadata = {
title: "GuildWorkman — Book trusted local pros",
description:
Expand All @@ -30,15 +28,25 @@ export default function RootLayout({
return (
<html lang="en" suppressHydrationWarning className={`${inter.variable} h-full antialiased`}>
<head>
{/*
* No-FOUC theme script: paints <html data-theme> + color-scheme
* before React hydrates. dangerouslySetInnerHTML is React's
* sanctioned way to emit an inline script; `themeScript` is an
* immutable, minified const whose only interpolations are
* compile-time constants (never user input), so there's no XSS
* surface here.
*/}
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
</head>
<body className="min-h-full flex flex-col bg-sand text-ink">
<NotificationProvider>
<Navbar />
<main className="flex-1">{children}</main>
<Footer />
<NotificationToast />
</NotificationProvider>
<ThemeProvider>
<NotificationProvider>
<Navbar />
<main className="flex-1">{children}</main>
<Footer />
<NotificationToast />
</NotificationProvider>
</ThemeProvider>
</body>
</html>
);
Expand Down
45 changes: 14 additions & 31 deletions src/components/ThemeToggle.tsx
Original file line number Diff line number Diff line change
@@ -1,48 +1,31 @@
"use client";

import { useEffect, useState } from "react";
import { FaSun, FaMoon } from "react-icons/fa6";
import { useTheme } from "./theme";

type Theme = "light" | "dark";

/** Light/dark toggle. Defaults to the system preference; once toggled,
the choice is stamped on <html data-theme> and persisted. The initial
paint is handled by the inline script in the root layout (no FOUC). */
/** Light/dark toggle. State is owned by <ThemeProvider> (per-user choice
persisted to localStorage; OS preference followed until a choice is
made). The initial paint is handled by the inline script in the root
layout, so there is no flash of the wrong theme. */
export default function ThemeToggle() {
const [theme, setTheme] = useState<Theme | null>(null);

useEffect(() => {
const stored = localStorage.getItem("theme");
if (stored === "light" || stored === "dark") {
setTheme(stored);
} else {
setTheme(window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
}
}, []);

const toggle = () => {
const next: Theme = theme === "dark" ? "light" : "dark";
setTheme(next);
const root = document.documentElement;
root.setAttribute("data-theme", next);
root.style.colorScheme = next;
localStorage.setItem("theme", next);
};
const { resolvedTheme, toggleTheme } = useTheme();
const isDark = resolvedTheme === "dark";

return (
<button
type="button"
onClick={toggle}
aria-label={theme === "dark" ? "Switch to light theme" : "Switch to dark theme"}
onClick={toggleTheme}
aria-label="Toggle color theme"
aria-pressed={isDark}
className="flex h-9 w-9 items-center justify-center rounded-xl border border-line text-muted transition hover:border-navy-2 hover:text-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gold"
>
{/* Render nothing until mounted to avoid a mismatched icon flash */}
{theme === "dark" ? (
{resolvedTheme === null ? (
<span className="h-[0.95rem] w-[0.95rem]" />
) : isDark ? (
<FaSun aria-hidden className="text-[0.95rem]" />
) : theme === "light" ? (
<FaMoon aria-hidden className="text-[0.95rem]" />
) : (
<span className="h-[0.95rem] w-[0.95rem]" />
<FaMoon aria-hidden className="text-[0.95rem]" />
)}
</button>
);
Expand Down
109 changes: 109 additions & 0 deletions src/components/theme/ThemeProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
"use client";

import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import {
applyTheme,
getStoredTheme,
getSystemTheme,
listenForSystemTheme,
setStoredTheme,
type Theme,
} from "@/lib/theme";

// ── Context shape ──────────────────────────────────────────────────────
interface ThemeContextValue {
/** The user's explicit choice, or `null` while following the OS. */
theme: Theme | null;
/**
* The effective theme — the user's explicit choice once made, otherwise
* the OS preference. `null` only for the single render before the
* initialisation effect runs (render nothing theme-dependent then).
*/
resolvedTheme: Theme | null;
/** Persist and apply an explicit choice. */
setTheme: (theme: Theme) => void;
/** Flip between light and dark, persisting the choice. */
toggleTheme: () => void;
}

const ThemeContext = createContext<ThemeContextValue | null>(null);

// ── Provider ───────────────────────────────────────────────────────────
/**
* Owns the navy/gold/terracotta light & dark modes. A stored per-user
* choice wins; otherwise the OS preference is followed live until the user
* picks. The root layout's inline script paints the initial theme before
* React hydrates, so this provider only keeps React state + <html> in sync
* — never a visible flash.
*/
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<Theme | null>(null);
const [systemTheme, setSystemTheme] = useState<Theme | null>(null);

// Initialise once: honour a stored choice, else resolve the OS
// preference. The no-FOUC script in the layout has already painted the
// correct theme by now, so this is pure state synchronisation.
useEffect(() => {
const stored = getStoredTheme();
if (stored) {
setThemeState(stored);
return;
}
setSystemTheme(getSystemTheme());
}, []);

// Apply changes to <html> and keep following the OS live, but only while
// the user has not made an explicit choice. When `theme` becomes non-null
// this effect returns early without subscribing — and because React runs
// the previous effect's cleanup first, the OS listener is unsubscribed the
// moment an explicit choice is made (and again on unmount). So the promise
// holds: "stored per-user choice wins; otherwise OS preference followed
// live until the user chooses."
useEffect(() => {
const resolved = theme ?? systemTheme;
if (resolved) applyTheme(resolved);
if (theme) return;
return listenForSystemTheme(setSystemTheme);
}, [theme, systemTheme]);

const setTheme = useCallback((next: Theme) => {
setThemeState(next);
setStoredTheme(next);
}, []);

const toggleTheme = useCallback(() => {
// resolvedTheme may still be null for a single pre-effect render;
// fall back to the OS preference so the first click is always right.
const resolved = theme ?? systemTheme ?? getSystemTheme();
setTheme(resolved === "dark" ? "light" : "dark");
}, [theme, systemTheme, setTheme]);

const value = useMemo<ThemeContextValue>(
() => ({
theme,
resolvedTheme: theme ?? systemTheme,
setTheme,
toggleTheme,
}),
[theme, systemTheme, setTheme, toggleTheme]
);

return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}

// ── Hook ───────────────────────────────────────────────────────────────
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) {
throw new Error("useTheme must be used within a <ThemeProvider>");
}
return ctx;
}
4 changes: 4 additions & 0 deletions src/components/theme/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Barrel for the theme system (same pattern as notifications/index.ts).
// ThemeProvider only imports from "@/lib/theme" — nothing here imports back
// through this barrel — so re-exporting creates no circular-import risk.
export { ThemeProvider, useTheme } from "./ThemeProvider";
4 changes: 4 additions & 0 deletions src/lib/test/setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// React 19 requires this flag so `act(...)` runs in the dedicated
// "act environment" mode instead of warning that the environment isn't
// configured for it. Vitest loads this file before every test file.
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
Loading
Loading