Skip to content

feat: runtime theme switching API#112

Merged
neonwatty merged 17 commits intomainfrom
feat/runtime-theme-api
Apr 15, 2026
Merged

feat: runtime theme switching API#112
neonwatty merged 17 commits intomainfrom
feat/runtime-theme-api

Conversation

@neonwatty
Copy link
Copy Markdown
Collaborator

Closes #104

Summary

  • Adds window.BugDrop.setTheme('light' | 'dark' | 'auto') so host apps can sync the widget theme whenever their own theme toggle changes (concrete use case: Seatify).
  • Fixes the latent bug that data-theme="auto" never followed OS theme changes after init — the widget now installs a matchMedia('(prefers-color-scheme: dark)') listener at init, gated by currentMode === 'auto', so widgets in auto mode track OS theme changes from then on.
  • Extracts all theme-dependent inline-style logic out of injectStyles into a new focused src/widget/theme.ts module (~150 lines). src/widget/ui.ts shrinks by ~80 lines.

Design

Full design spec: docs/superpowers/specs/2026-04-15-runtime-theme-api-design.md.
Full implementation plan: docs/superpowers/plans/2026-04-15-runtime-theme-api.md.

Locked-in decisions: void return, console.warn on invalid input, no events (YAGNI), always-on matchMedia listener gated by mode check, bgColor-derived inline styles re-applied on every theme change via a consolidated applyCustomStyles helper. See the spec for rationale.

Module boundary

New src/widget/theme.ts exports:

  • ThemeMode = 'light' | 'dark' | 'auto' and ResolvedTheme = 'light' | 'dark' — the split keeps "user intent" and "resolved DOM state" as distinct types so applyThemeClass can never receive 'auto'.
  • ThemeConfigSlice — minimal structural subset of WidgetConfig (6 optional fields) used by applyCustomStyles, declared locally to avoid an import cycle.
  • resolveTheme, isValidTheme, getSystemTheme, applyThemeClass, applyCustomStyles, attachSystemThemeListener — all 6 exports fully tested.

Hardening (from pre-PR review gate)

The pre-merge review gate (5 pr-review-toolkit agents in parallel — code-reviewer, pr-test-analyzer, code-simplifier, silent-failure-hunter, type-design-analyzer) surfaced several defense-in-depth items, all addressed in the final commit:

  • attachSystemThemeListener now console.warns when window.matchMedia is unavailable, so integrators in sandboxed iframes / restrictive CSP environments can debug why auto mode stops tracking.
  • The matchMedia change handler wraps its callback in try/catch so a throw from the application layer doesn't break the listener for subsequent events.
  • applyCustomStyles adds a Number.isFinite guard on the parsed border width — protects against 'NaNpx' leaking into the shadow offset when a user passes data-border-width="invalid".
  • setTheme's invalid-input warn uses String(mode) instead of JSON.stringify(mode) — avoids throwing on circular objects.
  • Module-level _currentMode and _detachSystemListener were removed. currentMode is now closure-captured inside exposeBugDropAPI, giving per-widget-instance isolation and removing the far-away assignment.

Deferred (not addressed in this PR)

  • Narrow shadow?: string to a literal union (type-design-analyzer suggestion) — real win but touches WidgetConfig in multiple files. Follow-up PR.
  • data-theme script-attribute validation (code-reviewer observation) — the init path accepts any string in data-theme, which is pre-existing behavior from before this PR. Follow-up PR.
  • destroy() API — out of scope.
  • Event emission (bugdrop:themechange) — YAGNI per spec; no concrete consumer.

Test plan

Unit (vitest, test/theme.test.ts, 43 tests, jsdom environment):

  • resolveTheme — light/dark passthrough, auto via injected probe, auto via default-parameter fallback
  • isValidTheme — 3 accepts + 8 rejects
  • getSystemTheme — returns light/dark based on mocked matchMedia, falls back to light when unavailable
  • applyThemeClass — adds/removes bd-dark, idempotent in both directions
  • applyCustomStyles — 14 cases covering the no-op branch + accent/bg/text/border/shadow blocks, including theme-dependent fallbacks (bgBase, shadowColor) and re-run overwrite semantics
  • attachSystemThemeListener — no-op cleanup when matchMedia missing, dark/light callback dispatch, cleanup stops subsequent calls

E2E (Playwright, e2e/theme.spec.ts, 8 tests):

  • setTheme('dark') flips the root class
  • setTheme('light') removes bd-dark
  • Invalid input warns and no-ops
  • Auto mode follows OS theme changes via page.emulateMedia (the issue Support runtime theme switching via JavaScript API #104 proof-of-life)
  • setTheme('auto') resolves to the current emulated OS theme
  • bgColor + setTheme re-derives --bd-bg-secondary via color-mix (imperative path)
  • setTheme('light') resists subsequent OS flip to dark (gate-behavior proof — added from review gate)
  • Auto mode + bgColor re-derives on OS flip (full callback integration — added from review gate)

Full local verification just before PR open:

  • npm run lint — 0 errors, 20 warnings (pre-existing)
  • npm run typecheck — clean
  • npm test — 109 passing
  • npm run build:widget — widget.js at 67.2 kb
  • npx playwright test e2e/theme.spec.ts — 8 passing in 2.0s
  • npx playwright test e2e/widget.spec.ts — 91 passing (no regression)

Release validation

This PR is a feat: (minor version bump). Beyond shipping the feature, it serves as the first real-world validation of the new deploy.yml with-release path from #111 — after merge:

  1. .github/workflows/deploy.yml fires on push: main
  2. release job publishes a new minor tag (e.g. v1.15.0)
  3. deploy job rebuilds with the new VERSION and ships to Cloudflare Workers

We'll capture wall-clock for the post-merge phase as the first datapoint for the "with-release" baseline (the fc8749e merge from #111 gave us the "no-release" baseline of 57s; this one will be measurably higher because it actually publishes).

Commit log (15 commits)

  1. 8e9ac4e feat(theme): scaffold theme module and test file (#104)
  2. bd340ce feat(theme): add isValidTheme predicate (#104)
  3. 6e74ae9 feat(theme): move getSystemTheme into theme module (#104)
  4. 6de157b feat(theme): add resolveTheme helper (#104)
  5. d58558a feat(theme): add applyThemeClass helper (#104)
  6. 1dacdd8 feat(theme): add applyCustomStyles helper (#104)
  7. 9e4c28c feat(theme): add attachSystemThemeListener wiring (#104)
  8. db21b24 refactor(widget): delegate theme resolution and custom styles to theme module (#104)
  9. 110c260 feat(widget): add window.BugDrop.setTheme runtime API (#104)
  10. b04ef27 feat(widget): auto-follow OS theme changes via matchMedia listener (#104)
  11. 97b456b test(e2e): add setTheme happy-path and invalid-input cases (#104)
  12. 11c209c test(e2e): verify auto mode follows OS theme changes (#104)
  13. bec7106 test(e2e): verify bgColor re-derives on runtime theme change (#104)
  14. 446f601 fix(theme): harden listener + closure-scope mode state (#104)

Design spec for the setTheme() API plus the matchMedia fix for auto mode.
Captures the decisions (void return, console.warn on bad input, no events,
bgColor re-derivation), the architecture (new theme.ts module), the three
runtime paths (init / setTheme / OS change), edge cases, and the unit +
E2E test plan.
17-task TDD-ordered plan covering the new theme.ts module, the injectStyles
refactor, the setTheme API wiring, the matchMedia listener, vitest unit
coverage, the 6-case Playwright E2E, the pre-PR review gate, and PR open.
Each task is bite-sized with explicit verification commands. Drives the
implementation session.
Scaffold e2e/theme.spec.ts with three cases covering the runtime theme API:
setTheme("dark") adds bd-dark, setTheme("light") removes it, and an invalid
input logs a warning without changing the class list.

Add a tiny inline harness to public/test/index.html that reads ?theme= and
?bg= query params and overrides the corresponding data-* attributes on the
BugDrop script tag. The widget script is marked `defer` so the harness —
which runs synchronously during parsing, after the widget tag is already
in the DOM — can set the attributes before the widget reads them.
Addresses feedback from pre-PR review gate:

- attachSystemThemeListener now warns when window.matchMedia is unavailable,
  so integrators in sandboxed iframes or restrictive CSP environments can
  debug why data-theme="auto" stops tracking OS changes.
- The matchMedia change handler wraps its callback in try/catch so a throw
  (e.g. from a detached DOM root) doesn't propagate into the browser's event
  loop and stop subsequent events from being processed.
- applyCustomStyles adds a Number.isFinite guard on the parsed borderWidth
  so invalid input no longer produces 'NaNpx' in the shadow offset.
- setTheme's invalid-input warn uses String(mode) instead of JSON.stringify
  so circular objects don't turn a bad-input warning into a runtime error.
- Module-level _currentMode and _detachSystemListener are gone. currentMode
  is now closure-captured inside exposeBugDropAPI, giving per-widget-instance
  isolation and removing the far-away _currentMode = config.theme assignment.

Adds two E2E tests covering gaps the test analyzer flagged:

- explicit setTheme('light') resists a subsequent OS flip to dark (proves
  the currentMode !== 'auto' gate in the matchMedia callback works).
- auto mode + bgColor re-derives --bd-bg-secondary on OS theme flip (proves
  the full matchMedia callback path, both applyThemeClass and applyCustomStyles,
  runs end-to-end for users combining data-theme="auto" with data-bg="...").
Knip in CI flagged both as unused exported types because nothing outside
src/widget/theme.ts imports them — they exist purely for the module's
internal type safety (function signatures and interface shape). Dropping
the `export` keyword keeps the types in place for internal type checking
and satisfies knip.

ThemeMode stays exported because src/widget/index.ts imports it to type
the setTheme method signature.
@neonwatty neonwatty added this pull request to the merge queue Apr 15, 2026
Merged via the queue into main with commit 8b24e6b Apr 15, 2026
6 checks passed
@neonwatty neonwatty deleted the feat/runtime-theme-api branch April 15, 2026 19:57
@github-actions
Copy link
Copy Markdown

🎉 This PR is included in version 1.28.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support runtime theme switching via JavaScript API

1 participant