add dark mode toggle to the editor UI - #125
Conversation
The editor chrome was hard-locked to the light paper palette (color-scheme: light, no theme mechanism). This adds a warm dark counterpart of the existing open-design palette as a :root[data-theme="dark"] variable override, a sun/moon toggle in the top bar next to the history button, and a pre-hydration inline script so a reload never flashes the wrong theme. - First visit follows prefers-color-scheme; an explicit choice is persisted under the "html-anything-theme" localStorage key. - Rendered artifacts (preview / deck / history / samples iframes) keep their own colors — generated documents are not re-themed. - Light mode is unchanged: the default :root palette is untouched; the only refactors are the header background and body grain moving into variables, plus one kbd chip in the samples gallery using var(--surface) instead of bg-white. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
nettee
left a comment
There was a problem hiding this comment.
Found one blocking issue in the new dark-mode toggle flow. The palette variable overrides look consistent overall, but the toggle can mount out of sync with the boot-time DOM theme on dark reloads, which breaks the first interaction.
🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.| const [theme, setTheme] = useState<Theme>("light"); | ||
| const t = useT(); | ||
|
|
||
| useEffect(() => { | ||
| setTheme(document.documentElement.dataset.theme === "dark" ? "dark" : "light"); | ||
| }, []); | ||
|
|
||
| const toggle = () => { | ||
| const next: Theme = theme === "dark" ? "light" : "dark"; |
There was a problem hiding this comment.
When the boot script in layout.tsx has already applied data-theme="dark" on reload, this component still mounts with theme === "light" until the effect runs. That means the icon and aria-pressed are wrong on first paint, and if the user clicks immediately toggle() computes next = "dark" again, so the first click is a no-op instead of switching back to light. Please initialize the React state from document.documentElement.dataset.theme before the first render, or derive the next value from the live DOM attribute inside toggle(), so the control stays in sync with the pre-hydration theme.
Review follow-up: the toggle mounted with useState("light") and only
synced to the boot script's data-theme in an effect, so on a dark
reload the icon / aria-pressed were stale at first paint and a click
in that window re-applied "dark" instead of switching to light.
Replace the state + effect pair with useSyncExternalStore: the <html>
data-theme attribute is now the single source of truth, read before
first paint (server snapshot stays "light" to match SSR markup) and
observed via MutationObserver, and toggle() derives the next theme
from the live attribute.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
nettee
left a comment
There was a problem hiding this comment.
Found one non-blocking follow-up in the boot-time theme initialization. The useSyncExternalStore fix resolves the stale-toggle regression, but the reload path can still skip the dark theme when storage access is denied.
| <script | ||
| dangerouslySetInnerHTML={{ | ||
| __html: | ||
| '(function(){try{var t=localStorage.getItem("html-anything-theme");if(t!=="light"&&t!=="dark")t=matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light";document.documentElement.dataset.theme=t;}catch(e){}})();', |
There was a problem hiding this comment.
If localStorage.getItem(...) throws here (for example when site storage is blocked by privacy settings or private browsing), the catch exits the entire boot script before it reaches the matchMedia fallback or sets data-theme. That means the app always boots in light mode in exactly the environments where persisted state is unavailable, even though this PR advertises a prefers-color-scheme fallback and ThemeToggle already treats persistence as optional later on. Please narrow the try to just the storage read, default t from matchMedia, and then overwrite it only when the stored value is successfully read and valid.
Review follow-up: the boot script wrapped everything in one try, so a throwing localStorage.getItem (site storage blocked by privacy settings / private browsing) skipped the matchMedia fallback and the data-theme assignment entirely — the app always booted light exactly where persistence is unavailable. Narrow the try to the storage read so the system-preference fallback still applies. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| <script | ||
| dangerouslySetInnerHTML={{ | ||
| __html: | ||
| '(function(){var t=null;try{t=localStorage.getItem("html-anything-theme")}catch(e){}if(t!=="light"&&t!=="dark")t=matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light";document.documentElement.dataset.theme=t;})();', |
There was a problem hiding this comment.
This fixes the storage-denied fallback, but the PR still ships both review follow-up fixes without any regression coverage. That matters because the two bugs already found on this branch were both in the theme bootstrap path: one made the first click a no-op after a dark reload, and the other skipped the prefers-color-scheme fallback whenever localStorage.getItem(...) threw. Since no e2e/ui or Vitest case changed with these lines, either behavior can regress silently in the next refactor. Please add an automated test matrix for this bootstrap flow, for example an e2e case that seeds html-anything-theme=dark, reloads, and asserts the toggle is pressed and switches to light on the first click, plus a case that makes localStorage.getItem throw while matchMedia("(prefers-color-scheme: dark)") returns true and then verifies <html data-theme="dark"> after boot.
Why
The editor chrome is hard-locked to the light paper palette (
color-scheme: light, no theme mechanism). Anyone working in a dark environment — which is where a lot of agent-driven writing happens — gets a full-brightness white UI with no way out. Since the entire chrome is already driven by the--paper/--ink/--surfaceCSS variables from the open-design palette, a dark theme is a natural, low-risk variable override rather than a redesign.What
globals.css— a:root[data-theme="dark"]block overriding the palette variables with a warm dark counterpart (same paper/ink language, inverted), pluscolor-scheme: dark. The header background and body grain move into variables (--header-bg,--grain-a/b) so they track the theme; the default:rootpalette is untouched, so light mode renders exactly as before.theme-toggle.tsx(new) — a sun/moon button in the top bar next to the history toggle, styled like the neighboring round buttons.layout.tsx— a tiny pre-hydration inline script that applies the stored theme (localStorage keyhtml-anything-theme) or falls back toprefers-color-scheme, so a reload never flashes the wrong theme.i18n.ts—toolbar.toggleThemein both en / zh-CN dicts.samples-gallery.tsx— onekbdchipbg-white→var(--surface)(the only chrome element that bypassed the palette).Deliberate scope decision: rendered artifacts (preview / deck / history / samples iframes) keep their own colors — generated documents are content, not chrome, and are not re-themed.
Verification
All CI steps pass locally on this branch:
pnpm exec tsx scripts/guard.ts✓pnpm -F @html-anything/next typecheck✓ ·pnpm -F @html-anything/e2e typecheck✓pnpm -F @html-anything/next test— 172/172 ✓pnpm -F @html-anything/next build✓pnpm -F @html-anything/e2e test— 11/11 ✓Manually swept in the browser in dark mode: main editor, agent-picker / welcome modal, settings modal, formats & samples galleries, tasks sidebar; toggled both ways and reloaded to confirm persistence and no flash. Light mode is pixel-identical to
main.No new dependencies.
🤖 Generated with Claude Code