Commit 06720e6d by PLN (Algolia)

theme: pin the resolved theme before paint; pill label waits for mount

parent 72e60913
...@@ -29,11 +29,14 @@ export const metadata = { ...@@ -29,11 +29,14 @@ export const metadata = {
/** /**
* Resolves the theme before first paint: stored choice wins, else system. * Resolves the theme before first paint: stored choice wins, else system.
* Keeps the HTML attribute semantics of styles/tokens.css — "dark" set * Always pins the resolved value — including "light", which must be set
* explicitly, "light" set explicitly (to opt out of the media query), * explicitly to opt out of the prefers-color-scheme fallback in
* nothing when the reader has no JS (the media query covers them). * styles/tokens.css (leaving it unpinned paints dark until hydration on
* dark-system machines whose reader chose light). Nothing is set when the
* reader has no JS… except there is no "when": this script only runs with
* JS. The no-JS story is the media query alone, untouched.
*/ */
const themeBootstrap = `(function(){try{var t=localStorage.getItem('pln:theme');if(t!=='light'&&(t==='dark'||window.matchMedia('(prefers-color-scheme: dark)').matches)){document.documentElement.dataset.theme='dark'}}catch(e){}})()`; const themeBootstrap = `(function(){try{var t=localStorage.getItem('pln:theme')||'auto';document.documentElement.dataset.theme=t==='auto'?(window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'):t}catch(e){}})()`;
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
return ( return (
......
...@@ -20,14 +20,17 @@ const LABELS = { ...@@ -20,14 +20,17 @@ const LABELS = {
* this component only manages the cycle and the persistence. * this component only manages the cycle and the persistence.
*/ */
export default function ThemeSwitcher() { export default function ThemeSwitcher() {
const [mode, setMode] = useState('auto'); // Null until mounted: SSR would paint the label "auto" even for a reader
// whose stored mode is light/dark — the glimpse this pill used to flash.
// An empty pill for a frame beats a wrong label.
const [mode, setMode] = useState(null);
useEffect(() => { useEffect(() => {
try { try {
const stored = window.localStorage.getItem(KEY); const stored = window.localStorage.getItem(KEY);
if (MODES.includes(stored)) setMode(stored); setMode(MODES.includes(stored) ? stored : 'auto');
} catch { } catch {
// storage refused — the pill still cycles, unaided setMode('auto'); // storage refused — the pill still cycles, unaided
} }
// Follow the system while in auto. // Follow the system while in auto.
const media = window.matchMedia('(prefers-color-scheme: dark)'); const media = window.matchMedia('(prefers-color-scheme: dark)');
...@@ -68,13 +71,17 @@ export default function ThemeSwitcher() { ...@@ -68,13 +71,17 @@ export default function ThemeSwitcher() {
type="button" type="button"
onClick={cycle} onClick={cycle}
className={styles.pill} className={styles.pill}
aria-label={LABELS[mode]} aria-label={LABELS[mode] ?? 'Thème'}
title={LABELS[mode]} title={LABELS[mode] ?? 'Thème'}
> >
{mode && (
<>
<span aria-hidden="true" className={styles.icon}> <span aria-hidden="true" className={styles.icon}>
{ICONS[mode]} {ICONS[mode]}
</span> </span>
<span className={styles.mode}>{mode}</span> <span className={styles.mode}>{mode}</span>
</>
)}
</button> </button>
); );
} }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment