Skip to content

feat(web): switch skins automatically with the system or the sun - #49

Merged
christianhuening merged 15 commits into
mainfrom
feat/auto-skin-switching
Oct 24, 2026
Merged

christianhuening merged 15 commits into
mainfrom
feat/auto-skin-switching

Conversation

@christianhuening

Copy link
Copy Markdown
Contributor

Summary

Skins can now switch on their own. Settings → Appearance offers three choices:

  • By hand: today's behaviour.
  • Follow system: the light skin while the OS is in light mode, the dark skin while it's in dark mode.
  • Follow the sun: the light skin from sunrise to sunset, the dark skin the rest of the time.

The automatic modes use one light skin and one dark skin, picked in the existing card grid. This is frontend only and stored per browser. There's no server, API or migration change and no new dependency.

Design: docs/superpowers/specs/2026-10-24-auto-skin-switching-design.md. Plan: docs/superpowers/plans/2026-10-24-auto-skin-switching.md.

flowchart LR
  P["localStorage<br/>knot.skin · skinMode · skinLight · skinDark · sunLocation"] --> R["currentSkin()<br/>styles/skinPrefs.ts"]
  OS["prefers-color-scheme"] --> R
  TZ["browser time zone → tzdata table<br/>or typed lat/lon"] --> SUN["isDaytime()<br/>lib/sun.ts"] --> R
  R --> S["useUi.skin<br/>(the skin showing)"] --> H["html data-skin / data-theme"]
  W["skinWatcher<br/>matchMedia · 60 s tick in sun mode<br/>focus · visibility · pageshow · storage"] --> S
