Some checks failed
CI / build-and-deploy (push) Has been cancelled
Home screen gains shutter, aperture and ISO as dropdowns plus a focus stepper (three nudge sizes each way, and AF) where the body drives focus over PTP. Those three drop out of the read-only strip rather than being shown twice. Everything is built from what the camera actually reports, so a body without one of these controls doesn't get it rather than showing something that silently fails. The 450D marks shutter and aperture readonly unless the mode dial is somewhere they apply, so the dropdowns disable themselves and say why. Focus uses the EOS manualfocusdrive steps and autofocusdrive, and warns when live view is off, which Canon bodies generally require for focus commands. The settings drawer is now tabbed: app settings stay on the first tab, and each config section the camera reports gets its own. Empty sections are dropped, and a selected section that disappears falls back to the first tab. Tabs wrap rather than scroll - a scrolled-off tab is an undiscoverable one. Reverts the automatic camera search added in the previous commit, restoring the Select camera button. Auto-connect could park with no way to reach the device chooser: WebUSB permissions are per-origin, so a grant on localhost doesn't carry to the deployed copy, and a camera that's asleep or held by another app never arrives. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QK7PKpRVoy69Fpa32R8abb
886 lines
20 KiB
HTML
886 lines
20 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"
|
|
/>
|
|
<style>
|
|
:root {
|
|
color-scheme: dark light;
|
|
--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;
|
|
}
|
|
|
|
@media (prefers-color-scheme: light) {
|
|
:root {
|
|
--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 .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>
|