Темна тема
Клас на html, блокуючий скрипт проти спалаху і чому inline в @theme обов'язковий.
Тема перемикається класом .dark на <html>, а не медіазапитом. Медіазапит
не дав би користувачеві вибору — а вибір потрібен: ту саму систему вмикають
і вдень біля вікна, і вночі.
Чому компоненти не мають dark:-префіксів
Утиліти посилаються на CSS-змінні, а .dark перевизначає самі змінні. Тому
bg-card працює в обох темах без жодного dark:bg-....
Це тримається на одному слові в theme.css:
@theme inline {
--color-card: var(--bg-card);
}
Без inline Tailwind підставив би значення змінної в момент генерації,
і bg-card назавжди застигла б білою. Це найлегша помилка в усій системі й
найважча для діагностики: усе виглядає правильно, поки не перемкнеш тему.
Спалах при завантаженні
Сторінки прередерені, і в їхньому HTML класу .dark немає — його ставить
лише клієнт. Без запобіжника користувач із темною темою бачив би білий
спалах на кожному завантаженні, поки не відпрацює гідрація.
Тому в <head> стоїть блокуючий скрипт, який виконується до першого
малювання:
(() => {
try {
const s = localStorage.getItem('tatetui-theme')
const d = s ? s === 'dark' : matchMedia('(prefers-color-scheme:dark)').matches
if (d) document.documentElement.classList.add('dark')
} catch (e) {}
})()
try/catch обов'язковий: у приватному режимі Safari звернення до
localStorage кидає виняток, і без нього сторінка не намалювалася б узагалі.
Три стани, а не два
Перемикач у шапці пропонує «Світла», «Темна» і «Як у системі». Явний вибір
пишеться в localStorage; «як у системі» — це відсутність ключа, а не
рядок 'system'. Скрипт вище вже трактує відсутність саме так, тож третій
стан не потребує змін у ньому. Поки обрано системну тему, useTheme()
слухає matchMedia('(prefers-color-scheme: dark)') і перемикає клас
наживо, без перезавантаження.
Двопозиційний перемикач назавжди фіксував вибір: користувач, який просто хотів глянути на іншу тему, потім не розумів, чому сайт не темнішає ввечері разом із системою.
Кругове розкриття
Якщо браузер має View Transitions API, нова тема «розходиться» колом від
точки кліку — старий знімок сторінки лишається на місці, новий
розкривається через clip-path. Це прикраса, а не вимога: без API просто
перемикається клас, під prefers-reduced-motion — теж. CSS живе в
main.css, центр і радіус кола ставить useTheme() через змінні
--theme-x, --theme-y, --theme-r.
Джерело правди — DOM, а не сховище
useTheme() читає початкове значення з класу на <html>, а не з
localStorage. Клас уже поставив скрипт вище, і саме він — джерело правди.
Якби composable читав сховище сам, сервер і клієнт розійшлися б у першому рендері прередереної сторінки, і Vue лаявся б на невідповідність гідрації.
Що перевіряти в кожному компоненті
Тіні. У світлій темі --shadow-card це 4% чорного — на темному тлі такого
просто не видно, і картка «злипається» з фоном. Тому в темній темі тіні
сильніші, і це задано токеном, а не класом.
Напівпрозорі підкладки. bg-primary-900/30 над майже чорним тлом дає
видимий результат лише тому, що --brand-900 у темній темі світліший.
Якщо додаєте власний відтінок — перевірте його саме в такому вживанні.