From c19ca55823a6a1d3ec38acc4a8de10067ec7a999 Mon Sep 17 00:00:00 2001 From: Mark Wylde Date: Sat, 9 Aug 2025 15:23:00 +0100 Subject: [PATCH] feat: improve theme handling with system preference and FOUC prevention - Add system theme preference detection as fallback when no saved preference exists - Implement automatic theme switching when system preference changes - Apply initial theme before React mount to prevent flash of unstyled content (FOUC) - Enhance segmented control styling with better focus states and active styling - Add defensive error handling for localStorage and matchMedia API access --- src/App.tsx | 47 ++++++++++++++++++++++++++++++++++++++++++++--- src/app.css | 14 ++++++++++++-- src/main.tsx | 10 ++++++++++ 3 files changed, 66 insertions(+), 5 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index d644082..097b25b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -154,14 +154,55 @@ function buildLogs(): LogRow[] { } export default function App() { - const [mode, setMode] = React.useState<'light' | 'dark'>( - () => (localStorage.getItem('massive-table-mode') as 'light' | 'dark') || 'light', - ); + const [mode, setMode] = React.useState<'light' | 'dark'>(() => { + try { + const saved = localStorage.getItem('massive-table-mode') as 'light' | 'dark' | null; + if (saved === 'light' || saved === 'dark') return saved; + } catch {} + try { + const prefersDark = window.matchMedia?.('(prefers-color-scheme: dark)')?.matches ?? false; + return prefersDark ? 'dark' : 'light'; + } catch {} + return 'light'; + }); React.useEffect(() => { + // Persist and apply theme at the document level so global CSS vars resolve try { localStorage.setItem('massive-table-mode', mode); } catch {} + try { + const root = document.documentElement; + root.setAttribute('data-theme', mode); + // Also mirror on body (defensive in case of component portals) + document.body?.setAttribute('data-theme', mode); + } catch {} }, [mode]); + + // If the user hasn't explicitly chosen a theme, follow system changes + React.useEffect(() => { + let hasSaved = false; + try { + hasSaved = !!localStorage.getItem('massive-table-mode'); + } catch {} + if (hasSaved) return; + const mql = window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)') : null; + if (!mql) return; + const handler = (ev: MediaQueryListEvent) => setMode(ev.matches ? 'dark' : 'light'); + try { + mql.addEventListener('change', handler); + } catch { + // Safari <14 legacy API + mql.addListener?.(handler); + } + return () => { + try { + mql.removeEventListener('change', handler); + } catch { + // Safari <14 legacy API + mql.removeListener?.(handler); + } + }; + }, []); // Build demo data in-memory (deterministic via Chance + SEED) const data = React.useMemo(() => Array.from({ length: rowCount }, (_, i) => makeRow(i)), []); diff --git a/src/app.css b/src/app.css index 91b1ad1..fdb64e1 100644 --- a/src/app.css +++ b/src/app.css @@ -76,19 +76,29 @@ body { .segmented { display: inline-flex; + align-items: center; + gap: 0; + padding: 2px; border: 1px solid var(--border); border-radius: 999px; + background: color-mix(in oklab, var(--fg) 6%, transparent); overflow: hidden; } .segmented button { - padding: 6px 10px; + padding: 6px 12px; background: transparent; border: 0; color: var(--fg); cursor: pointer; + border-radius: 999px; /* ensure pill corners when focused */ } .segmented button.active { - background: color-mix(in oklab, var(--fg) 6%, transparent); + background: var(--accent); + color: #fff; +} +.segmented button:focus-visible { + outline: none; + box-shadow: 0 0 0 2px color-mix(in oklab, var(--accent) 40%, transparent); } /* Shell */ diff --git a/src/main.tsx b/src/main.tsx index 84f7c0f..b4d1312 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -5,6 +5,16 @@ import './app.css'; // Prism theme (can swap for other themes) import 'prismjs/themes/prism.css'; +// Apply an initial theme attribute before React mounts to prevent FOUC +try { + const saved = localStorage.getItem('massive-table-mode'); + let initial: 'light' | 'dark' = 'light'; + if (saved === 'light' || saved === 'dark') initial = saved; + else if (window.matchMedia?.('(prefers-color-scheme: dark)')?.matches) initial = 'dark'; + document.documentElement.setAttribute('data-theme', initial); + document.body?.setAttribute('data-theme', initial); +} catch {} + const rootEl = document.getElementById('root'); if (rootEl) { const root = createRoot(rootEl);