Loading
web/src/lib/sun.ts                  solar elevation, isDaytime, sunrise/sunset (NOAA Solar Calculator / Meeus)
web/src/lib/sunLocation.ts          time zone → coordinates, fallback for unknown zones, coordinate parsing
web/src/lib/tzCoords.generated.ts   549 zones + legacy link names from tzdata 2026e (7 KB gzipped, main bundle)
tools/gen-tz-coords.mjs             generator: make tz.gen TZDATA=<extracted tzdata>
web/src/styles/skinPrefs.ts         storage keys, validation, resolver
web/src/stores/ui.ts                mode, light/dark slots, the skin showing
web/src/stores/skinWatcher.ts       live switching, started once from main.tsx
web/src/features/workspace/         SkinPicker (mode switch, slots, "Now" marker), SunLocationPanel
e2e/flows/skin-auto.spec.ts
  • Nothing changes for existing users. A missing knot.skinMode means By hand, and knot.skin keeps its meaning, including the legacy knot.theme fallback.
  • No flash of the wrong skin. The resolved skin is stamped before first paint. That includes a laptop in sun mode opened after dark.
  • Where you are:
    • knot reads the browser's IANA time zone and looks it up in a bundled tzdata table. The table includes the legacy names Chrome reports, such as Asia/Calcutta.
    • Unknown zones (UTC, Etc/*) use the UTC offset's meridian at the equator, which gives a day of roughly 06:00–18:00.
    • Users can type a latitude and longitude instead.
    • No network request, no geolocation prompt.
  • Live switching:
    • An OS appearance change applies immediately.
    • In sun mode, knot re-checks every 60 s and on focus, visibility and pageshow, which covers a laptop waking from sleep.
    • Other tabs pick up setting changes. Side effect: a hand-picked skin now syncs across open tabs too, which it didn't before.
  • Switching modes: the first switch into an automatic mode fills the matching slot with the current skin, so a Nord user gets Nord at night. Switching back to By hand keeps whatever is showing.
  • Unchanged: Mermaid diagrams and Excalidraw boards still don't follow the skin. They didn't before either.

Evidence

  • Before: skins could only be picked by hand. pnpm test: 474 passed.
    After:
    • pnpm test: 64 files, 572 tests passed. tsc and eslint --max-warnings 0 are clean. The new tests cover:
      • solar math against published sunrise and sunset tables (±2 min): Berlin at both solstices and the equinox, Sydney's winter solstice, and Tromsø's polar day and polar night;
      • the tz table, including ICU legacy names;
      • every transition in the store's mode state machine;
      • the watcher's lifecycle, with fake timers, a matchMedia stub and storage events;
      • focus handling in the location form.
    • e2e flows/skin-auto.spec.ts: 3 passed in Chromium.
      • emulateMedia flips the OS appearance, and the skin follows without a reload.
      • Sun mode uses page.clock with timezoneId: Europe/Berlin. It expects light at 19:00Z, which only the time-zone placement gives, then dark after the 19:33Z sunset, both live and after a reload.
      • A typed Tromsø location in December gives dark, because of polar night.
    • e2e flows/skin.spec.ts: 2 passed, unchanged.
    • Full e2e suite (without the toxiproxy reconnect specs): 93 passed, 1 failed. The failure is tree-reorder.spec.ts "nest", which fails the same way on main.
    • Screenshots of every mode, the form error, phone width and a dark OS were checked by hand. They aren't attached.
  • Review: each of the 7 plan tasks had a spec and quality review. A final whole-branch review looked at correctness, spec, UI and accessibility, and code and test quality, with each finding independently verified. It found no correctness defects. All 10 Minor findings were fixed, including forced-colors support for the segmented control, focus handling in the location form, and locale-aware time formatting.

Merge Danger

Door: two-way

The new preferences live in new localStorage keys. A revert leaves them unused and knot.skin untouched, so everyone falls back to their hand-picked skin.

Blast Radius: every page load

The pre-paint skin stamp on every page, including login, setup and public share pages, now goes through the resolver. The tz table (about 7 KB gzipped) joins the main bundle. Syncing a hand-picked skin across tabs is new behaviour.

🤖 Generated with Claude Code

christianhuening and others added 15 commits October 24, 2026 13:53
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
"Follow the sun" needs a location before first paint, without asking.
tzdata's zone.tab names each zone's principal city; tools/gen-tz-coords.mjs
turns it, plus backward's legacy link names (which Chrome reports, e.g.
Asia/Calcutta), into a 549-zone table from tzdata 2026e, about 7 KB gzipped.
A zone the table can't place falls back to the equator at the longitude
its UTC offset implies.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
skinPrefs.ts reads and writes the four new localStorage keys (mode, the
light and dark slots, a typed sun location) next to the existing
knot.skin, each surviving a missing or throwing storage, and decides the
skin to show: the hand-picked one, or the light or dark slot by the
system's appearance or by whether the sun is up. A slot holding a skin
of the other scheme counts as unset. readInitialSkin moves here as
readManualSkin; ui.ts keeps the old name as an alias.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`skin` is now the skin showing, resolved from the mode, the hand-picked
skin, the light and dark slots and the sun location. Going into an
automatic mode seeds the slot of the skin showing if it has no pick yet;
going back to By hand keeps the skin showing. refreshSkin and
adoptStoredSkinPrefs never write storage and leave <html> alone when the
skin showing is unchanged, for the watcher that comes next. main.tsx
stamps the resolved skin before first paint.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Follow system switches live with the OS appearance and survives a reload.
Follow the sun, with the time zone pinned and the clock faked, shows the
light skin by day and the dark skin after sunset, live and from first
paint, and a typed location wins over the time zone.

The changelog gains the entry under Unreleased, Added.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
An unplaced zone's longitude now wraps to the exact meridian of its UTC
offset instead of clamping at 180, so UTC+14 lands at 150° W rather than
on the antimeridian, an hour off.

ICU reports America/Coral_Harbour for Atikokan, Ontario, while tzdata's
backward file links it to America/Panama. The generator now overrides
that name with Atikokan's zone.tab coordinates.

A typed coordinate also accepts the minus sign (U+2212). The size-budget
test reads the table next to itself, wherever vitest runs from.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The sunrise and sunset times use the locale's short time style, and the
unplaced-zone sentence writes its 06:00 and 18:00 on the same clock.

A failed Save marks every field that is wrong, each with its own message,
and moves focus to the first, so a repeated failure is read out again.
The edit button is described by the times and location lines, so
landing on it after Save, Cancel or "Use time zone" reads the result.

In forced-colours mode the chosen segment of the mode control takes the
system highlight, and its keyboard focus shows as an outline instead of
a box-shadow ring.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
main.tsx stamps the skin the store already resolved on load instead of
resolving it a second time, so first paint and the store can't disagree.
resolveInitialSkin goes; a fresh-module test pins the store's initial
skin for sun mode after sunset.

setSunLocation turns away a location that storage wouldn't read back
(non-finite or out of range), so the store never holds one a reload
would drop. A new test pins that sun mode without a typed location
places the sun by the browser's time zone, re-read on every call.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The daytime check now runs at 19:00 UTC, when only the time zone's table
entry for Europe/Berlin has the sun still up; the equator fallback would
already be in the night. Every reload assertion also checks data-theme.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The changelog entry says the skin is now synced across open tabs,
including one picked by hand. The plan no longer names a local scratch
directory for tzdata, and sunEvent's comment says which polar verdict
a sun that never crosses the sunrise altitude gives, and that sunrise's
is checked first.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@christianhuening
christianhuening merged commit 5e4aed8 into main Oct 24, 2026
5 checks passed
@christianhuening
christianhuening deleted the feat/auto-skin-switching branch October 24, 2026 15:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant