feat(app): give the theme three states and a memory, and end the Settings tab - #566
Conversation
…ings tab The Settings tab held two controls and then 728px of nothing on a 915px sheet, 80% of it. One of the two was the app's only native checkbox, 13x13 and painted in the browser's blue. Theme becomes a segmented control: System, Dark, Light. The checkbox could only say dark-or-light, and the state most people are actually in is "follow the device", which is now the default a fresh install starts in. It also stored nothing, so a chosen light theme lasted until the next reload and index.html's hardcoded dark won again; the choice is now persisted under core-hunter-theme. The version row moves from Status to the foot of Settings, pushed down with margin-top: auto. That is what the Settingsmodal artboard drew, and it gives the tab a bottom edge instead of a cut-off. Status keeps what it is for: what is connected, and who you are. Measured at 412x915: the tab now ends 16px above the sheet's bottom, against 728px before. resolveTheme and nextThemePref live in a new theme.js so the rule is one function. index.html has to restate it inline, because app.js is a module and runs too late to stop a stored light theme flashing dark first; theme.test.js runs that inline script and compares its answer to resolveTheme's for every stored value and device setting, rather than trusting the two to stay in step. accent-color on :root covers the checkboxes that stay checkboxes, because they are booleans: the two filter rows and Remember me. They were the last browser-blue on any of the three surfaces. Verified in the browser at 412x915: all three states apply and persist across a reload, System resolves light on a light device and dark on a dark one, the segmented buttons are 44px tall, and exactly one settings panel is displayed per tab (the display: flex rule uses :not([hidden]) so it cannot beat the UA's hidden rule). Not verified here: the live re-resolve when the device flips while the preference is System. The preview pane's colour-scheme emulation changes matchMedia's `matches` without firing a `change` event, measured with a page-attached listener that saw zero events across a flip, so the listener could not be exercised. Both halves either side of it are verified. Tests: app 861 passed + build clean, web 504 passed, eslint clean. The new theme assertions were mutation-checked three ways: an unknown device preference falling back to light, an unrecognised stored value sticking at dark, and the cycle no longer wrapping. All three went red. Closes efiten#563 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts: # app/changelog.json # web/changelog.json
efiten
left a comment
There was a problem hiding this comment.
Rebased onto master for you (changelog conflicts only; both copies merged and identical, 46 entries) and pushed.
Three states with system as the default is the right shape, and the test I did not expect is the best thing in here: pinning that the pre-paint inline script in index.html agrees with resolveTheme on every stored value and device setting. Two implementations of one rule, one of them inlined in markup so it can run before first paint, is exactly where a silent drift lives, and the symptom would be a flash of the wrong theme that nobody can reproduce on demand.
Both branches of the resolution are pinned, checked by breaking them:
- accepting any truthy stored value instead of only
dark/light→ 5 tests red. That is the migration case: an older build's value, a hand edit, a stale key. - making
systemresolve to dark regardless of the device → 3 tests red.
app/ 895 tests and a clean build with master merged in.
One small gap, not a blocker. THEME_PREFS's comment says "the order is also the cycle order", but reordering it to ['dark', 'light', 'system'] passes everything: nextThemePref's test compares seen.sort() against THEME_PREFS.sort(), so it pins that the cycle visits every state and returns to the start, deliberately not the sequence. Either the comment is stronger than the intent, or one assertion is missing:
expect(seen).toEqual(['system', 'dark', 'light'])The tap order is user-visible, so I would pin it, but you own that call and it is one line either way.
Ending the Settings tab is the right call for the reason you measured: 728px of empty below the content, 80% of the sheet, for two controls.
🤖 I have created a release *beep* *boop* --- <details><summary>app: 1.24.0</summary> ## [1.24.0](app-v1.23.0...app-v1.24.0) (2026-08-30) ### Features * **app:** give the theme three states and a memory, and end the Settings tab ([#566](#566)) ([e67c319](e67c319)) * **app:** keep the SNR the repeater heard us at ([#489](#489)) ([0cd0a7c](0cd0a7c)) * **app:** make every recorded reception audible, one instrument per type (+ sound tweaks) ([#470](#470)) ([6244c0f](6244c0f)) * **web:** gate the point layer, and say what an account opens ([#513](#513)) ([71856f1](71856f1)) ### Bug Fixes * **app:** take the gate's coach marks out of #splash's stacking context ([#565](#565)) ([6fba59e](6fba59e)) </details> <details><summary>web: 1.20.0</summary> ## [1.20.0](web-v1.19.0...web-v1.20.0) (2026-08-30) ### Features * **app:** give the theme three states and a memory, and end the Settings tab ([#566](#566)) ([e67c319](e67c319)) * **app:** keep the SNR the repeater heard us at ([#489](#489)) ([0cd0a7c](0cd0a7c)) * **app:** make every recorded reception audible, one instrument per type (+ sound tweaks) ([#470](#470)) ([6244c0f](6244c0f)) * **web:** gate the point layer, and say what an account opens ([#513](#513)) ([71856f1](71856f1)) ### Bug Fixes * **app:** take the gate's coach marks out of #splash's stacking context ([#565](#565)) ([6fba59e](6fba59e)) </details> --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Closes #563
What was happening
Measured on
ae9ba97at 412x915, settings sheet open on the Settings tab:The tab held two controls. One of them,
Light theme, was the app's only native checkbox:13x13 with
accent-color: auto, so it painted in the browser's blue.Two things surfaced while building that the issue did not name:
The light theme did not survive a reload. Nothing was ever stored. The checkbox set
data-themefor the session andindex.html's hardcodeddata-theme="dark"won again on thenext load.
The blue checkbox was not the only one.
fs-direct-only,fs-unnamedandss-acc-rememberare the same native control with the same browser blue.
The change
Theme becomes a segmented control: System, Dark, Light. A checkbox is two states, and the
state most people are in is "follow the device", which a boolean cannot express.
Systemiswhat a fresh install starts in, so a stored preference stays a deliberate choice rather than
something an install picked for you. The choice persists under
core-hunter-theme.The version row moves from Status to the foot of Settings, pushed down with
margin-top: auto. This is whatdesign-canvas/Settingsmodal.dc.htmldrew, and it gives the taba bottom edge rather than a cut-off. Status keeps what it is for: what is connected, and who you
are. A version at the bottom of a settings screen is also where one usually lives.
Measured after: the tab ends 16px above the sheet's bottom, against 728px before.
accent-color: var(--ch-accent)on:rootfor the checkboxes that stay checkboxes, becausethey are booleans and a segmented control would be three states for a yes/no.
One rule, in two places, pinned together
resolveThemelives in a newtheme.js.index.htmlhas to restate it in an inline script,because
app.jsis a module and runs after the document is parsed, so resolving there aloneflashes dark under a stored light theme on every load.
A restated rule drifts, so rather than matching its source text,
theme.test.jsruns theinline script with a stubbed
localStorageandmatchMediaand compares its answer toresolveTheme's across every stored value and device setting, plus the storage-throws path.Mutation-checked: flipping the inline script's unknown-device branch fails with
stored=null prefersDark=undefined: expected 'light' to be 'dark'.Verified in the browser, at 412x915
Systemresolves light on a light device and dark on a dark one.display: flexrule is scoped:not([hidden]), so it cannot beat the UA's[hidden]rule, which is the trap that bit thefeat(web): give the map a settings sheet, so the bar can stop being the junk drawer #432 round.
Not verified here
The live re-resolve when the device flips while the preference is
System. The preview pane'scolour-scheme emulation changes
matchMedia(...).matcheswithout firing achangeevent:measured with a listener attached in the page, which saw zero events across a flip while
matcheswent fromtruetofalse. The listener is the standard one-liner and both halveseither side of it are verified, but the event itself could not be exercised.
Tests
The new theme assertions were mutation-checked three ways, all red: an unknown device preference
falling back to light instead of dark, an unrecognised stored value sticking at dark instead of
following the device, and the preference cycle no longer wrapping.
Changelog
One entry,
apponly, in both copies.🤖 Generated with Claude Code