Skip to content

[Review]: Dark mode public API and SSR consistency #224

Description

@ndlabdev

Component under review

ThemeModeButton, plus the dark mode public API surface (src/lib/index.ts, theme.css, setup docs).

Review scope

  • TypeScript & type safety
  • Bug detection (SSR/hydration)
  • Accessibility (icon-only names)
  • Exports & public API
  • Parity with common library conventions

Findings

# Severity Category Summary Decision
1 Critical api mode-watcher is a required peer dependency while every other runtime library sits in dependencies. Users must install it themselves, and package managers that do not auto install peers fail outright
2 Critical api ModeWatcher and toggleMode are not exported from sv5ui, but two docs pages instruct importing them from 'sv5ui'
3 Medium bug ThemeModeButton renders a different icon on the server than on the client in dark mode. Svelte deliberately does not repair {@html} mismatches, so the wrong glyph persists after reload
4 Low api resetConfig exists in config.ts and is documented, but is not exported from index.ts
5 Low docs theme.css claims support for prefers-color-scheme, but the custom variant only matches the class
6 Low a11y No themeColors passed, so no <meta name="theme-color">. Mobile browser chrome does not follow the mode
7 Low docs The getting started snippet uses <slot />, which is Svelte 4 syntax

Evidence

Every finding below was reproduced by running code, not by reading it.

Finding 1

I packed the library and installed it into a clean project with peer auto install disabled, then tried to resolve the module from the exact path that imports it:

dist/components/ThemeModeButton/ThemeModeButton.svelte:3
    import { toggleMode, mode } from "mode-watcher";

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

bits-ui is the control: it is equally required at runtime and it resolves, because it lives in dependencies. mode-watcher is the only runtime library classified as a required peer.

Finding 2

A type check probe importing the documented names, with a control import that must pass:

Error: Module '"$lib/index.js"' has no exported member 'ModeWatcher'. (ts)
Error: Module '"$lib/index.js"' has no exported member 'toggleMode'. (ts)
Error: Module '"$lib/index.js"' has no exported member 'resetConfig'. (ts)

svelte-check found 3 errors and 0 warnings in 1 file

The control import of Button from the same module passed, which confirms the checker was actually validating.

Finding 3

Server render and client render of the same component, compared directly:

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

The cause is that the mode resolver returns undefined during SSR, so the light branch is always taken on the server. Both the {@html} icon body and the aria-label diverge.

Proposed direction

  1. Move mode-watcher from peerDependencies to dependencies, matching how every other runtime library is treated, so users install nothing extra.
  2. Add a ThemeMode component that wraps the mode watcher internally and supplies themeColors from the surface tokens, then re-export toggleMode, setMode, resetMode and mode. This keeps the underlying library an implementation detail, so replacing it later is not a breaking change. Self closing, as a drop in for the current setup line, since a provider shape would give nothing today: the watcher keeps its state in module scope, not in the component tree.
  3. Fix the icon divergence with CSS rather than a JavaScript branch, and make the accessible name static so it no longer depends on the mode.
  4. Correct the docs and the theme comment.

Out of scope, recorded for later

Migrating tokens to light-dark() so system mode needs no JavaScript. I measured it and the trade is not worth taking yet:

  • Unsupported browsers do not fall back to light, they lose all color. The two declaration fallback trick does not work for custom properties, so staying safe means keeping the duplicate token block and losing the size benefit.
  • The media branch needs an explicit light class to detect an override, but the current default adds no such class, so a manual light choice on a dark OS would wrongly apply dark utilities.

Worth revisiting only alongside dropping the external mode library, when that class is under our control.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area: dxDeveloper experience / ergonomicspriority: P1High — important, schedule soonstatus: readyTriaged, ready to work

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions