Plan: light and dark mode for the Nuxt 3 app (plain CSS, SSR)
I haven't looked at your code, so file names below are placeholders. I did not run anything.
1. Token approach
- Audit. Grep components for
#[0-9a-fA-F]{3,8},rgb(,hsl(and named colors, including inlinestyleand SVGfill/strokeattributes. Group the values by role. A handful of distinct grays usually collapses into 3–4 surface and text roles. - Define role tokens, never value-named ones, in
assets/css/theme.css. Register it innuxt.config.tsundercss: ['~/assets/css/theme.css']. Role tokens:- Surfaces:
--bg,--surface,--surface-raised,--surface-input. - Text:
--text,--text-secondary,--text-disabled,--text-inverse,--link. - Borders and focus:
--border,--border-strong,--focus-ring. - Brand:
--primary,--primary-hover,--on-primary. - Status:
--success,--warning,--danger,--info, each with an--on-*token. --shadow.
- Surfaces:
- Pick values. Seed from the skill's starter palette, then substitute your brand colors. Set each dark value deliberately, with no
filter: invertand no mechanical lightness flip. Re-tune the status colors per theme. - Migrate components. Replace every hex with
var(--token). No.dark .card {}overrides. Switch SVG icons tocurrentColor.
2. Root and head setup
CSS, using data-theme on <html> as the single switch:
:root { color-scheme: light; /* light tokens */ }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) { color-scheme: dark; /* dark tokens */ }
}
:root[data-theme="dark"] { color-scheme: dark; /* dark tokens */ }
The dark tokens appear twice. To keep the copies from drifting, I'd generate one from the other with a small build step (PostCSS or a script). If that is too heavy, I'd put a comment on both blocks and add a check that diffs them.
Head setup in nuxt.config.ts:
app: {
head: {
meta: [
{ name: 'color-scheme', content: 'light dark' },
{ name: 'theme-color', content: '#ffffff', media: '(prefers-color-scheme: light)' },
{ name: 'theme-color', content: '#0f1115', media: '(prefers-color-scheme: dark)' },
],
script: [{
innerHTML: `(function(){try{var s=localStorage.getItem('theme');if(s==='light'||s==='dark')document.documentElement.setAttribute('data-theme',s)}catch(e){}})()`,
tagPosition: 'head',
}],
},
},
Nuxt/unhead renders a script with innerHTML as an inline, synchronous <script> in the server HTML. I'd check the rendered HTML to confirm it has no defer, async or type="module". Also check that the project's CSP, if it has one, allows inline scripts, with a nonce or hash if needed. Without a stored choice the script does nothing, so the media query handles System.
3. System / Light / Dark toggle
A useTheme() composable plus a ThemeToggle.vue component:
- State:
choiceis'system' | 'light' | 'dark'. - Select Light or Dark: set
data-themeand writelocalStorage.theme. - Select System: remove the attribute and remove the key. Never store the word "system".
- Storage access: wrap every read and write in
try/catch. - System changes: while the choice is System, listen to
matchMedia('(prefers-color-scheme: dark)')changes. This matters mainly fortheme-colorand for any JS that reads the theme. CSS follows the media query on its own. - Markup: a three-option radio group (
<fieldset>withrole="radiogroup") or three buttons witharia-pressed. It shows the current choice and has an accessible name.
4. Avoiding the flash and the hydration mismatch
Flash.
- The inline head script sets
data-themebefore first paint. <meta name="color-scheme">pluscolor-schemein CSS prevents a white canvas.- No theme transitions on first paint. Add a
theme-readyclass after mount if you want them.
Hydration mismatch. The server can't know the saved choice, so the server HTML must not depend on it.
- Initialize
choiceto'system'identically on server and client, and read storage only inonMounted. - Don't use
useStateoruseCookieto render a different icon or label per theme. - Make the toggle's visual state not depend on JS state at first render. Two options: render the control's checked state only after mount, or drive it from CSS with
:root[data-theme="dark"] .opt-dark { … }. - Don't bind
data-themethrough Vue on<html>. The head script owns it, and a Vue-managed attribute can conflict with it on hydration. - Never branch on
import.meta.clientorwindowduring render.
5. Other items to handle
- Logos and images with baked-in backgrounds: use
<picture>with amediaquery, or a variant switched bydata-theme. Note that<picture>follows the system setting, not the manual choice, so for a manual override use two<img>elements toggled by CSS. - Shadows, selection color, scrollbars, code blocks, charts, and third-party embeds. Embeds may remain known gaps.
- Print styles and email templates should stay light, set explicitly.
6. Verification
Contrast, checked per theme and per state (hover, focus, disabled, error, placeholder, link):
--texton--bg,--surfaceand--surface-raised.--text-secondaryon--bgand--surface.--linkon--bgand--surface.--on-primaryon--primaryand--primary-hover.- Each
--on-<status>on its status color. --border-strongand--focus-ringagainst their surfaces (3:1).- Placeholder on
--surface-input.
I'd compute these ratios with a script from the token values, which needs no browser. Thresholds are 4.5:1 for text and 3:1 for large text and UI parts.
Browser matrix, at phone and desktop widths:
- First load with no stored choice (system light and system dark), stored light, and stored dark. Throttle CPU and cache to catch any flash.
- Hard reload and client-side navigation.
- Toggle persists across reloads. System follows the OS.
- Storage blocked (private window or blocked site data): the page still renders correctly.
- Console shows no hydration warning.
- Keyboard focus rings visible in both themes. No text, icon or border disappears.
curlthe SSR HTML to confirm the inline script is in<head>and the toggle markup has no theme-dependent content.- Grep for leftover hard-coded colors.
What I could not check
I can't run the app, so none of the following is verified:
- Any visual result in either theme.
- The actual flash behavior.
- Hydration warnings.
- Real contrast of your brand colors. Starter values are placeholders, and the ratios have to be re-checked once your colors are in.
- Behavior at phone and desktop widths.
- Toggle persistence and OS-follow behavior.
- Blocked-storage behavior.
- Which unhead version and CSP settings your app has, which affects the inline script.
- Whether your images, charts or embeds have theme problems, because I haven't seen them.
Real run in an isolated folder with only this skill installed. Only the Skill and Read tools were enabled, so no command was run and no file was written. The scenario is fictional and the model had no access to the code. The model opened the skill's token starter file.
它做什麼
一套為網頁介面新增或修復深淺色模式的分步方法。它先看專案的框架與既有的主題體系,然後依角色定義語意 Token(背景、文字、邊框、品牌色與狀態色及其「其上文字色」),而不是直接寫十六進位顏色。它用根元素上的一個 data-theme 屬性來切換主題,預設跟隨系統設定,並一律設定 color-scheme,讓原生控制項與捲軸跟著變。它用放在 head 中的一小段同步腳本防止閃出錯誤主題,讀取已儲存的選擇時包在 try/catch 裡,並說明伺服器端算繪的頁面怎麼避免水合不一致。切換器有三個狀態:跟隨系統、淺色、深色。
檢查與修復
它列出了如何在每種主題、每個狀態下檢查對比度(一般文字 4.5:1,大字與有意義的介面元件 3:1),以及常見問題來源:寫死的顏色、白底圖片、沒用 currentColor 的圖示、陰影、圖表、第三方嵌入、程式碼區塊與列印樣式。一份參考檔提供了起步用的 Token,其中的佔位顏色已依它自己列出的對比度組合核算過。
適合什麼場景
為既有網站加深色模式,或修復看起來不對的深色主題。
低風險:純指令檔,沒有腳本,不連網、不寫檔。它會給出 CSS 與一小段內嵌腳本,要加進你自己的專案,上線前請先審閱。起步用的顏色只是佔位:已依 Skill 列出的對比度組合核算過,但你換上品牌色之後必須重新核對。它看不到你正在執行的頁面,會說明哪些檢查沒有做。較新的 CSS 與 meta 特性在不同瀏覽器的支援不同,請對照你的目標瀏覽器。AIBars 原創(MIT)。已用一個虛構的 Nuxt 應用試用過一次。