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
913 lines
21 KiB
HTML
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>
|