Add a theme toggle, and fix the voice picker showing no voices
Some checks failed
CI / build-and-deploy (push) Has been cancelled

Theme toggle sits next to the settings icon. The choice starts as 'system' and
follows the OS until you press it, after which it's explicit and persisted.

An inline script in <head> stamps the resolved theme on <html> before the
first paint - resolving it from JS after load means a visible flash of dark on
the way to light. Because that attribute is always present, the stylesheet
drops its prefers-color-scheme query entirely rather than having a media query
and an explicit override fighting over the same tokens.

The voice picker was effectively empty: Chrome reports zero voices
synchronously and only fills the list when voiceschanged fires, and while the
narrator did reload them, nothing told preact to re-render - so the dropdown
kept whatever existed at construction, which was nothing. It now notifies, and
the list arrives (181 voices here).

That many voices needs shape, so they're sorted with your own language first
and offline voices ahead of network ones, then grouped into optgroups by
language. Network voices are marked as such since they're useless offline,
which for an app built to work in a field matters.

Speed and pitch are adjustable too, both fed through to every utterance.

The settings button gains a class of its own: the header now has two icon
buttons, so identifying it by .icon-button alone hits the theme toggle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QK7PKpRVoy69Fpa32R8abb
This commit is contained in:
Jon
2026-08-02 20:17:32 +01:00
parent c3cf5ddfbf
commit 55958ae206
5 changed files with 232 additions and 30 deletions

View File

@@ -36,6 +36,19 @@ if (isDebug) {
/** @param {number} ms */
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
const systemPrefersLight = () => matchMedia('(prefers-color-scheme: light)');
/**
* Turn the stored preference into the theme actually in force.
* @param {'system' | 'light' | 'dark'} choice
*/
const resolveTheme = choice =>
choice === 'light' || choice === 'dark'
? choice
: systemPrefersLight().matches
? 'light'
: 'dark';
/** @extends Component<{}, AppState> */
class App extends Component {
/** @type {Camera | undefined} */
@@ -71,12 +84,20 @@ class App extends Component {
componentDidMount() {
this.saver.mode = this.state.prefs.saveMode;
this.syncNarrator(this.state.prefs);
// The voice list arrives after construction; re-render when it does.
this.narrator.onVoicesChanged = () => this.forceUpdate();
// Held for as long as the app is open, not just while a sequence runs -
// you're usually mid-setup when the display would otherwise sleep.
if (this.state.prefs.keepAwake) this.wakeLock.acquire();
document.addEventListener('fullscreenchange', this.handleFullscreenChange);
this.applyTheme(this.state.prefs.theme);
// While the choice is 'system', follow the OS if it changes underneath us.
systemPrefersLight().addEventListener('change', () => {
if (this.state.prefs.theme === 'system') this.applyTheme('system');
});
addEventListener('error', ({ message }) =>
this.log(`Uncaught error: ${message}`, 'error')
);
@@ -117,13 +138,34 @@ class App extends Component {
}));
}
/** @param {'system' | 'light' | 'dark'} choice */
applyTheme(choice) {
let theme = resolveTheme(choice);
document.documentElement.dataset.theme = theme;
// Keeps the PWA title bar and mobile browser chrome in step.
document
.querySelector('meta[name="theme-color"]')
?.setAttribute('content', theme === 'light' ? '#ffffff' : '#14161a');
this.setState({ theme });
}
toggleTheme = () => {
let next = /** @type {'light' | 'dark'} */ (
resolveTheme(this.state.prefs.theme) === 'dark' ? 'light' : 'dark'
);
this.applyTheme(next);
this.setPref({ theme: next });
};
/** @param {import('./settings.js').Prefs} prefs */
syncNarrator(prefs) {
Object.assign(this.narrator, {
enabled: prefs.voice,
countFrom: prefs.voiceCountFrom,
voiceURI: prefs.voiceURI,
announceFrames: prefs.voiceAnnounceFrames
announceFrames: prefs.voiceAnnounceFrames,
rate: prefs.voiceRate,
pitch: prefs.voicePitch
});
}
@@ -412,14 +454,31 @@ class App extends Component {
)
),
h(
'button',
{
type: 'button',
class: 'icon-button',
onclick: this.toggleSettings,
title: 'Settings'
},
'⚙'
'div',
{ class: 'header-actions' },
h(
'button',
{
type: 'button',
class: 'icon-button',
onclick: this.toggleTheme,
title:
this.state.theme === 'dark'
? 'Switch to light mode'
: 'Switch to dark mode'
},
this.state.theme === 'dark' ? '☀' : '☾'
),
h(
'button',
{
type: 'button',
class: 'icon-button settings-button',
onclick: this.toggleSettings,
title: 'Settings'
},
'⚙'
)
)
);
}