Skip to content

feat(app): give the theme three states and a memory, and end the Settings tab - #566

Merged
efiten merged 2 commits into
efiten:masterfrom
khagele:feat/563-settings-tab
Aug 30, 2026
Merged

feat(app): give the theme three states and a memory, and end the Settings tab#566
efiten merged 2 commits into
efiten:masterfrom
khagele:feat/563-settings-tab

Conversation

@khagele

@khagele khagele commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Closes #563

What was happening

Measured on ae9ba97 at 412x915, settings sheet open on the Settings tab:

before
sheet height 915px
content ends at y 187
empty below 728px, 80% of the sheet

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-theme for the session and index.html's hardcoded data-theme="dark" won again on the
next load.

The blue checkbox was not the only one. fs-direct-only, fs-unnamed and ss-acc-remember
are 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. System is
what 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 what design-canvas/Settingsmodal.dc.html drew, and it gives the tab
a 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 :root for the checkboxes that stay checkboxes, because
they are booleans and a segmented control would be three states for a yes/no.

One rule, in two places, pinned together

resolveTheme lives in a new theme.js. index.html has to restate it in an inline script,
because app.js is a module and runs after the document is parsed, so resolving there alone
flashes dark under a stored light theme on every load.

A restated rule drifts, so rather than matching its source text, theme.test.js runs the
inline script with a stubbed localStorage and matchMedia and compares its answer to
resolveTheme'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

  • All three states apply, and all three survive a reload.
  • System resolves light on a light device and dark on a dark one.
  • The segmented buttons are 44px tall, against the 13px checkbox they replace (F7).
  • Exactly one settings panel is displayed per tab. The new display: flex rule is scoped
    :not([hidden]), so it cannot beat the UA's [hidden] rule, which is the trap that bit the
    feat(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's
colour-scheme emulation changes matchMedia(...).matches without firing a change event:
measured with a listener attached in the page, which saw zero events across a flip while
matches went from true to false. The listener is the standard one-liner and both halves
either side of it are verified, but the event itself could not be exercised.

Tests

  • app: 861 passed, build clean
  • web: 504 passed (the changelog parity guard)
  • eslint: clean

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, app only, in both copies.

🤖 Generated with Claude Code

khagele and others added 2 commits August 29, 2026 17:33
…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 efiten left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 system resolve 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.

@efiten
efiten merged commit e67c319 into efiten:master Aug 30, 2026
6 checks passed
@github-actions github-actions Bot mentioned this pull request Aug 30, 2026
efiten pushed a commit that referenced this pull request Aug 30, 2026
🤖 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>
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.

app: the Settings tab is two controls and then 80% empty

2 participants