Commit f8ab0604 by PLN (Algolia)

theme: site-wide dark/light/auto — token-based dark palette, no-FOUC bootstrap, fixed switcher pill

Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
parent 280b1af8
......@@ -3,6 +3,7 @@ import '@/styles/clarte-tokens.css';
import '@/styles/globals.css';
import '@/styles/main.css';
import '@/styles/masonry.css';
import ThemeSwitcher from '@/components/ThemeSwitcher';
export const metadata = {
metadataBase: new URL('https://me.nech.pl'),
......@@ -26,10 +27,24 @@ export const metadata = {
},
};
/**
* Resolves the theme before first paint: stored choice wins, else system.
* Keeps the HTML attribute semantics of styles/tokens.css — "dark" set
* explicitly, "light" set explicitly (to opt out of the media query),
* nothing when the reader has no JS (the media query covers them).
*/
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){}})()`;
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
<html lang="en" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: themeBootstrap }} />
</head>
<body>
{children}
<ThemeSwitcher />
</body>
</html>
);
}
'use client';
import { useEffect, useState } from 'react';
import styles from './ThemeSwitcher.module.css';
const KEY = 'pln:theme';
const MODES = ['auto', 'light', 'dark'];
const ICONS = { auto: '◐', light: '☀', dark: '☾' };
const LABELS = {
auto: 'Thème : auto',
light: 'Thème : clair',
dark: 'Thème : sombre',
};
/**
* Site-wide theme control — a small fixed pill, same register as the blog's
* font badge: one button, three states (auto / light / dark), works with
* touch as well as pointer. The resolved theme lands on <html data-theme>
* before paint (inline bootstrap in app/layout.tsx), so there is no flash;
* this component only manages the cycle and the persistence.
*/
export default function ThemeSwitcher() {
const [mode, setMode] = useState('auto');
useEffect(() => {
try {
const stored = window.localStorage.getItem(KEY);
if (MODES.includes(stored)) setMode(stored);
} catch {
// storage refused — the pill still cycles, unaided
}
// Follow the system while in auto.
const media = window.matchMedia('(prefers-color-scheme: dark)');
const apply = () => {
try {
const stored = window.localStorage.getItem(KEY) || 'auto';
const resolved =
stored === 'auto' ? (media.matches ? 'dark' : 'light') : stored;
document.documentElement.dataset.theme = resolved;
} catch {
// as above
}
};
apply();
media.addEventListener('change', apply);
return () => media.removeEventListener('change', apply);
}, []);
const cycle = () => {
const next = MODES[(MODES.indexOf(mode) + 1) % MODES.length];
setMode(next);
try {
window.localStorage.setItem(KEY, next);
} catch {
// persist is best-effort; the session still switches
}
const resolved =
next === 'auto'
? window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
: next;
document.documentElement.dataset.theme = resolved;
};
return (
<button
type="button"
onClick={cycle}
className={styles.pill}
aria-label={LABELS[mode]}
title={LABELS[mode]}
>
<span aria-hidden="true" className={styles.icon}>
{ICONS[mode]}
</span>
<span className={styles.mode}>{mode}</span>
</button>
);
}
.pill {
position: fixed;
bottom: 1rem;
right: 1rem;
z-index: 60;
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.3rem 0.75rem;
border: 1px dashed var(--border);
border-radius: 999px;
background: var(--surface-raised);
color: var(--text-muted);
font-size: 0.72rem;
letter-spacing: 0.04em;
cursor: pointer;
}
.pill:hover {
color: var(--text-primary);
}
.icon {
font-size: 0.85rem;
line-height: 1;
}
.mode {
text-transform: uppercase;
}
@media (prefers-reduced-motion: reduce) {
.pill {
transition: none;
}
}
......@@ -37,3 +37,32 @@
--text-muted: #737373;
color-scheme: dark;
}
/* Dark — neutral counterpart of the default palette. The ThemeSwitcher
(components/ThemeSwitcher.jsx) resolves auto/light/dark and sets
data-theme on <html> before paint; the media query below is the no-JS
fallback (system preference, when no explicit light choice is set).
Section themes keep winning: their attribute sits on a closer wrapper. */
[data-theme="dark"] {
--surface: #101010;
--surface-raised: #1a1a1a;
--text-primary: #e8e8e8;
--text-muted: #9a9a9a;
--link: #5aa2ff;
--link-hover: #85bbff;
--border: #2c2c2c;
color-scheme: dark;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--surface: #101010;
--surface-raised: #1a1a1a;
--text-primary: #e8e8e8;
--text-muted: #9a9a9a;
--link: #5aa2ff;
--link-hover: #85bbff;
--border: #2c2c2c;
color-scheme: dark;
}
}
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