Files
web-dslr/examples/preact/index.html
Jon 55958ae206
Some checks failed
CI / build-and-deploy (push) Has been cancelled
Add a theme toggle, and fix the voice picker showing no voices
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
2026-08-02 20:17:32 +01:00

913 lines
21 KiB
HTML

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Intervalometer — Canon over WebUSB</title>
<link rel="manifest" href="./manifest.webmanifest" />
<meta name="theme-color" content="#14161a" />
<meta name="mobile-web-app-capable" content="yes" />
<link rel="apple-touch-icon" href="./icon-192.png" />
<link
rel="stylesheet"
href="https://unpkg.com/purecss@2.0.6/build/pure-min.css"
crossorigin="anonymous"
/>
<script>
// Resolve the theme before the first paint, or the page flashes dark on
// its way to light. Kept inline and dependency-free for that reason.
(() => {
let choice = 'system';
try {
choice =
JSON.parse(localStorage.getItem('web-dslr.prefs') || '{}').theme ||
'system';
} catch {}
document.documentElement.dataset.theme =
choice === 'light' || choice === 'dark'
? choice
: matchMedia('(prefers-color-scheme: light)').matches
? 'light'
: 'dark';
})();
</script>
<style>
/* Dark is the base. The script above always stamps an explicit
data-theme, so there's no prefers-color-scheme query here to fight
with an override the user has chosen. */
:root,
:root[data-theme='dark'] {
color-scheme: dark;
--bg: #14161a;
--panel: #1c1f26;
--panel-2: #242832;
--line: #333846;
--text: #e8eaee;
--muted: #9aa3b2;
--accent: #4f9cf9;
--accent-text: #06121f;
--danger: #e05252;
--warn: #e8b64c;
--ok: #4fbf7b;
--radius: 10px;
}
:root[data-theme='light'] {
color-scheme: light;
--bg: #f2f4f7;
--panel: #ffffff;
--panel-2: #f7f8fa;
--line: #d9dee6;
--text: #1a1d23;
--muted: #5c6675;
--accent: #1f6fd0;
--accent-text: #ffffff;
}
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
}
body {
margin: 0;
display: flex;
flex-direction: column;
background: var(--bg);
color: var(--text);
font: 15px/1.45 system-ui, -apple-system, 'Segoe UI', sans-serif;
overflow: hidden;
}
h1,
h2,
h3 {
margin: 0;
font-weight: 600;
}
a {
color: var(--accent);
}
/* ---------- header ---------- */
#app-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 16px;
background: var(--panel);
border-bottom: 1px solid var(--line);
}
#app-header .brand {
display: flex;
align-items: center;
gap: 12px;
}
#app-header .header-actions {
display: flex;
align-items: center;
gap: 2px;
}
#app-header .logo {
font-size: 24px;
}
#app-header h1 {
font-size: 17px;
}
#app-header small {
color: var(--muted);
font-size: 12px;
}
/* ---------- layout ---------- */
main {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) 400px;
}
#viewer {
position: relative;
min-height: 0;
background: #000;
display: flex;
}
#viewer .center-parent {
position: relative;
flex: 1;
display: flex;
min-height: 0;
}
#viewer canvas {
margin: auto;
max-width: 100%;
max-height: 100%;
}
.preview-overlay {
position: absolute;
inset: auto 0 0 0;
padding: 8px 12px;
background: rgba(0, 0, 0, 0.65);
color: #fff;
text-align: center;
font-size: 13px;
}
/* Fullscreen preview */
.viewer-button {
position: absolute;
top: 10px;
right: 10px;
z-index: 2;
padding: 6px 10px;
font-size: 16px;
line-height: 1.2;
color: #fff;
background: rgba(0, 0, 0, 0.45);
border-color: rgba(255, 255, 255, 0.25);
opacity: 0.55;
transition: opacity 0.15s;
}
#viewer:hover .viewer-button,
.viewer-button:focus-visible {
opacity: 1;
}
#viewer:fullscreen {
background: #000;
}
.viewer-status {
position: absolute;
top: 10px;
left: 10px;
z-index: 2;
display: flex;
flex-direction: column;
gap: 2px;
padding: 10px 14px;
border-radius: var(--radius);
background: rgba(0, 0, 0, 0.55);
color: #fff;
}
.viewer-status-headline {
font-size: 24px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.viewer-status-detail {
font-size: 13px;
opacity: 0.8;
}
#home {
min-height: 0;
overflow-y: auto;
padding: 14px;
border-left: 1px solid var(--line);
background: var(--bg);
display: flex;
flex-direction: column;
gap: 12px;
}
/* Nothing in this column may be squashed to fit - it scrolls instead.
(`overflow: hidden` on .readout would otherwise let flex shrink it to
zero height, since that zeroes the automatic minimum size.) */
#home > * {
flex: none;
}
.card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 14px;
}
/* ---------- sequence status ---------- */
.sequence-status {
background: var(--panel);
border: 1px solid var(--line);
border-left: 4px solid var(--line);
border-radius: var(--radius);
padding: 14px;
}
.sequence-status.phase-waiting,
.sequence-status.phase-delay,
.sequence-status.phase-capturing {
border-left-color: var(--accent);
}
.sequence-status.phase-done {
border-left-color: var(--ok);
}
.sequence-status.phase-failed {
border-left-color: var(--danger);
}
.sequence-status.phase-stopped {
border-left-color: var(--warn);
}
.sequence-status .headline {
font-size: 22px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.sequence-status .detail {
color: var(--muted);
font-size: 13px;
margin-top: 2px;
}
.warn-text {
color: var(--warn) !important;
}
.progress {
margin-top: 10px;
height: 6px;
border-radius: 3px;
background: var(--panel-2);
overflow: hidden;
}
.progress .bar {
height: 100%;
background: var(--accent);
transition: width 0.2s linear;
}
/* ---------- fields ---------- */
.field-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
gap: 10px;
}
/* Shutter / aperture / ISO belong on one line, so allow narrower columns. */
.field-grid.tight {
grid-template-columns: repeat(auto-fit, minmax(88px, 1fr));
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
}
.field > span {
font-size: 12px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.input-with-unit {
display: flex;
align-items: center;
gap: 6px;
}
.unit {
color: var(--muted);
font-size: 12px;
}
.inline-check {
display: flex;
align-items: center;
gap: 2px;
color: var(--muted);
font-size: 14px;
white-space: nowrap;
}
/* The config tree renders text widgets as a bare <input> with no type
attribute, so match on what it isn't - a typed selector list silently
misses those and leaves them with the browser's default light chrome. */
input:not([type='checkbox']):not([type='radio']):not([type='button']),
select {
min-width: 0;
width: 100%;
padding: 6px 8px;
background: var(--panel-2);
color: var(--text);
border: 1px solid var(--line);
border-radius: 6px;
font: inherit;
font-variant-numeric: tabular-nums;
}
/* Dropdown lists are painted by the OS, not the page. */
option {
background: var(--panel-2);
color: var(--text);
}
input:disabled,
select:disabled {
opacity: 0.55;
}
/* Values the camera reports but won't let you change. */
input[readonly],
select[readonly] {
background: transparent;
border-color: transparent;
color: var(--muted);
}
.card-title {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted);
margin-bottom: 10px;
display: flex;
align-items: baseline;
gap: 8px;
}
.card-title-note {
text-transform: none;
letter-spacing: 0;
font-size: 12px;
opacity: 0.8;
}
.mini-spinner {
display: inline-block;
width: 9px;
height: 9px;
margin-left: 6px;
border: 1.5px solid var(--line);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
vertical-align: middle;
}
/* Focus stepper */
.focus-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.focus-steps {
display: flex;
align-items: center;
gap: 4px;
flex: 1;
}
.focus-end {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted);
}
.focus-step {
flex: 1;
padding: 7px 4px;
font-size: 11px;
line-height: 1;
letter-spacing: -1px;
}
.plan {
margin: 12px 0 0;
font-size: 13px;
color: var(--muted);
}
/* ---------- buttons ---------- */
button {
font: inherit;
border-radius: 8px;
border: 1px solid var(--line);
background: var(--panel-2);
color: var(--text);
padding: 8px 14px;
cursor: pointer;
}
button:hover:not(:disabled) {
border-color: var(--accent);
}
button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
button.primary {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-text);
font-weight: 600;
}
button.danger {
background: var(--danger);
border-color: var(--danger);
color: #fff;
font-weight: 600;
}
button.big {
padding: 12px 18px;
font-size: 16px;
}
button.icon-button {
background: none;
border: none;
font-size: 20px;
padding: 4px 8px;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
/* Labels shouldn't fold onto a second line to make room for a sibling -
the row wraps instead. */
.actions button {
white-space: nowrap;
}
.actions .big {
flex: 1;
}
.status-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.status-text {
min-width: 0;
}
.voice-toggle {
flex: none;
padding: 6px 10px;
font-size: 18px;
line-height: 1.2;
}
.voice-toggle.on {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 14%, transparent);
}
/* ---------- readout & log ---------- */
.readout {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(96px, 1fr));
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
overflow: hidden;
}
/* Cell borders rather than a gap over a coloured backdrop, so a partly
filled last row doesn't leave a stray block of grid line. */
.readout-item {
border-right: 1px solid var(--line);
border-bottom: 1px solid var(--line);
padding: 8px 10px;
display: flex;
flex-direction: column;
min-width: 0;
}
.readout-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted);
}
.readout-value {
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.log {
font-size: 12px;
border-top: 1px solid var(--line);
padding-top: 8px;
display: flex;
flex-direction: column;
gap: 4px;
}
.log-entry {
display: flex;
gap: 8px;
color: var(--muted);
}
.log-entry.warn {
color: var(--warn);
}
.log-entry.error {
color: var(--danger);
}
.log-time {
font-variant-numeric: tabular-nums;
opacity: 0.7;
flex: none;
}
.notice {
margin: 10px 0 0;
padding: 8px 10px;
border-radius: 8px;
background: var(--panel-2);
color: var(--muted);
font-size: 12.5px;
}
.notice.warn {
color: var(--warn);
background: rgba(232, 182, 76, 0.1);
}
.tag {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
background: var(--panel-2);
color: var(--muted);
border-radius: 4px;
padding: 2px 6px;
vertical-align: middle;
}
/* ---------- settings drawer ---------- */
.scrim {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity 0.2s, visibility 0.2s;
z-index: 5;
}
.scrim.open {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
#settings {
position: fixed;
top: 0;
right: 0;
height: 100%;
width: min(440px, 100%);
background: var(--panel);
border-left: 1px solid var(--line);
transform: translateX(100%);
/* `visibility` keeps the closed drawer out of the tab order and the
accessibility tree; it flips only once the slide-out finishes. */
visibility: hidden;
transition: transform 0.2s ease, visibility 0.2s;
display: flex;
flex-direction: column;
z-index: 6;
}
#settings.open {
transform: none;
visibility: visible;
}
#settings header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
border-bottom: 1px solid var(--line);
}
#settings h2 {
font-size: 16px;
}
#settings h3 {
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted);
margin-bottom: 8px;
}
/* Wraps rather than scrolls: a scrolled-off tab is an undiscoverable
one, and two short rows cost less than a hidden overflow. */
.tab-bar {
display: flex;
flex-wrap: wrap;
gap: 2px 4px;
padding: 4px 8px 0;
border-bottom: 1px solid var(--line);
flex: none;
}
.tab {
flex: none;
border: none;
border-bottom: 2px solid transparent;
border-radius: 0;
background: none;
color: var(--muted);
font-size: 13px;
padding: 9px 10px;
white-space: nowrap;
}
.tab:hover:not(.active) {
color: var(--text);
border-color: var(--line);
}
.tab.active {
color: var(--text);
border-bottom-color: var(--accent);
}
.drawer-body {
flex: 1;
overflow-y: auto;
padding: 14px;
}
.drawer-body section {
margin-bottom: 22px;
}
.setting-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 8px 0;
border-bottom: 1px solid var(--line);
}
.setting-label {
display: flex;
flex-direction: column;
gap: 2px;
}
.setting-label small {
color: var(--muted);
font-size: 11.5px;
}
.setting-control {
flex: none;
display: flex;
align-items: center;
gap: 6px;
min-width: 140px;
justify-content: flex-end;
}
/* Needs the #settings prefix to outrank the generic input width above. */
#settings .setting-control input[type='number'] {
width: 80px;
}
/* camera config tree (rendered with purecss classes) */
#settings fieldset {
border: 1px solid var(--line);
border-radius: 8px;
margin: 0 0 10px;
padding: 6px 10px 10px;
}
#settings legend {
color: var(--muted);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
#settings .pure-control-group {
display: flex;
align-items: center;
gap: 8px;
margin: 4px 0;
}
#settings .pure-control-group label {
flex: 1;
font-size: 13px;
margin: 0;
}
/* Specific enough to beat purecss's own .pure-form control styling,
which is built for a light page. */
#settings .pure-control-group input:not([type='checkbox']),
#settings .pure-control-group select {
flex: none;
width: 48%;
/* purecss pins selects to 2.25em, which clips descenders once our own
padding is added. */
height: auto;
background: var(--panel-2);
color: var(--text);
border: 1px solid var(--line);
box-shadow: none;
}
#settings .pure-control-group input[readonly],
#settings .pure-control-group select[readonly] {
background: transparent;
border-color: transparent;
color: var(--muted);
}
#settings .pure-control-group input[type='checkbox'] {
flex: none;
width: auto;
}
/* ---------- misc ---------- */
.center {
margin: auto;
text-align: center;
padding: 20px;
}
.muted {
color: var(--muted);
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.mini-spinner {
animation-duration: 2.4s;
}
}
.fine-print {
max-width: 46ch;
margin: 16px auto 0;
font-size: 12.5px;
color: var(--muted);
}
@media (max-width: 900px) {
main {
grid-template-columns: 1fr;
grid-template-rows: minmax(180px, 38vh) minmax(0, 1fr);
overflow: hidden;
}
#home {
border-left: none;
border-top: 1px solid var(--line);
}
}
</style>
<script type="importmap">
{
"imports": {
"web-gphoto2": "https://unpkg.com/web-gphoto2@0.4.1/build/camera.js",
"preact": "https://unpkg.com/preact@10.6.4/dist/preact.module.js",
"preact/debug": "https://unpkg.com/preact@10.6.4/debug/dist/debug.module.js",
"preact/devtools": "https://unpkg.com/preact@10.6.4/devtools/dist/devtools.module.js",
"stats.js": "https://unpkg.com/stats.js@0.17.0/src/Stats.js"
}
}
</script>
<script type="module">
try {
if (!('usb' in navigator)) {
throw new Error('WebUSB is unsupported');
}
await import('./index.js');
} catch (e) {
console.error(e);
await import('./index-fallback.js');
}
</script>
<link
rel="icon"
href='data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="10 0 110 100"><text y=".9em" font-size="90">⏱</text></svg>'
/>
<script>
// Registered outside the module graph so a service worker failure can
// never stop the app itself from loading.
if ('serviceWorker' in navigator) {
addEventListener('load', () => {
navigator.serviceWorker
.register('./sw.js')
.catch(err => console.warn('Service worker registration failed:', err));
});
}
</script>
</head>
<body>
<div class="center">⌛ Loading…</div>
</body>
</html>