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
5 changes: 5 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"enabledPlugins": {
"frontend-design@claude-plugins-official": true
}
}
6 changes: 1 addition & 5 deletions apps/web/src/components/HmrcResults.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,11 @@ import { useVirtualTextLayout } from 'virtual-text-layout';

import { useHmrcSearch } from '../hooks/useHmrcSearch';
import { useResultsKeyboardNav } from '../hooks/useResultsKeyboardNav';
import { formatLocation } from '../utils';
import { formatLocation, prefersReducedMotion } from '../utils';
import HmrcCard from './HmrcCard';
import SkeletonCards from './SkeletonCards';
import UnionJackLens from './UnionJackLens';

const prefersReducedMotion = () =>
typeof window !== 'undefined' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches;

// Vertical centre of a card's name line from its top: py-2(8) + nameLine(24)/2.
const NAME_LINE_CENTER = 20;
// Rail/marker horizontal centre, in the content box's coordinate space (x=0 is
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/components/LondonSkyline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,9 @@ export default function LondonSkyline({ className }: LondonSkylineProps) {
strokeLinejoin="round"
role="img"
aria-label="London skyline"
data-london-skyline
data-sun-x={CELESTIAL.cx}
data-sun-y={CELESTIAL.cy}
>
{/* Sun — light mode only (disc fills in gradually + rays) */}
<g key={`sun-${themeFlips}`} className={styles.sun}>
Expand Down
55 changes: 38 additions & 17 deletions apps/web/src/components/ThemeToggle.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';

import { THEME_COLORS } from '../theme';
import { cancelThemeTransition, runThemeTransition } from '../theme-transition';
import { MonitorIcon, MoonIcon, SunIcon } from './ThemeIcons';

type ThemeMode = 'light' | 'dark' | 'auto';
Expand All @@ -22,26 +23,46 @@ function getInitialMode(): ThemeMode {
return 'auto';
}

/** Resolve a mode to a concrete `light`/`dark` theme, reading the OS preference for `'auto'`. */
function resolveMode(mode: ThemeMode): 'light' | 'dark' {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
return mode === 'auto' ? (prefersDark ? 'dark' : 'light') : mode;
}

/**
* Apply a theme mode to the document: toggles the `light`/`dark` class and
* `color-scheme` on `<html>`, and updates the `theme-color` meta so mobile
* browser chrome matches the app background. `'auto'` resolves via
* `prefers-color-scheme`.
* `prefers-color-scheme`. When `animate` is true and the resolved theme
* actually changes, the swap is hidden inside a day<->night sky timelapse.
*/
function applyThemeMode(mode: ThemeMode) {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const resolved = mode === 'auto' ? (prefersDark ? 'dark' : 'light') : mode;

document.documentElement.classList.remove('light', 'dark');
document.documentElement.classList.add(resolved);
document.documentElement.style.colorScheme = resolved;

// Update mobile browser chrome to match the app background
const meta = document.querySelector<HTMLMetaElement>(
'meta[name="theme-color"]',
);
if (meta) {
meta.content = resolved === 'dark' ? THEME_COLORS.dark : THEME_COLORS.light;
function applyThemeMode(mode: ThemeMode, animate = false) {
const resolved = resolveMode(mode);
const root = document.documentElement;
const current = root.classList.contains('dark') ? 'dark' : 'light';

const swap = () => {
root.classList.remove('light', 'dark');
root.classList.add(resolved);
root.style.colorScheme = resolved;

// Update mobile browser chrome to match the app background
const meta = document.querySelector<HTMLMetaElement>(
'meta[name="theme-color"]',
);
if (meta) {
meta.content =
resolved === 'dark' ? THEME_COLORS.dark : THEME_COLORS.light;
}
};

if (animate && resolved !== current) {
runThemeTransition(swap);
} else {
// Cancel any in-flight transition first so its deferred swap can't fire late
// and clobber this (newer) theme — otherwise a rapid re-toggle desyncs <html>.
cancelThemeTransition();
swap();
}
}

Expand All @@ -66,7 +87,7 @@ export default function ThemeToggle() {
}

const media = window.matchMedia('(prefers-color-scheme: dark)');
const onChange = () => applyThemeMode('auto');
const onChange = () => applyThemeMode('auto', true);

media.addEventListener('change', onChange);
return () => {
Expand All @@ -82,7 +103,7 @@ export default function ThemeToggle() {
const nextMode: ThemeMode =
mode === 'light' ? 'dark' : mode === 'dark' ? 'auto' : 'light';
setMode(nextMode);
applyThemeMode(nextMode);
applyThemeMode(nextMode, true);
window.localStorage.setItem('theme', nextMode);
}

Expand Down
Loading
Loading