Skip to content

fix(theme-mode): export dark mode API, fix SSR icon mismatch - #225

Merged
ndlabdev merged 1 commit into
devfrom
fix/224-dark-mode-public-api
Sep 25, 2026
Merged

ndlabdev merged 1 commit into
devfrom
fix/224-dark-mode-public-api

Conversation

@ndlabdev

Copy link
Copy Markdown
Owner

Summary

Dark mode had two defects that I reproduced by running code before touching anything.

mode-watcher was a required peer dependency while every other runtime library sits in dependencies. I packed the library and installed it into a clean project with peer auto install disabled, then resolved the module from the exact file that imports it:

resolve 'mode-watcher' -> MODULE_NOT_FOUND
resolve 'bits-ui'      -> resolves fine     (control case)

bits-ui is the control: equally required at runtime, and it resolves because it is a regular dependency.

ThemeModeButton picked its icon from the resolved mode, which is undefined during SSR, so the server always rendered the light branch:

server : mode.current = undefined -> moon, "Switch to dark mode"
client : mode.current = 'dark'    -> sun,  "Switch to light mode"

Svelte deliberately does not repair mismatched {@html} blocks, so the wrong glyph stuck in the DOM after a reload in dark mode until the next toggle.

Refs #224

Type of change

  • 🐛 Bug fix
  • ✨ New feature / component
  • 📖 Documentation
  • ♻️ Refactor / chore
  • ⚠️ Breaking change

Changes

  • mode-watcher moves from peerDependencies to dependencies at the same ^1.0.0 range, so nothing extra has to be installed and package managers that do not auto install peers keep working.
  • New ThemeMode component wrapping the watcher, plus toggleMode, setMode, resetMode, mode, userPrefersMode, systemPrefersMode and resetConfig re-exported from the package root. This keeps the underlying library an implementation detail, so it can be replaced later without a breaking change.
  • ThemeModeButton renders both mode icons and lets CSS pick the visible one, so server and client markup are identical. The icon size now resolves through one chain shared by the button and its icons.
  • Corrected the dark mode comment in theme.css, which claimed prefers-color-scheme support that the custom variant does not provide.

Breaking changes

  • ThemeModeButton accessible name is now a fixed Toggle theme instead of Switch to light mode / Switch to dark mode. Anything selecting the button by accessible name needs updating.
  • Its DOM carries two <svg> elements instead of one. Measured impact is small: the hidden icon is display: none so layout is unchanged and ordinary svg styling still applies. Selectors that count children, such as svg:only-child, no longer match.
  • It no longer inherits its size from a surrounding FieldGroup, because the button and its icons now share one resolved size.

Checklist

  • Linked the related issue
  • pnpm check passes (0 errors, 0 warnings)
  • pnpm lint passes
  • pnpm test passes
  • Added or updated tests for the change
  • Updated CHANGELOG.md under [Unreleased]
  • Followed component conventions (no comments outside *.types.ts, Material 3 design tokens)

Notes

Upgrade safety was verified rather than assumed. I downloaded the published 2.7.0 from npm and diffed the exported surface: 0 exports removed, 8 added. I then built a consumer using the old API in full, including import { ModeWatcher } from 'mode-watcher', every ThemeModeButton prop, ui with the old slot names and the children({ isDark }) snippet. It type checks with 0 errors and runs correctly in both system color schemes, with exactly one icon visible at a time.

Tests: 3853 passing across 107 files. The regression tests assert computed styles rather than class strings, and one of them asserts that the rendered markup is byte identical in both modes, which is the property that removes the mismatch at its root.

Not in this PR, still open on the issue: the theme-color meta tag, and a nonce that the upstream library interpolates into a script tag without escaping. Both are recorded for the follow up work of owning the mode engine.

mode-watcher was a required peer dependency while every other runtime
library sits in dependencies. Users had to install it themselves, and
package managers that do not auto install peers failed to resolve it at
all. It moves to dependencies at the same range.

toggleMode and resetConfig were referenced by the setup docs but never
exported from the package root, so copying those snippets broke the
build. A ThemeMode component now wraps the watcher and the mode
functions are re-exported, which keeps the underlying library an
implementation detail and leaves room to replace it later.

ThemeModeButton chose its icon from mode.current, which is undefined
during SSR, so the server always rendered the light branch. Svelte does
not repair mismatched html blocks, so the wrong glyph stuck after a
reload in dark mode. Both icons now render and CSS picks the visible
one, and the accessible name no longer depends on the resolved mode.

The docs pages are left untouched, so the findings about setup snippets
stay open.

Refs #224
@ndlabdev ndlabdev added bug Something isn't working area: dx Developer experience / ergonomics priority: P1 High — important, schedule soon labels Sep 24, 2026
@ndlabdev ndlabdev self-assigned this Sep 24, 2026
@ndlabdev
ndlabdev merged commit c85ac8c into dev Sep 25, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: dx Developer experience / ergonomics bug Something isn't working priority: P1 High — important, schedule soon

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant