Settings gains an Updates block: it asks the church Gitea for the newest release, compares the tag against the running build, and if there is a newer one shows what changed and offers to download the installer. Deliberately not electron-updater. That wants a static feed published beside the artifacts, and this build is ad-hoc signed with no Team ID, so a silent in-place swap would meet Gatekeeper on a machine nobody is sitting at. And nothing here happens on its own: a background check that announces itself three minutes into a sermon is a hazard, so the operator presses the button, and presses a second one to download. The file lands in Downloads and is revealed; installing is a drag across when the room is empty. Tags are v<version>-<build>, compared numerically field by field so 3.9.0 does not read as newer than 3.23.2 and build 113 beats build 112. Two things only real testing found. Gitea answers with whatever address it was reached on, so the release API returned asset URLs on 10.3.8.90 while the forge is configured by hostname — the same-host guard refused a perfectly good installer. The URL is now rebuilt on the configured origin, which keeps the property that matters (this only ever fetches from the server the operator configured) and survives a forge that knows itself by more than one name. And a private repository answers 404 rather than 401, so 'no releases published yet' told the operator the opposite of the truth; without a token that case now names the remedy instead. The token is this machine's credential: it stays out of PORTABLE_APP, so it does not ride along in an exported settings file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
713 lines
33 KiB
CSS
713 lines
33 KiB
CSS
/*
|
|
* engine.css — the backstage app.
|
|
*
|
|
* Nobody wants to look at this during a service. It exists to answer three
|
|
* questions fast: is it hearing audio, is it connected to the church server,
|
|
* and are the words right.
|
|
*/
|
|
|
|
/*
|
|
* Make the `hidden` attribute actually hide things.
|
|
*
|
|
* The browser's own [hidden] rule is `display: none`, but it lives in the user-
|
|
* agent stylesheet, so any author rule that sets `display` — every .warn,
|
|
* .toast and flex row below — silently outranks it. The element stays on
|
|
* screen with hidden="true" and the JavaScript looks like it isn't running.
|
|
* That is how the operator console ended up permanently insisting the Mac
|
|
* engine was disconnected while it was sitting there working.
|
|
*/
|
|
[hidden] { display: none !important; }
|
|
|
|
:root {
|
|
--bg: #0b0d12; --surface: #151922; --raised: #1d2331; --line: #2a3244;
|
|
--text: #eef1f6; --muted: #8b93a7; --accent: #6ea8fe;
|
|
--go: #2fbf71; --stop: #e5484d; --warn: #f5a524;
|
|
}
|
|
|
|
* { box-sizing: border-box; }
|
|
|
|
body {
|
|
margin: 0; background: var(--bg); color: var(--text);
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
|
font-size: 14px;
|
|
}
|
|
|
|
h1 { font-size: 1.5rem; margin: 0 0 .375rem; font-weight: 700; }
|
|
h2 { font-size: .9375rem; margin: 0 0 .25rem; font-weight: 650; }
|
|
p { margin: 0 0 .75rem; line-height: 1.5; }
|
|
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .75rem;
|
|
background: var(--raised); padding: .0625rem .3125rem; border-radius: 4px; }
|
|
hr { border: 0; border-top: 1px solid var(--line); margin: 1.25rem 0; }
|
|
|
|
.muted { color: var(--muted); font-size: .8125rem; }
|
|
.hint { color: var(--muted); font-size: .75rem; line-height: 1.5; margin: .375rem 0 0; }
|
|
|
|
/* ---- header ---- */
|
|
.head {
|
|
position: sticky; top: 0; z-index: 5;
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
padding: .75rem 1.25rem; background: rgba(11,13,18,.92);
|
|
backdrop-filter: blur(10px); border-bottom: 1px solid var(--line);
|
|
-webkit-app-region: drag;
|
|
}
|
|
.brand { display: flex; align-items: center; gap: .5rem; font-size: .875rem; padding-left: 4.5rem; }
|
|
.brand b { font-weight: 700; }
|
|
.mark { width: .625rem; height: .625rem; border-radius: 3px; background: var(--accent); }
|
|
.head-right { display: flex; align-items: center; gap: .4375rem; -webkit-app-region: no-drag; }
|
|
.pip { width: .5rem; height: .5rem; border-radius: 50%; background: var(--muted); }
|
|
.pip.ok { background: var(--go); }
|
|
.pip.bad { background: var(--stop); }
|
|
.pip-label { font-size: .75rem; color: var(--muted); }
|
|
|
|
.build {
|
|
font-size: .6875rem; color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
border: 1px solid var(--line); border-radius: 999px; padding: .0625rem .4375rem; margin-left: .125rem;
|
|
}
|
|
|
|
/* ---- page ----
|
|
Hero across the top, settings in a column of the same width they have always
|
|
had, and the congregation card parked in a rail on the right where it stays
|
|
put while the settings scroll under it. Narrow windows fold back to one
|
|
column and the rail returns to the top of the flow, where it is still the
|
|
first thing you need. */
|
|
.page {
|
|
max-width: 72rem; margin: 0 auto; padding: 1.25rem 1.25rem 3rem;
|
|
display: grid; gap: 0 1rem; justify-content: center;
|
|
grid-template-columns: minmax(0, 44rem) minmax(17rem, 21rem);
|
|
align-items: start;
|
|
}
|
|
.page > .hero { grid-column: 1 / -1; }
|
|
.col-main { grid-column: 1; min-width: 0; }
|
|
.col-rail { grid-column: 2; position: sticky; top: 4rem; min-width: 0; }
|
|
/*
|
|
* One column. The order is deliberate and it is NOT source order: the hero
|
|
* stays first — it is the button you came to press — then the congregation
|
|
* card, then the settings. `order: -1` on the rail alone put the QR ABOVE the
|
|
* hero, which is the one arrangement that is wrong.
|
|
*/
|
|
@media (max-width: 68rem) {
|
|
.page { grid-template-columns: minmax(0, 44rem); }
|
|
.col-main, .col-rail { grid-column: 1; }
|
|
.page > .hero { order: 1; }
|
|
.col-rail { position: static; order: 2; }
|
|
.col-main { order: 3; }
|
|
}
|
|
|
|
.card {
|
|
background: var(--surface); border: 1px solid var(--line);
|
|
border-radius: 14px; padding: 1.125rem; margin-bottom: .875rem;
|
|
}
|
|
|
|
/* ---- collapsible groups ---- */
|
|
.group {
|
|
background: var(--surface); border: 1px solid var(--line);
|
|
border-radius: 14px; margin-bottom: .875rem; overflow: hidden;
|
|
}
|
|
.group-head {
|
|
display: flex; align-items: center; gap: .625rem; width: 100%;
|
|
appearance: none; background: transparent; border: 0; color: var(--text);
|
|
padding: 1rem 1.125rem; cursor: pointer; text-align: left; font: inherit;
|
|
transition: background .16s ease;
|
|
}
|
|
.group-head:hover { background: var(--raised); }
|
|
.group-head h2 { margin: 0; }
|
|
/* Clipped by CSS, not by slicing the string — slicing cut mid-word and left
|
|
things like "Dante Virtual Soundcard (Vir" with the bracket hanging open. */
|
|
.group-sub {
|
|
margin-left: auto; font-size: .75rem; color: var(--muted);
|
|
max-width: 55%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
}
|
|
.chev {
|
|
width: .5rem; height: .5rem; flex: none;
|
|
border-right: 2px solid var(--muted); border-bottom: 2px solid var(--muted);
|
|
transform: rotate(-45deg); transition: transform .32s cubic-bezier(.2,.7,.3,1);
|
|
}
|
|
.group[data-open="true"] .chev { transform: rotate(45deg); }
|
|
|
|
/*
|
|
* 0fr -> 1fr, not a pixel height: the content of these sections changes size
|
|
* after they open (a 105-row language list, a live meter, a QR that loads),
|
|
* and any measured max-height would be wrong the moment it did.
|
|
*/
|
|
.group-body {
|
|
display: grid; grid-template-rows: 0fr;
|
|
transition: grid-template-rows .34s cubic-bezier(.2,.7,.3,1), opacity .22s ease;
|
|
opacity: 0;
|
|
}
|
|
.group[data-open="true"] .group-body { grid-template-rows: 1fr; opacity: 1; }
|
|
.group-body > .group-inner { overflow: hidden; min-height: 0; padding: 0 1.125rem; }
|
|
.group[data-open="true"] > .group-body > .group-inner { padding-bottom: 1.125rem; }
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.group-body { transition: none; }
|
|
.chev { transition: none; }
|
|
}
|
|
|
|
.subhead2 { margin: 1rem 0 .25rem; font-size: .75rem; color: var(--muted); font-weight: 650; }
|
|
|
|
/* ---- right rail ---- */
|
|
.rail-card .qr { display: block; margin: 0 auto .625rem; }
|
|
/* The rail is a third of the width the settings column has, and an operator
|
|
URL does not fit in it. Let it wrap rather than clip — a truncated address
|
|
is worse than a two-line one. */
|
|
.rail-card .codeblock { flex-wrap: wrap; }
|
|
.rail-card .codeblock code {
|
|
flex: 1 1 100%; min-width: 0; overflow-wrap: anywhere; white-space: normal;
|
|
}
|
|
.rail-card .codeblock .btn { margin-left: auto; }
|
|
.railbtns { display: grid; gap: .375rem; margin-top: .625rem; }
|
|
.railwide { width: 100%; margin-top: .375rem; }
|
|
|
|
/* ---- output routing matrix ----
|
|
One row per language, one button per physical channel. The grid is the whole
|
|
point: you should be able to see at a glance that nothing is doubled up and
|
|
nothing is unrouted. */
|
|
/* Sized for 64 channels as readily as for 2: the rows scroll rather than the
|
|
page, and the buttons shrink a step once there are more than sixteen so a
|
|
64-wide row still fits without becoming a horizontal scroll hunt. */
|
|
.outmap { display: grid; gap: .375rem; margin-top: .5rem; max-height: 22rem; overflow-y: auto; }
|
|
.outrow { display: flex; align-items: flex-start; gap: .5rem; }
|
|
.outrow > b {
|
|
flex: none; width: 9.5rem; padding-top: .25rem;
|
|
font-size: .8125rem; font-weight: 600;
|
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
}
|
|
.outchans { display: flex; flex-wrap: wrap; gap: .1875rem; flex: 1; min-width: 0; }
|
|
.outch {
|
|
appearance: none; cursor: pointer; font: inherit; font-size: .75rem;
|
|
min-width: 1.875rem; padding: .1875rem .375rem;
|
|
background: var(--raised); border: 1px solid var(--line); color: var(--muted);
|
|
border-radius: 6px; font-variant-numeric: tabular-nums;
|
|
transition: border-color .14s ease, background .14s ease, color .14s ease;
|
|
}
|
|
.outmap.many .outch { min-width: 1.5rem; padding: .125rem .1875rem; font-size: .6875rem; }
|
|
|
|
/* Adjacent selected channels fuse into one pill, so a stereo pair reads as a
|
|
pair rather than as two numbers that happen to be lit. This is the "link"
|
|
made visible — the shape says they belong together. */
|
|
.outch.on:has(+ .outch.on) { border-top-right-radius: 0; border-bottom-right-radius: 0; }
|
|
.outch.on + .outch.on {
|
|
border-top-left-radius: 0; border-bottom-left-radius: 0;
|
|
margin-left: -.1875rem; border-left-color: rgba(110,168,254,.35);
|
|
}
|
|
.outmap.many .outch.on + .outch.on { margin-left: -.1875rem; }
|
|
|
|
.outtag {
|
|
flex: none; width: 8rem; padding-top: .25rem; text-align: right;
|
|
font-size: .6875rem; color: var(--muted); font-variant-numeric: tabular-nums;
|
|
}
|
|
.outrow.none .outtag { opacity: .6; }
|
|
|
|
.outtrim { flex: none; width: 5.5rem; margin: 0; accent-color: var(--accent); }
|
|
.outtrim:disabled { opacity: .3; }
|
|
.outdb {
|
|
flex: none; width: 3.25rem; padding-top: .25rem; text-align: right;
|
|
font-size: .6875rem; color: var(--muted); font-variant-numeric: tabular-nums;
|
|
}
|
|
.outch:hover { border-color: var(--muted); }
|
|
.outch.on { border-color: var(--accent); background: rgba(110,168,254,.16); color: var(--text); }
|
|
.outrow.none > b { color: var(--muted); font-weight: 500; }
|
|
|
|
/* ---- channel picker ----
|
|
A grid of numbered buttons, each with its own live level. Finding the
|
|
speaker on a 64-channel Dante device is a matter of watching for the bar
|
|
that moves, so the meter is the point and the number is the label. */
|
|
.chanbox { margin: .75rem 0; }
|
|
.chanhead { display: flex; align-items: center; gap: .5rem; margin-bottom: .375rem; }
|
|
.chanhead .btn { margin-left: auto; }
|
|
.chanlist {
|
|
display: grid; gap: .3125rem;
|
|
grid-template-columns: repeat(auto-fill, minmax(4.5rem, 1fr));
|
|
max-height: 13rem; overflow-y: auto; padding: .125rem;
|
|
}
|
|
.chanrow {
|
|
display: flex; align-items: center; gap: .375rem;
|
|
appearance: none; cursor: pointer; font: inherit;
|
|
background: var(--raised); border: 1px solid var(--line); color: var(--muted);
|
|
border-radius: 7px; padding: .3125rem .4375rem;
|
|
transition: border-color .16s ease, background .16s ease, color .16s ease;
|
|
}
|
|
.chanrow:hover { border-color: var(--muted); }
|
|
.chanrow b { font-size: .75rem; min-width: 1.25rem; text-align: right; font-variant-numeric: tabular-nums; }
|
|
.chanrow.on { border-color: var(--accent); color: var(--text); background: rgba(110,168,254,.12); }
|
|
.chanbar { flex: 1; height: .375rem; border-radius: 999px; background: var(--bg); overflow: hidden; }
|
|
.chanbar-fill {
|
|
display: block; height: 100%; width: 0; border-radius: 999px;
|
|
background: var(--muted); transition: width .08s linear;
|
|
}
|
|
.chanbar-fill.hot { background: var(--go); }
|
|
.chanrow.on .chanbar-fill { background: var(--accent); }
|
|
.chanrow.on .chanbar-fill.hot { background: var(--go); }
|
|
|
|
/* ---- hero ---- */
|
|
.hero {
|
|
background: var(--surface); border: 1px solid var(--line);
|
|
border-radius: 14px; padding: 1.75rem; margin-bottom: .875rem; text-align: center;
|
|
}
|
|
.hero[data-state="live"] { border-color: var(--go); background: linear-gradient(180deg, rgba(47,191,113,.10), var(--surface)); }
|
|
.hero[data-state="ready"] { border-color: #2c4a7a; }
|
|
.hero[data-state="loading"] { border-color: var(--accent); }
|
|
.hero[data-state="error"] { border-color: var(--stop); background: linear-gradient(180deg, rgba(229,72,77,.10), var(--surface)); }
|
|
|
|
.hero-icon { width: 2.75rem; height: 2.75rem; margin: 0 auto .75rem; border-radius: 50%; background: var(--raised); position: relative; }
|
|
.hero[data-state="live"] .hero-icon { background: var(--go); animation: pulse 2s ease-in-out infinite; }
|
|
.hero[data-state="loading"] .hero-icon { background: var(--accent); animation: pulse 1.4s ease-in-out infinite; }
|
|
.hero[data-state="error"] .hero-icon { background: var(--stop); }
|
|
/* A voice falling back to built-in is amber, on the hero, in a sentence — the
|
|
one place whoever is standing at the Mac is actually looking. */
|
|
.voice-warn { margin: .5rem 0 0; padding: .5rem .75rem; border-radius: 8px; font-size: .8125rem;
|
|
color: #fbbf24; background: rgba(251,191,36,.10); border: 1px solid rgba(251,191,36,.35); }
|
|
@keyframes pulse { 0%,100% { transform: scale(1); opacity: 1; } 50% { transform: scale(.86); opacity: .55; } }
|
|
|
|
.hero p { color: var(--muted); font-size: .875rem; max-width: 30rem; margin-left: auto; margin-right: auto; }
|
|
|
|
.bar { height: .4375rem; background: var(--raised); border-radius: 999px; overflow: hidden; margin: .875rem auto .5rem; max-width: 24rem; }
|
|
.bar-fill { height: 100%; width: 0; background: var(--accent); border-radius: 999px; transition: width .25s ease; }
|
|
.prog-note { font-size: .75rem; color: var(--muted); font-family: ui-monospace, Menlo, monospace; }
|
|
|
|
/* ---- controls ---- */
|
|
label { display: block; font-size: .75rem; color: var(--muted); margin: .875rem 0 .375rem;
|
|
text-transform: uppercase; letter-spacing: .07em; font-weight: 600; }
|
|
label:first-of-type { margin-top: 0; }
|
|
|
|
input, select {
|
|
width: 100%; padding: .625rem .75rem;
|
|
background: var(--raised); color: var(--text);
|
|
border: 1px solid var(--line); border-radius: 9px;
|
|
font: inherit; font-size: .875rem; outline: none;
|
|
}
|
|
input:focus, select:focus { border-color: var(--accent); }
|
|
input[type=range] { padding: 0; background: transparent; border: 0; accent-color: var(--accent); }
|
|
|
|
.row { display: flex; gap: .5rem; align-items: center; margin-top: .5rem; flex-wrap: wrap; }
|
|
.row select, .row input { flex: 1; min-width: 10rem; }
|
|
.row .btn { flex: none; }
|
|
|
|
.btn {
|
|
appearance: none; border: 1px solid var(--line); background: var(--raised); color: var(--text);
|
|
padding: .5625rem 1rem; border-radius: 9px; font: inherit; font-size: .875rem;
|
|
font-weight: 550; cursor: pointer;
|
|
}
|
|
.btn:hover { background: #262d3d; }
|
|
.btn-primary { background: var(--accent); border-color: var(--accent); color: #08101f; font-weight: 650; }
|
|
.btn:disabled { opacity: .5; cursor: not-allowed; }
|
|
|
|
/* ---- level meter ---- */
|
|
.meter-row { display: flex; align-items: center; gap: .75rem; margin-top: .875rem; }
|
|
.meter {
|
|
position: relative; flex: 1; height: .875rem;
|
|
background: var(--raised); border-radius: 5px; overflow: hidden;
|
|
}
|
|
.meter-fill { height: 100%; width: 0; background: var(--muted); border-radius: 5px; transition: width .06s linear, background .15s ease; }
|
|
.meter-fill.speech { background: var(--go); }
|
|
.meter-fill.hot { background: var(--warn); }
|
|
.meter-fill.clip { background: var(--stop); }
|
|
.meter-thresh { position: absolute; top: 0; bottom: 0; width: 2px; background: var(--text); opacity: .65; left: 0; transition: left .2s ease; }
|
|
.meter-label { font-family: ui-monospace, Menlo, monospace; font-size: .75rem; color: var(--muted); width: 5.5rem; text-align: right; }
|
|
/* The gate control sits under the meter it moves, and lines its readout up
|
|
with the meter's own so the two numbers can be compared at a glance. */
|
|
.threshrow { display: flex; align-items: center; gap: .75rem; margin-top: .5rem; }
|
|
.threshrow .oprow { flex: 1 1 auto; }
|
|
.threshrow .outtrim { flex: none; }
|
|
|
|
/* ---- monitor ---- */
|
|
.monitor {
|
|
background: var(--raised); border-radius: 10px; padding: .875rem;
|
|
height: 9rem; overflow-y: auto; font-size: .875rem; line-height: 1.55;
|
|
display: flex; flex-direction: column; gap: .5rem;
|
|
}
|
|
.mon-empty { color: var(--muted); font-size: .8125rem; margin: auto; }
|
|
.mon-line { color: var(--text); }
|
|
.mon-line.partial { color: var(--muted); font-style: italic; }
|
|
.mon-line small { display: block; color: var(--muted); font-size: .6875rem; margin-top: .1875rem; }
|
|
/* English, then what each language was actually told. The operator reads this
|
|
to verify the service, so the two must be visually distinct but adjacent. */
|
|
.mon-line + .mon-line { margin-top: .625rem; }
|
|
.mon-en { color: var(--text); }
|
|
.mon-tr { margin: .1875rem 0 0 .875rem; font-size: .8125rem; color: var(--muted);
|
|
display: flex; gap: .5rem; align-items: baseline; }
|
|
.mon-tr b { flex: none; font-size: .625rem; font-weight: 700; letter-spacing: .03em;
|
|
text-transform: uppercase; color: var(--accent); opacity: .85; }
|
|
.mon-tr.dropped .mon-tr-text { color: var(--warn); font-style: italic; }
|
|
.mon-wait { margin: .1875rem 0 0 .875rem; font-size: .75rem; color: var(--muted); opacity: .5; font-style: italic; }
|
|
|
|
/* ---- details ---- */
|
|
.details summary { cursor: pointer; list-style: none; display: flex; align-items: center; gap: .5rem; }
|
|
.details summary::-webkit-details-marker { display: none; }
|
|
.details summary::before { content: "▸"; color: var(--muted); font-size: .75rem; }
|
|
.details[open] summary::before { content: "▾"; }
|
|
.details summary h2 { margin: 0; }
|
|
|
|
/* ---- log ---- */
|
|
.log {
|
|
background: var(--raised); border-radius: 10px; padding: .75rem;
|
|
height: 8rem; overflow-y: auto;
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .6875rem; line-height: 1.6;
|
|
color: var(--muted); white-space: pre-wrap; word-break: break-word;
|
|
}
|
|
.log .err { color: var(--stop); }
|
|
.log .ok { color: var(--go); }
|
|
/* The guard trimming an invented clause is not an error and not a success —
|
|
it is the model being caught. Amber so a run of them is visible at a glance,
|
|
because a spike is the only signal that the guard has started misfiring. */
|
|
.log .warn { color: #d8a23a; }
|
|
|
|
/* ---- the address the congregation uses ---- */
|
|
.sharerow { display: flex; gap: 1.25rem; flex-wrap: wrap; align-items: flex-start; margin-top: .875rem; }
|
|
.qr { width: 9.5rem; height: 9.5rem; background: #fff; border-radius: 10px; padding: .4375rem; flex: none; }
|
|
.shareside { flex: 1; min-width: 15rem; }
|
|
|
|
.addrlist { display: flex; flex-direction: column; gap: .375rem; margin-bottom: .875rem; }
|
|
.addr {
|
|
display: flex; align-items: baseline; gap: .625rem;
|
|
background: var(--raised); border: 1px solid var(--line);
|
|
border-radius: 9px; padding: .5625rem .75rem;
|
|
}
|
|
.addr b {
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
font-size: .9375rem; font-weight: 500; color: var(--text); word-break: break-all;
|
|
}
|
|
.addr span { font-size: .6875rem; color: var(--muted); text-transform: uppercase; letter-spacing: .06em; flex: none; }
|
|
.addrlist:empty::before { content: "No network connection"; color: var(--muted); font-size: .8125rem; }
|
|
.addr { cursor: pointer; width: 100%; text-align: left; font: inherit; transition: border-color .15s ease; }
|
|
.addr:hover { border-color: var(--accent); }
|
|
.addr.is-chosen { border-color: var(--accent); }
|
|
.addr.is-chosen span { color: var(--accent); }
|
|
|
|
/* ---- who is listening right now ---- */
|
|
.listenbox {
|
|
display: flex; align-items: baseline; flex-wrap: wrap; gap: .5rem .75rem;
|
|
background: var(--raised); border: 1px solid var(--line);
|
|
border-radius: 10px; padding: .75rem .9375rem; margin-bottom: .875rem;
|
|
}
|
|
.listenbox[hidden] { display: none; }
|
|
.listenbox > b { font-size: 1.5rem; font-weight: 700; font-variant-numeric: tabular-nums; line-height: 1; }
|
|
.listenbox > span { font-size: .8125rem; color: var(--muted); }
|
|
.listenbreak { display: flex; flex-wrap: wrap; gap: .375rem; width: 100%; }
|
|
.listenbreak span {
|
|
font-size: .75rem; color: var(--text);
|
|
background: var(--line); border-radius: 999px; padding: .1875rem .5625rem;
|
|
}
|
|
.listenbreak b { font-variant-numeric: tabular-nums; }
|
|
|
|
/* Which devices are connected — so a forgotten tab can be found. */
|
|
.listenwho { display: flex; flex-direction: column; gap: .25rem; width: 100%; margin-top: .5rem; }
|
|
.listenwho:empty { display: none; }
|
|
.wholine {
|
|
display: flex; align-items: baseline; gap: .5rem; flex-wrap: wrap;
|
|
font-size: .75rem; padding: .3125rem 0;
|
|
border-top: 1px solid var(--line);
|
|
}
|
|
.wholine b { font-weight: 600; color: var(--text); }
|
|
.wholine span { color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .6875rem; }
|
|
|
|
/* ---- Setup / Operator, in one window ---- */
|
|
|
|
/* Panes cross-fade rather than jumping, so switching mode reads as the same
|
|
window changing rather than a new screen appearing. */
|
|
/* Both panes are now one continuous settings page. The entrance animation is
|
|
kept on the first only — running it on both produced a visible two-stage
|
|
fade with a seam between them. */
|
|
.mode-pane { animation: none; }
|
|
#setupPane { animation: paneIn .26s cubic-bezier(.2,.7,.3,1) both; }
|
|
.mode-pane[hidden] { display: none; }
|
|
@keyframes paneIn {
|
|
from { opacity: 0; transform: translateY(.5rem); }
|
|
to { opacity: 1; transform: none; }
|
|
}
|
|
@media (prefers-reduced-motion: reduce) { .mode-pane { animation: none; } }
|
|
|
|
/* The hero becomes the control in operator mode. */
|
|
.hero-go {
|
|
appearance: none; border: 0; cursor: pointer;
|
|
width: 100%; margin-top: 1.125rem; padding: 1.125rem;
|
|
border-radius: 12px; font: inherit; font-size: 1.375rem; font-weight: 750;
|
|
letter-spacing: .02em;
|
|
background: var(--go); color: #052e18;
|
|
transition: background .18s ease, transform .08s ease, opacity .18s ease;
|
|
animation: paneIn .26s cubic-bezier(.2,.7,.3,1) both;
|
|
}
|
|
.hero-go[hidden] { display: none; }
|
|
.hero-go:active { transform: scale(.99); }
|
|
.hero-go[data-live="true"] { background: var(--stop); color: #fff; }
|
|
.hero-go:disabled { background: var(--raised); color: var(--muted); cursor: not-allowed; }
|
|
|
|
.stats { display: grid; grid-template-columns: repeat(3,1fr); gap: .5rem; margin-top: 1rem;
|
|
animation: paneIn .3s cubic-bezier(.2,.7,.3,1) both; }
|
|
.stats[hidden] { display: none; }
|
|
.stat { background: var(--raised); border-radius: 10px; padding: .625rem; }
|
|
.stat b { display: block; font-size: 1.25rem; font-weight: 700; font-variant-numeric: tabular-nums; }
|
|
.stat span { font-size: .6875rem; color: var(--muted); }
|
|
|
|
/* The cough button. Amber, not red — red is "stopped", and this is the opposite
|
|
of stopped: everything is still running, it just is not going out. */
|
|
.hold-btn {
|
|
display: block; width: 100%; max-width: 22rem; margin: .5rem auto 0;
|
|
padding: .5rem; font-weight: 600; letter-spacing: .02em;
|
|
}
|
|
.hold-btn[data-held="true"] {
|
|
border-color: var(--warn); background: rgba(245,165,36,.18); color: var(--warn);
|
|
}
|
|
.holdbar {
|
|
margin: .625rem auto 0; max-width: 34rem; padding: .5rem .75rem; border-radius: 7px;
|
|
border: 1px solid var(--warn); background: rgba(245,165,36,.14);
|
|
font-size: .8125rem; line-height: 1.45;
|
|
animation: holdPulse 1.6s ease-in-out infinite;
|
|
}
|
|
.holdbar b { color: var(--warn); letter-spacing: .04em; }
|
|
@keyframes holdPulse { 0%,100% { opacity: 1; } 50% { opacity: .68; } }
|
|
@media (prefers-reduced-motion: reduce) { .holdbar { animation: none; } }
|
|
|
|
.subhead { margin: 1rem 0 .375rem; font-size: .8125rem; color: var(--muted);
|
|
text-transform: uppercase; letter-spacing: .06em; }
|
|
|
|
/* The QR is printed and handed out. When the address behind it moves, or when
|
|
it is pointing at a network no phone is on, that has to be impossible to
|
|
miss — it looks fine either way, and the failure only shows up in the room. */
|
|
.qrwarn {
|
|
margin: 0 0 .625rem; padding: .5rem .6875rem; border-radius: 7px;
|
|
border: 1px solid var(--warn); background: rgba(245,165,36,.12);
|
|
color: var(--text); font-size: .8125rem; line-height: 1.45;
|
|
}
|
|
.qrwarn b { color: var(--warn); }
|
|
|
|
.chips { display: flex; flex-wrap: wrap; gap: .375rem; margin: .625rem 0; min-height: 1.75rem; }
|
|
.chips:empty::before { content: "None"; color: var(--muted); font-size: .8125rem; }
|
|
.chip {
|
|
display: inline-flex; align-items: center; gap: .375rem;
|
|
background: var(--raised); border: 1px solid var(--line);
|
|
border-radius: 999px; padding: .25rem .4375rem .25rem .6875rem; font-size: .8125rem;
|
|
}
|
|
.chip button { appearance: none; border: 0; background: transparent; color: var(--muted);
|
|
cursor: pointer; font-size: 1rem; line-height: 1; padding: 0 .1875rem; }
|
|
.vtag.warn { color: var(--warn); }
|
|
|
|
.chip button:hover { color: var(--stop); }
|
|
|
|
/* Pinning and unpinning used to be instantaneous, which on a page this long
|
|
reads as a chip appearing from nowhere. Same treatment as the language
|
|
columns: grow in, shrink out, and fly from the search result you picked. */
|
|
@keyframes chipIn { from { transform: scale(.86); opacity: 0; } to { transform: scale(1); opacity: 1; } }
|
|
@keyframes chipOut { from { transform: scale(1); opacity: 1; max-width: 18rem; }
|
|
to { transform: scale(.82); opacity: 0; max-width: 0;
|
|
padding-left: 0; padding-right: 0; margin-left: -.375rem; } }
|
|
.chip.chip-in { animation: chipIn .2s cubic-bezier(.2,.7,.3,1) both; }
|
|
.chip.chip-out { animation: chipOut .19s ease-in both; overflow: hidden; white-space: nowrap;
|
|
pointer-events: none; }
|
|
.chip.is-landing { visibility: hidden; }
|
|
.chipfly {
|
|
position: fixed; z-index: 60; pointer-events: none; margin: 0;
|
|
box-shadow: 0 8px 24px rgba(0,0,0,.45);
|
|
will-change: transform, opacity;
|
|
}
|
|
.chipfly button { display: none; }
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.chipfly { display: none; }
|
|
.chip.chip-in, .chip.chip-out { animation: none; }
|
|
}
|
|
|
|
.opresults { display: grid; gap: .1875rem; margin-top: .375rem; max-height: 11rem; overflow-y: auto; }
|
|
.opresults:empty { display: none; }
|
|
.opresults button {
|
|
appearance: none; text-align: left; width: 100%; font: inherit; font-size: .8125rem;
|
|
background: var(--raised); border: 1px solid transparent; color: var(--text);
|
|
border-radius: 8px; padding: .4375rem .625rem; cursor: pointer;
|
|
}
|
|
.opresults button:hover { border-color: var(--accent); }
|
|
.opresults button span { color: var(--muted); margin-left: .4375rem; font-size: .75rem; }
|
|
|
|
.codeblock { display: flex; align-items: center; gap: .5rem; background: var(--raised);
|
|
border: 1px solid var(--line); border-radius: 9px; padding: .5rem .625rem; }
|
|
.codeblock code { flex: 1; overflow-x: auto; white-space: nowrap; font-size: .75rem; color: #cbd5e6;
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
.codeblock + .codeblock { margin-top: .375rem; }
|
|
.urlname { flex: 0 0 3.5rem; font-size: .75rem; font-weight: 600; color: var(--dim); }
|
|
h2.mt { margin-top: 1.5rem; }
|
|
textarea { width: 100%; padding: .625rem .75rem; background: var(--raised); color: var(--text);
|
|
border: 1px solid var(--line); border-radius: 9px; font-family: ui-monospace, Menlo, monospace;
|
|
font-size: .8125rem; line-height: 1.6; resize: vertical; outline: none; }
|
|
textarea:focus { border-color: var(--accent); }
|
|
|
|
/* The volunteer's traffic light: one sentence, colored by severity. */
|
|
.op-health { margin: .375rem 0 0; font-size: .8125rem; font-weight: 600; }
|
|
.op-health[data-level="green"] { color: #4ade80; }
|
|
.op-health[data-level="amber"] { color: #fbbf24; }
|
|
.op-health[data-level="red"] { color: #f87171; }
|
|
|
|
/* The voice auditioning list: one row per candidate, play and choose. */
|
|
.voicelist { display: flex; flex-direction: column; gap: .25rem; margin-top: .5rem;
|
|
max-height: 19rem; overflow-y: auto; }
|
|
.vrow { display: flex; align-items: center; gap: .5rem; padding: .375rem .5rem;
|
|
background: var(--raised); border: 1px solid var(--line); border-radius: 8px; }
|
|
.vrow.is-current { border-color: var(--accent); }
|
|
.vrow .vname { flex: 1; font-size: .8125rem; }
|
|
.vrow .vtag { font-size: .6875rem; color: var(--dim); }
|
|
.vrow .vmsg { font-size: .6875rem; color: #fbbf24; max-width: 14rem; }
|
|
.vrow .vmsg.bad { color: #f87171; }
|
|
.vrow button { flex: 0 0 auto; }
|
|
/*
|
|
* "Offer this voice to listeners".
|
|
*
|
|
* Subject to exactly the two traps documented for .oprow below — `label` is
|
|
* styled as a small-caps field caption and bare `input` is width:100% — so it
|
|
* has to undo both, or the word "Offer" arrives shouting in letter-spaced caps
|
|
* next to a checkbox ten rems wide.
|
|
*/
|
|
.vrow .voffer {
|
|
display: flex; align-items: center; gap: .3rem;
|
|
flex: 0 0 auto; margin: 0;
|
|
text-transform: none; letter-spacing: normal; font-weight: 400;
|
|
font-size: .6875rem; color: var(--dim);
|
|
cursor: pointer; user-select: none;
|
|
}
|
|
.vrow .voffer input[type="checkbox"] { width: auto; min-width: 0; flex: 0 0 auto; margin: 0; }
|
|
.vrow .voffer:has(input:checked) { color: var(--accent); }
|
|
|
|
/*
|
|
* Checkbox rows on the operator page.
|
|
*
|
|
* These are <label> elements, and this stylesheet styles `label` as a small-caps
|
|
* FIELD CAPTION (uppercase, letter-spaced, muted) — correct for "API KEY", wrong
|
|
* for a sentence you click. It also styles bare `input` at width:100%, and
|
|
* `.row input` at min-width:10rem, so an unqualified checkbox rendered ten rems
|
|
* wide and shoved the language name off the side of the card. Both have to be
|
|
* undone explicitly here; verified in the browser, not assumed.
|
|
*/
|
|
.oprow, .langrow {
|
|
text-transform: none;
|
|
letter-spacing: normal;
|
|
font-weight: 400;
|
|
color: var(--text);
|
|
margin: 0;
|
|
}
|
|
.oprow {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: .5rem;
|
|
margin: .625rem 0 .125rem;
|
|
font-size: .8125rem;
|
|
cursor: pointer;
|
|
user-select: none;
|
|
flex: 0 0 auto;
|
|
}
|
|
/* Beat `input, select { width: 100% }` and `.row input { flex: 1; min-width: 10rem }`. */
|
|
.oprow input[type="checkbox"],
|
|
.langrow input[type="checkbox"],
|
|
.row .oprow input[type="checkbox"] {
|
|
width: auto;
|
|
min-width: 0;
|
|
flex: 0 0 auto;
|
|
padding: 0;
|
|
margin: 0;
|
|
cursor: pointer;
|
|
}
|
|
|
|
/* The language on/off list. 104 rows, so it scrolls inside its own box rather
|
|
than pushing the rest of the operator page down a screen and a half. */
|
|
.langlist {
|
|
margin-top: .5rem;
|
|
max-height: 22rem;
|
|
overflow-y: auto;
|
|
overflow-x: hidden;
|
|
border: 1px solid var(--line);
|
|
border-radius: 9px;
|
|
background: var(--bg);
|
|
}
|
|
.langrow {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: .5rem;
|
|
padding: .375rem .625rem;
|
|
font-size: .8125rem;
|
|
cursor: pointer;
|
|
border-bottom: 1px solid rgba(42, 50, 68, .5);
|
|
min-width: 0;
|
|
}
|
|
.langrow:last-child { border-bottom: 0; }
|
|
.langrow:hover { background: var(--raised); }
|
|
|
|
/* The native name is what the listener sees, so it leads. It shrinks and
|
|
ellipsises rather than forcing the row wider than the card. */
|
|
.lname {
|
|
flex: 0 1 9rem;
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.lsub {
|
|
flex: 1 1 auto;
|
|
min-width: 0;
|
|
color: var(--muted);
|
|
font-size: .75rem;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
/* Whether a language can be SPOKEN is the thing being tuned — it earns a spot
|
|
on every row instead of a separate list to cross-reference. */
|
|
.ltag { flex: 0 0 auto; font-size: .6875rem; color: var(--go); white-space: nowrap; }
|
|
.langrow.novoice .ltag { color: var(--muted); }
|
|
.langrow.novoice .lname { color: #c3cad9; }
|
|
|
|
/* ── the language columns ─────────────────────────────────────────────────
|
|
On the left, off on the right. Both scroll independently, and both must
|
|
contain their scroll: with only a few languages on, the left column is not
|
|
scrollable at all, so a trackpad flick over it would chain to the page while
|
|
the identical box beside it trapped — two panes that look the same behaving
|
|
oppositely. */
|
|
.langcols { display: grid; grid-template-columns: 1fr 1fr; gap: .5rem; margin-top: .5rem; }
|
|
.langcol {
|
|
display: flex; flex-direction: column; min-width: 0;
|
|
border: 1px solid var(--line); border-radius: 9px; background: var(--bg);
|
|
}
|
|
.langcolhead {
|
|
display: flex; align-items: baseline; gap: .375rem;
|
|
padding: .4375rem .625rem; border-bottom: 1px solid var(--line);
|
|
font-size: .6875rem; text-transform: uppercase; letter-spacing: .07em;
|
|
color: var(--muted); font-weight: 600;
|
|
}
|
|
.langcolhead b { color: var(--text); font-weight: 600; }
|
|
.langcols .langlist {
|
|
margin-top: 0; border: 0; border-radius: 0; background: transparent;
|
|
max-height: 20rem; overscroll-behavior: contain;
|
|
}
|
|
/* Two columns halve the row width, and the first casualties were exactly the
|
|
languages this church distinguishes — "Spanish (Latin Am…", "Português
|
|
(Portu…". The native name is what the LISTENER sees; the operator is looking
|
|
for the English name, so the native name yields its space here and stays
|
|
available on hover. */
|
|
.langcols .lsub { display: none; }
|
|
.langcols .lname { flex: 1 1 auto; }
|
|
|
|
/* The flying row. position:fixed on <body>, so no ancestor overflow can clip it
|
|
— which is the whole reason it exists. Animating the real row instead meant
|
|
the row was clipped by its own column for the entire part of the flight that
|
|
crosses the gap, and the list is rebuilt every 3 seconds by the server tick,
|
|
which would destroy a real-node animation mid-air. A clone notices neither. */
|
|
.langfly {
|
|
position: fixed; z-index: 60; pointer-events: none; margin: 0;
|
|
border-radius: 7px; border: 1px solid var(--accent);
|
|
background: var(--raised); box-shadow: 0 8px 24px rgba(0,0,0,.45);
|
|
will-change: transform, opacity;
|
|
}
|
|
.langfly .ltag, .langfly input { display: none; }
|
|
.langrow.is-landing { visibility: hidden; }
|
|
/* A short accent sweep on the row under the cursor, so the click is confirmed
|
|
even if the eye never follows the clone. */
|
|
@keyframes rowTouched { from { background: rgba(110,168,254,.22); } to { background: transparent; } }
|
|
.langrow.touched { animation: rowTouched .45s ease-out both; }
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.langfly { display: none; }
|
|
.langrow.touched { animation: none; }
|
|
}
|
|
|
|
/* Release notes are prose from the forge — wrap them, keep them short, and let
|
|
them scroll rather than push the rest of Settings off the pane. */
|
|
.updnotes {
|
|
max-height: 16rem; overflow: auto; white-space: pre-wrap; word-break: break-word;
|
|
font-size: .75rem; line-height: 1.45; color: var(--muted);
|
|
background: var(--panel, rgba(255,255,255,.03));
|
|
border-radius: 6px; padding: .625rem .75rem; margin: .375rem 0 0;
|
|
}
|
|
#updFound { margin-top: .5rem; }
|