Files
church-translate/app/engine.css
T

703 lines
32 KiB
CSS
Raw Normal View History

/*
* 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; }
}