/*
 * Smart Radio Telescope — web UI gateway
 * Copyright (C) 2026 Radio Science Institute
 *
 * This program is free software: you can redistribute it and/or
 * modify it under the terms of the GNU General Public License as
 * published by the Free Software Foundation, either version 3 of the
 * License, or (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 * SPDX-License-Identifier: GPL-3.0-or-later
 */

:root {
  --cy: #22d3ee;            /* structural cyan: lines, frames, borders */
  --cy-bright: #67e8f9;     /* emphasis: traces, reticle, active tab */
  --ink: #eaf9ff;           /* numerals — deliberately not cyan */
  --dim: rgba(191, 243, 255, 0.55);
  --warn: #fca5a5;
  --ok: #4ade80;
  --rule: rgba(34, 211, 238, 0.22);
  --hair: rgba(34, 211, 238, 0.14);
  --glow: 0 0 12px rgba(34, 211, 238, 0.7);
  --mono: ui-monospace, "JetBrains Mono", "DejaVu Sans Mono", monospace;
  --bg-near: #06222a;       /* radial gradient near */
  --bg-far: #030a0e;        /* radial gradient far */
  --scan: rgba(34, 211, 238, 0.045);  /* scan-line overlay tint */
  --btn-bg: rgba(34, 211, 238, 0.06);  /* button background */
  --warn-line: rgba(252, 165, 165, 0.45);  /* STOP button border */
}

* { box-sizing: border-box; }

/* The `hidden` attribute is only a UA-stylesheet `display: none`, so ANY
   author rule that sets `display` on the same element silently defeats it —
   the element keeps its layout while `el.hidden` reads true. That is not a
   theoretical trap: .power-actions sets `display: flex`, and without this
   rule the System tab showed the Shut-down button and its own confirmation
   prompt at the same time. Stated once here so every `hidden` in the markup
   means what it says. */
[hidden] { display: none !important; }

body {
  margin: 0;
  min-height: 100vh;
  /* The sky view sizes itself from this row, so a mobile browser's collapsing
     toolbar must not be counted in: with 100vh alone the footer sits below the
     fold until the toolbar retracts. */
  min-height: 100dvh;
  display: grid;
  grid-template-rows: auto auto 1fr auto auto;
  font-family: var(--mono);
  color: var(--ink);
  background: radial-gradient(130% 110% at 50% -10%, var(--bg-near) 0%, var(--bg-far) 62%);
  background-attachment: fixed;
}

/* Scan lines. A fixed overlay rather than a per-panel background so the
   pattern stays continuous across the whole window. */
body::after {
  content: "";
  position: fixed;
  inset: 0;
  pointer-events: none;
  z-index: 100;
  background: repeating-linear-gradient(
    to bottom, var(--scan) 0 1px, transparent 1px 3px);
}

/* --- brand -------------------------------------------------------------- */

/* The institute's mark sits in the header's cell grid like a readout does,
   the way a maker's plate sits on an instrument rather than beside it.

   It is also the link's lamp: the ring sweeps while the status socket is
   delivering and stops when it is not. The signal comes from the same
   body.is-disconnected the motion lockout uses, not from a second source, so
   the lamp cannot disagree with the buttons it sits above. .link-state spells
   the same state out in words a few cells to the right -- the sweep is the
   glanceable half of it, never the only carrier. */
.brand {
  display: flex;
  padding: 9px 16px; border-right: 1px solid var(--hair);
}

/* The mark links out to the institute, in a new tab -- see index.html for
   why a control station must not follow a link in place. Everything inside
   inherits the HUD's own colours; only the focus ring is added, because a
   link with no visible focus is unreachable from the keyboard. The other
   focusables here brighten their border instead, which a link has none of. */
.brand-link {
  display: flex; align-items: center; gap: 10px;
  color: inherit; text-decoration: none; border-radius: 4px;
}
.brand-link:focus-visible { outline: 1px solid var(--cy-bright); outline-offset: 3px; }
.brand-link:hover .brand-name { color: var(--cy-bright); }

.brand-mark { position: relative; flex: none; width: 36px; height: 36px; }
.brand-mark img { display: block; width: 100%; height: 100%; border-radius: 50%; }

/* Both rings sit 4px outside the badge, so the badge's own printed rim stays
   the innermost edge and the sweep reads as instrumentation around it rather
   than as part of the artwork. */
.brand-mark::before,
.brand-mark::after {
  content: ""; position: absolute; inset: -4px;
  border-radius: 50%; pointer-events: none;
}

/* The track the arc runs in. */
.brand-mark::before { border: 1px solid var(--rule); }

/* The arc itself: a 75-degree wedge fading to nothing, masked down to a ring
   so the badge underneath stays clean. closest-side measures to the edge of
   this square box, i.e. the ring's own radius. */
.brand-mark::after {
  background: conic-gradient(from 0deg,
              rgba(34, 211, 238, 0.55), rgba(34, 211, 238, 0) 75deg);
  -webkit-mask: radial-gradient(closest-side, transparent 86%, #000 86%);
  mask: radial-gradient(closest-side, transparent 86%, #000 86%);
  animation: brand-sweep 4s linear infinite;
}

/* Four seconds per turn: slow enough to read as a dish tracking rather than
   as a spinner waiting for something. */
@keyframes brand-sweep { to { transform: rotate(360deg); } }

.brand-name {
  font-size: 9px; letter-spacing: 0.2em; text-transform: uppercase;
  line-height: 1.5; color: var(--dim); white-space: nowrap;
}

/* A moving mark is the first thing to drop for a viewer who asked for less
   motion. The ring stays put so the cell keeps its shape, and the words in
   .link-state still carry the state. */
@media (prefers-reduced-motion: reduce) {
  .brand-mark::after { animation: none; }
}

/* The header is a single non-wrapping row, so every cell in it competes for
   the same width -- with the words the row wants 949px, without them 842px.
   Below a laptop's width the mark alone carries the identity and the words
   step aside, rather than squeezing the readouts they sit next to. */
@media (max-width: 1000px) {
  .brand { padding-right: 12px; }
  .brand-link { gap: 0; }
  .brand-name { display: none; }
}

/* --- header ------------------------------------------------------------ */

.head {
  display: flex;
  align-items: stretch;
  border-bottom: 1px solid var(--rule);
}

.readout { padding: 9px 16px; border-right: 1px solid var(--hair); }
.readout .label {
  font-size: 9px; letter-spacing: 0.2em; text-transform: uppercase;
  color: var(--dim);
}
.readout .value {
  font-size: 22px; font-weight: 500; line-height: 1.2;
  font-variant-numeric: tabular-nums;
}
/* Per-axis homing, under the angle it belongs to. The aggregate HOMED in the
   footer answers "may I command a move at all"; this answers "which axis do I
   have to re-home", which is the question you actually have on a three-axis
   mount. Colour is not the only carrier — the words differ too, so it reads
   without colour vision. min-width keeps the header from reflowing as an axis
   changes between the two words. */
.readout .homed {
  font-size: 9px; letter-spacing: 0.14em; text-transform: uppercase;
  line-height: 1.6; min-width: 8ch; color: var(--dim);
}
.readout .homed.is-homed     { color: var(--ok); }
.readout .homed.is-not-homed { color: var(--warn); }

.head .spacer { margin-left: auto; display: flex; align-items: center; gap: 8px;
                padding: 0 14px; }

.btn {
  font-family: var(--mono); font-size: 10px; letter-spacing: 0.16em;
  text-transform: uppercase; padding: 8px 16px; cursor: pointer;
  color: var(--cy-bright); background: var(--btn-bg);
  border: 1px solid var(--rule); border-radius: 4px;
}
.btn:hover:not(:disabled) { background: var(--hair); }
.btn:disabled { opacity: 0.35; cursor: not-allowed; }
.btn-stop { color: var(--warn); border-color: var(--warn-line); }
/* Deliberately not .btn-stop: that class is what the disconnected rule at the
   bottom of this file exempts, and it must keep meaning exactly one thing —
   the header's global STOP. The scan's own stop is an ordinary view control
   and is correctly locked out with the rest of them. */
.btn-warn { color: var(--warn); border-color: var(--warn-line); }

.link-state { font-size: 10px; letter-spacing: 0.14em; color: var(--dim); }
.link-state.is-stale, .link-state.is-warn { color: var(--warn); }

/* --- tabs -------------------------------------------------------------- */

#tabs { display: flex; gap: 2px; padding: 0 12px;
        border-bottom: 1px solid var(--rule); }
.tab {
  font-family: var(--mono); font-size: 9.5px; letter-spacing: 0.18em;
  text-transform: uppercase; color: var(--dim); background: none;
  border: 0; border-bottom: 2px solid transparent; padding: 9px 14px;
  cursor: pointer;
}
.tab.is-active {
  color: var(--cy-bright); border-bottom-color: var(--cy);
  text-shadow: var(--glow);
}

/* --- views ------------------------------------------------------------- */

/* main is the 1fr row of the body grid and therefore already has a definite
   height; this is what passes it on to the active view, which the sky view
   needs to size its dome. min-height: 0 so a view taller than the row can
   still overflow instead of forcing the grid open. */
main { display: flex; flex-direction: column; min-height: 0; }

.view { display: none; padding: 14px 16px; min-height: 0; }
/* flex-shrink: 0 — the other tabs grow into an otherwise empty window (their
   content is top-aligned, so they look unchanged) but are never squeezed
   below their content height. */
.view.is-active { display: block; flex: 1 0 auto; }

/* --- signal strip and footer ------------------------------------------- */

.strip { border-top: 1px solid var(--rule); padding: 6px 12px; }
#signal-strip { display: block; width: 100%; height: 46px; }

.foot {
  display: flex; gap: 20px; padding: 7px 14px;
  border-top: 1px solid var(--rule);
  font-size: 9.5px; letter-spacing: 0.14em; color: var(--dim);
}
.foot .gps { margin-left: auto; }
.foot .ok { color: var(--ok); }

/* --- sky view ------------------------------------------------------------ */

/* A column so .sky-wrap below can take all the height main handed down. */
.view[data-view="sky"].is-active { display: flex; flex-direction: column; }

/* Positioning context for the canvas and the scan panel, and the sky's size:
   it takes whatever height the view has left, so the dome is as large as the
   panel can offer at any resolution. The minimum keeps it usable on a short
   window — the page scrolls then, which body's min-height already allows. */
.sky-wrap { position: relative; flex: 1 1 auto; min-height: 320px; }

/* Absolute against .sky-wrap, and that is what makes height: 100% mean
   something. In flow it did not resolve — no ancestor had a height — so the
   canvas fell back to its intrinsic aspect ratio (the 300x150 default, since
   the element carries no width/height attributes) and resizeToBox() wrote
   that same 2:1 ratio back into the backing store every frame. The box
   settled at w x w/2, so the dome — min(w, h) — was only ever half the panel
   wide. Both percentages are required: inset alone does not stretch a
   replaced element, which keeps its intrinsic size when width/height are
   auto. */
#sky-canvas { position: absolute; inset: 0; width: 100%; height: 100%;
              display: block; cursor: crosshair;
              /* Without this a region drag scrolls the page on a touch
                 screen instead of drawing, exactly as #jog-pad found. */
              touch-action: none; }
#sky-canvas.is-scan-mode { cursor: cell; }

/* The institute's mark etched into the sky, the way a maker's name is etched
   into an eyepiece. Deliberately faint: it is an identity, not a readout, and
   the dome behind it has to stay readable through it -- so it carries no
   background of its own and never takes a pointer event.

   z-index 1 puts it over the canvas and under every overlay: the four corner
   panels all sit at 2, so an open catalogue or credit line covers it rather
   than fighting it for the same pixels. The bottom offset clears the
   catalogue's collapsed bar, which is what that corner shows at rest.

   Sized against the viewport's short side rather than a fixed pixel count:
   the dome is sized against the short side of this box, and this box is
   almost all of the viewport, so the two grow and shrink together. */
.sky-wrap::after {
  content: ""; position: absolute; z-index: 1; pointer-events: none;
  right: 16px; bottom: 52px;
  width: clamp(110px, 15vmin, 190px); aspect-ratio: 1;
  background: url("../img/rsi-badge.png") center / contain no-repeat;
  opacity: 0.06;
}

/* --- scan panel ---------------------------------------------------------- */

.scan-panel {
  position: absolute; top: 8px; right: 8px; z-index: 2;
  width: 300px; max-width: calc(100% - 16px);
  border: 1px solid var(--rule); border-radius: 4px;
  background: rgba(3, 10, 14, 0.88);
}
.scan-bar {
  display: flex; width: 100%; gap: 8px; align-items: center;
  padding: 8px 12px; cursor: pointer;
  font-family: var(--mono); font-size: 9.5px; letter-spacing: 0.16em;
  text-transform: uppercase; color: var(--cy-bright);
  background: transparent; border: 0;
}
.scan-bar-state { margin-left: auto; color: var(--dim); }
.scan-bar-state.is-running { color: var(--ok); }
.scan-bar-state.is-warn { color: var(--warn); }

.scan-body { padding: 10px 12px; border-top: 1px solid var(--hair); }
.scan-check {
  display: flex; gap: 8px; align-items: center; margin-bottom: 8px;
  font-size: 9px; letter-spacing: 0.14em; text-transform: uppercase;
  color: var(--dim); cursor: pointer;
}
.scan-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.scan-fields label {
  display: flex; flex-direction: column; gap: 4px;
  font-size: 9px; letter-spacing: 0.14em; text-transform: uppercase;
  color: var(--dim);
}
.scan-fields input, .scan-fields select {
  font-family: var(--mono); font-size: 12px; width: 100%;
  padding: 5px 7px; color: var(--ink); background: transparent;
  border: 1px solid var(--rule); border-radius: 4px;
}
.scan-fields input:focus, .scan-fields select:focus {
  outline: none; border-color: var(--cy-bright);
}
.scan-fields option { background: var(--bg-far); }

.scan-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
.scan-actions .btn { padding: 6px 10px; }

.scan-note {
  margin: 6px 0 0; font-size: 10px; line-height: 1.45; letter-spacing: 0.06em;
  color: var(--dim); font-variant-numeric: tabular-nums;
}
.scan-note.is-warn { color: var(--warn); }

/* --- sky map overlay ----------------------------------------------------- */

/* Mirror of .scan-panel on the other corner, and deliberately narrower: it
   holds one control. */
.map-panel {
  position: absolute; top: 8px; left: 8px; z-index: 2;
  max-width: calc(100% - 16px);
  padding: 8px 12px;
  border: 1px solid var(--rule); border-radius: 4px;
  background: rgba(3, 10, 14, 0.88);
}
.map-pick {
  display: flex; flex-direction: column; gap: 4px;
  font-size: 9px; letter-spacing: 0.14em; text-transform: uppercase;
  color: var(--dim);
}
.map-pick select {
  font-family: var(--mono); font-size: 12px;
  max-width: 300px; padding: 5px 7px;
  color: var(--ink); background: transparent;
  border: 1px solid var(--rule); border-radius: 4px;
}
.map-pick select:focus { outline: none; border-color: var(--cy-bright); }
.map-pick select:disabled { color: var(--dim); }
/* A transparent select renders an unreadable native dropdown; the options
   need a ground of their own, exactly as .scan-fields found. */
.map-pick option { background: var(--bg-far); }

/* The marker toggle under the picker, in the scan panel's checkbox idiom. */
.map-check {
  display: flex; gap: 8px; align-items: center; margin-top: 8px;
  font-size: 9px; letter-spacing: 0.14em; text-transform: uppercase;
  color: var(--dim); cursor: pointer;
}

/* The credit line. Bottom-left of the dome, out of the reticle's way, and
   the survey's citation obligation is discharged here — so it is not
   permitted to be decorative: it stays legible against the sky. */
.map-credit {
  position: absolute; left: 8px; bottom: 8px; z-index: 2;
  max-width: calc(100% - 16px); margin: 0;
  padding: 4px 8px; border-radius: 3px;
  background: rgba(3, 10, 14, 0.72);
  font-size: 9.5px; line-height: 1.4; letter-spacing: 0.06em;
  color: var(--dim);
}
.map-credit a { color: var(--dim); text-decoration: underline; }
.map-credit a:hover { color: var(--cy-bright); }

/* --- object catalogue ---------------------------------------------------- */

/* Bottom-right of the dome: the fourth and last corner, diagonally opposite
   the credit and under the scan panel, so no two overlays can ever cover each
   other. Collapsed by default like the scan panel — the dome is what goes on
   the projector, and a visitor opens this when they want to read. */
.obj-panel {
  position: absolute; right: 8px; bottom: 8px; z-index: 2;
  width: 320px; max-width: calc(100% - 16px);
  border: 1px solid var(--rule); border-radius: 4px;
  background: rgba(3, 10, 14, 0.88);
}
.obj-bar {
  display: flex; width: 100%; gap: 8px; align-items: center;
  padding: 8px 12px; cursor: pointer;
  font-family: var(--mono); font-size: 9.5px; letter-spacing: 0.16em;
  text-transform: uppercase; color: var(--cy-bright);
  background: transparent; border: 0;
}
.obj-panel-body { border-top: 1px solid var(--hair); }

/* The list scrolls inside the panel rather than growing it: eighteen objects
   are taller than any sky view, and a panel that pushes past the canvas would
   cover the dome it annotates. */
.obj-list {
  max-height: 320px; overflow-y: auto;
  padding: 4px 12px 0;
}

.obj-row {
  display: grid; grid-template-columns: 1fr auto;
  gap: 2px 8px; align-items: baseline;
  padding: 7px 0; border-bottom: 1px solid var(--hair);
}
.obj-row:last-child { border-bottom: 0; }
.obj-row.is-open { background: rgba(34, 211, 238, 0.05); }

.obj-head {
  display: flex; gap: 8px; align-items: baseline; text-align: left;
  padding: 0; cursor: pointer;
  background: transparent; border: 0; font-family: var(--mono);
}
.obj-name { font-size: 12px; color: var(--ink); }
.obj-kind {
  font-size: 8.5px; letter-spacing: 0.12em; text-transform: uppercase;
  color: var(--dim);
}

.obj-when {
  grid-column: 1; margin: 0;
  font-size: 10px; line-height: 1.4; letter-spacing: 0.04em;
  font-variant-numeric: tabular-nums; color: var(--dim);
}
/* Up now, coming up, and out of reach. Three states, three colours — the
   fourth (circumpolar) is up as well, and reads the same. */
.obj-when.is-up { color: var(--ok); }
.obj-when.is-soon { color: var(--ink); }
.obj-when.is-out { color: var(--warn); }

.obj-track { grid-column: 2; grid-row: 1 / span 2; padding: 5px 10px; }
.obj-track:disabled { opacity: 0.4; cursor: not-allowed; }

.obj-detail { grid-column: 1 / -1; }
.obj-text {
  margin: 6px 0 0; font-size: 10.5px; line-height: 1.5;
  letter-spacing: 0.03em; color: var(--ink);
}
.obj-facts {
  display: grid; grid-template-columns: auto 1fr; gap: 2px 10px;
  margin: 6px 0 2px; font-size: 10px; line-height: 1.4;
}
.obj-facts dt {
  letter-spacing: 0.12em; text-transform: uppercase; font-size: 8.5px;
  color: var(--dim);
}
.obj-facts dd { margin: 0; color: var(--dim); }

.obj-result {
  margin: 0; padding: 6px 12px 8px;
  font-size: 10px; letter-spacing: 0.06em; color: var(--dim);
  border-top: 1px solid var(--hair);
}

/* --- control view -------------------------------------------------------- */

/* Three panels, grouped by what the operator is aiming at. They wrap rather
   than compress: a Goto field narrower than its own number is worse than a
   second row of panels. */
/* Not stretched: a card is as tall as what it holds. Forcing three of them
   to the tallest one's height left the shortest with a hole in the middle
   and its result line marooned at the foot of it. */
.ctrl-grid {
  display: flex; flex-wrap: wrap; gap: 16px;
  align-items: flex-start;
}
.ctrl-panel {
  flex: 1 1 340px; min-width: 0;
  display: flex; flex-direction: column;
  padding: 14px 16px 12px;
  border: 1px solid var(--hair); border-radius: 6px;
  background: rgba(34, 211, 238, 0.025);
}
.ctrl-title {
  margin: 0 0 12px; font-size: 10px; font-weight: 500;
  letter-spacing: 0.16em; text-transform: uppercase; color: var(--dim);
}
.ctrl-fields {
  display: flex; flex-wrap: wrap; align-items: flex-end; gap: 10px 12px;
}
.ctrl-fields-wide {
  margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--hair);
}
.ctrl-fields label {
  display: flex; flex-direction: column; gap: 4px;
  font-size: 9px; letter-spacing: 0.14em; text-transform: uppercase;
  color: var(--dim);
}
.ctrl-grow { flex: 1 1 200px; min-width: 0; }

/* The three axes share one grid: captions on the first row, fields on the
   second, range hints on the third. The labels are display: contents and
   each of their three parts names its own row, so the parts land in
   columns in the order they are written. That is what lets Goto sit on the
   field row -- in a flex row it sank to the bottom of the tallest label,
   a caption's height below the fields it belongs to -- and it keeps the
   three hints on one baseline however long each one's numbers are. */
.ctrl-axes-fields {
  display: grid; align-items: end;
  grid-template-columns: repeat(3, minmax(6ch, 1fr)) auto;
  gap: 4px 12px;
}
.ctrl-axes-fields label { display: contents; }
.ctrl-axes-fields .ctrl-cap { grid-row: 1; }
.ctrl-axes-fields input { grid-row: 2; width: 100%; }
.ctrl-axes-fields .ctrl-hint { grid-row: 3; }
.ctrl-axes-go { grid-column: 4; grid-row: 2; }
.ctrl-cap {
  font-size: 9px; letter-spacing: 0.14em; text-transform: uppercase;
  color: var(--dim);
}
.ctrl-fields input, .ctrl-axes-fields input, .site-form input {
  font-family: var(--mono); font-size: 13px; width: 9ch;
  padding: 7px 9px; color: var(--ink); background: transparent;
  border: 1px solid var(--rule); border-radius: 4px;
}
.ctrl-fields input:focus,
.ctrl-axes-fields input:focus,
.site-form input:focus { outline: none; border-color: var(--cy-bright); }
/* Marked, not blocked: the bounds come from /limits and the backend stays
   the authority on what it accepts. The native :out-of-range would also stop
   the submit and explain itself in a browser bubble, which is why these are
   our own classes, set from the same verdict the line below the fields
   spells out. */
.ctrl-axes-fields input.is-risky { border-color: var(--warn-line); }
.ctrl-axes-fields input.is-rejected {
  border-color: var(--warn); color: var(--warn);
}
.ctrl-fields select {
  font-family: var(--mono); font-size: 13px; width: 100%; max-width: 42ch;
  padding: 7px 9px; color: var(--ink); background: transparent;
  border: 1px solid var(--rule); border-radius: 4px;
}
.ctrl-fields select:focus { outline: none; border-color: var(--cy-bright); }
/* A transparent select renders an unreadable native dropdown; the options
   need a ground of their own, as .scan-fields and .map-pick both found. */
.ctrl-fields option { background: var(--bg-far); }
.ctrl-fields option:disabled { color: var(--dim); }

.ctrl-hint {
  font-size: 9px; letter-spacing: 0.06em; text-transform: none;
  font-variant-numeric: tabular-nums; color: var(--dim);
}

/* The warning while a target is being typed. It sits between the fields and
   the result line because it is about what has not been sent yet. */
.ctrl-verdict {
  margin: 12px 0 0; font-size: 11px; line-height: 1.45;
  letter-spacing: 0.04em; color: var(--dim);
  min-height: 1.45em;   /* held open, so a warning never shifts the button */
}
.ctrl-verdict.is-warn { color: var(--warn); }
.ctrl-verdict.is-error { color: var(--warn); }

/* Every panel's own outcome line, under the controls that write it. */
.ctrl-result {
  margin: 12px 0 0;
  font-size: 11px; letter-spacing: 0.08em; color: var(--dim);
}
/* The tracker's own line: what the mount is following, which can change
   without anyone on this tab having pressed anything. */
.ctrl-tracking {
  margin: 12px 0 0; font-size: 11px; letter-spacing: 0.08em;
  color: var(--dim);
}

.axis-state {
  display: grid; grid-template-columns: max-content max-content max-content 1fr;
  gap: 6px 14px; margin: 0 0 14px; align-items: baseline;
}
.axis-state dt {
  font-size: 9px; letter-spacing: 0.16em; color: var(--dim);
}
.axis-state dd { margin: 0; font-size: 11px; color: var(--dim); }
.axis-state-value {
  font-size: 15px; color: var(--ink); font-variant-numeric: tabular-nums;
}
.axis-state-homed { font-size: 9px; letter-spacing: 0.14em; }
.axis-state-homed.is-homed { color: var(--ok); }
.axis-state-homed.is-not-homed { color: var(--warn); }
.axis-state-switch.is-closed { color: var(--warn); }

.ctrl-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.ctrl-row-power {
  margin-top: 10px; padding-top: 12px; border-top: 1px solid var(--hair);
}

/* --- axis bars ----------------------------------------------------------- */

/* The Control view is a column so the axis bars can take the height the
   three panels left over -- the same arrangement the dashboard uses to give
   its chart the room. */
.view[data-view="control"].is-active {
  display: flex; flex-direction: column; min-height: 0;
}
.axis-bars {
  flex: 1 1 auto; display: flex; flex-direction: column;
  margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--hair);
}
.axis-bar-rows {
  flex: 1 1 auto; display: flex; flex-direction: column;
  justify-content: space-evenly; gap: 12px; min-height: 0;
}
.axis-bar {
  display: grid; grid-template-columns: 3.5ch 1fr 9ch;
  align-items: center; gap: 12px;
}
.axis-bar-body { min-width: 0; }
.axis-bar-name {
  font-size: 9px; letter-spacing: 0.16em; color: var(--dim);
}
.axis-bar-value {
  font-size: 13px; text-align: right; color: var(--ink);
  font-variant-numeric: tabular-nums;
}
.axis-bar-track {
  position: relative; height: 36px; border-radius: 4px;
  border: 1px solid var(--hair); background: rgba(34, 211, 238, 0.03);
  overflow: hidden;
  /* Aimable by pointer: crosshair rather than pointer, because a click
     picks a place on the bar rather than activating the bar. */
  cursor: crosshair;
  touch-action: none;   /* a drag along the bar must not scroll the page */
}
.axis-bar-track:hover { border-color: var(--rule); }
.axis-band { position: absolute; top: 0; bottom: 0; }
.axis-band.is-envelope { background: rgba(34, 211, 238, 0.09); }
/* One degree of AZ is 0.27 % of its travel. Without a floor the zone that
   de-homes the axis is the one band on the drawing nobody can see. */
.axis-band.is-endstop { background: rgba(252, 165, 165, 0.22); min-width: 4px; }
/* The fill reads as a gauge rather than a marker: how far along its travel
   the axis is, which is the question the bare number does not answer. */
.axis-fill {
  position: absolute; top: 0; bottom: 0; left: 0;
  background: linear-gradient(90deg,
    rgba(34, 211, 238, 0.08), rgba(34, 211, 238, 0.28));
  border-right: 2px solid var(--cy-bright);
  box-shadow: var(--glow);
}
/* Dashed, because it is where the axis would go and not where it is. */
.axis-mark.is-target {
  position: absolute; top: 0; bottom: 0; width: 0;
  border-left: 2px dashed var(--ink);
}
.axis-bar-scale {
  display: flex; justify-content: space-between; margin-top: 3px;
  font-size: 9px; color: var(--dim); font-variant-numeric: tabular-nums;
}
.axis-legend {
  display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
  margin: 10px 0 0; font-size: 9px; letter-spacing: 0.08em; color: var(--dim);
}
.axis-key {
  width: 16px; height: 9px; border-radius: 2px;
  background: rgba(34, 211, 238, 0.06);
}
.axis-key.is-fill { background: rgba(34, 211, 238, 0.28);
  border-right: 2px solid var(--cy-bright); }
.axis-key.is-envelope { background: rgba(34, 211, 238, 0.09);
  border: 1px solid var(--hair); }
.axis-key.is-endstop { background: rgba(252, 165, 165, 0.22); }
.axis-key.is-target { background: none; border-left: 2px dashed var(--ink);
  width: 2px; height: 11px; border-radius: 0; }
.axis-key-text { margin-right: 12px; }

/* --- dashboard view ------------------------------------------------------ */

/* The chart's own heading, and beside it whether anything is arriving. The
   two sit on one baseline so the state reads as part of the title rather
   than as a separate notice. */
.chart-head {
  display: flex; align-items: baseline; gap: 14px; flex-wrap: wrap;
  margin: 0 0 10px;
}
.chart-title {
  margin: 0; font-size: 10px; letter-spacing: 0.18em; text-transform: uppercase;
  color: var(--cy-bright); font-weight: 400;
}
.source-switch { display: flex; align-items: baseline; gap: 6px; }
.source-switch .btn.is-active {
  color: var(--cy-bright); border-color: var(--cy); text-shadow: var(--glow);
}
.source-note { font-size: 9px; letter-spacing: 0.14em; color: var(--warn); }
.chart-state {
  font-size: 9px; letter-spacing: 0.14em; text-transform: uppercase;
  color: var(--dim);
}
/* Colour is never the only carrier: the words differ too ("live" against
   "no ADC data"), so the state reads without colour vision. */
.chart-state.is-live { color: var(--ok); }
.chart-state.is-live::before { content: "\25cf "; }
.chart-state.is-stale { color: var(--warn); }

/* The dashboard is the one view sized to the window rather than to its own
   content: the chart is its subject, so it takes whatever height the panels
   below it leave. The other tabs keep .view.is-active's top-aligned block
   layout. */
.view[data-view="dashboard"].is-active {
  display: flex; flex-direction: column; min-height: 0;
}

#signal-chart {
  display: block; width: 100%;
  /* flex-basis 0, so the canvas is sized by what is left over rather than by
     a height of its own — dashboard.js reads clientHeight every frame and
     follows. The floor keeps it a chart rather than a line on a short window;
     below that the view overflows and the page scrolls, which is the honest
     outcome. */
  flex: 1 1 0; min-height: 200px;
}
#signal-stats {
  margin-top: 10px; font-size: 11px; letter-spacing: 0.08em;
  color: var(--dim); font-variant-numeric: tabular-nums;
}
#signal-stats.is-clipped { color: var(--warn); }

/* --- chart controls ------------------------------------------------------ */

.chart-controls {
  display: flex; flex-wrap: wrap; align-items: flex-end; gap: 10px 18px;
  margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--hair);
}
.chart-pick {
  display: flex; flex-direction: column; gap: 4px;
  font-size: 9px; letter-spacing: 0.14em; text-transform: uppercase;
  color: var(--dim);
}
.chart-pick select {
  font-family: var(--mono); font-size: 12px; padding: 5px 7px;
  color: var(--ink); background: transparent;
  border: 1px solid var(--rule); border-radius: 4px;
}
.chart-pick select:focus { outline: none; border-color: var(--cy-bright); }
/* A transparent select renders an unreadable native dropdown; the options
   need a ground of their own, as .scan-fields and .map-pick both found. */
.chart-pick option { background: var(--bg-far); }

/* The manual controls. Hidden in auto mode rather than disabled: they steer
   nothing there, and a greyed-out slider still invites a drag. */
.chart-manual { display: flex; flex-wrap: wrap; gap: 10px 18px; }
.chart-slider {
  display: grid; grid-template-columns: 1fr; gap: 4px;
  font-size: 9px; letter-spacing: 0.14em; text-transform: uppercase;
  color: var(--dim);
}
.chart-slider input[type="range"] {
  width: 190px; max-width: 46vw; accent-color: var(--cy);
  background: transparent; cursor: pointer;
}
.chart-slider input[type="range"]:focus-visible {
  outline: 1px solid var(--cy-bright); outline-offset: 3px;
}
/* The live value under its own slider. Tabular so the number does not
   shuffle the layout while the slider is being dragged. */
.chart-slider-value {
  font-family: var(--mono); font-size: 11px; letter-spacing: 0.06em;
  text-transform: none; color: var(--ink);
  font-variant-numeric: tabular-nums;
}

/* --- system view ----------------------------------------------------------- */

/* One card per source. Capped in width rather than stretched: four cards
   across a 1880px window is a row of readable columns, four cards sharing
   that width equally is four columns of mostly air. */
.sys-cards {
  display: flex; flex-wrap: wrap; gap: 16px; align-items: flex-start;
  margin-top: 4px;
}
.sys-card {
  flex: 1 1 280px; min-width: 0; max-width: 420px;
  display: flex; flex-direction: column;
  padding: 14px 16px 12px;
  border: 1px solid var(--hair); border-radius: 6px;
  background: rgba(34, 211, 238, 0.025);
}
/* Where this card's numbers come from and how often. It is the answer to
   "why is one temperature moving and the other frozen", which a single flat
   list of rows could not give. */
.sys-source {
  margin: -6px 0 12px; font-size: 9px; letter-spacing: 0.06em;
  color: var(--dim);
}

/* What the thing is, above how it is doing: asked once, not watched. */
.sys-ident, .sys-rows {
  display: grid; grid-template-columns: max-content 1fr;
  gap: 8px 16px; margin: 0;
}
.sys-ident {
  padding-bottom: 12px; margin-bottom: 12px;
  border-bottom: 1px solid var(--hair);
}
.sys-ident dt, .sys-rows dt {
  font-size: 9px; letter-spacing: 0.16em; text-transform: uppercase;
  color: var(--dim); align-self: center;
}
/* Right-aligned, like the headline over every meter below it: one column of
   figures down the card rather than values that start wherever their label
   happened to end. */
.sys-ident dd, .sys-rows dd {
  margin: 0; font-size: 15px; color: var(--ink); text-align: right;
  font-variant-numeric: tabular-nums;
}

/* A percentage is a position on a scale, and this is what a position looks
   like. The number stays beside it — the bar is the glanceable half of the
   row, never the only carrier. */
.sys-meter { margin-top: 12px; }
.sys-meter-head {
  display: flex; justify-content: space-between; align-items: baseline;
  gap: 12px;
  font-size: 9px; letter-spacing: 0.16em; text-transform: uppercase;
  color: var(--dim);
}
.sys-meter-head span:last-child {
  font-size: 15px; letter-spacing: 0.02em; text-transform: none;
  color: var(--ink); font-variant-numeric: tabular-nums;
}
.sys-meter-track {
  position: relative; height: 7px; margin-top: 6px;
  border: 1px solid var(--hair); border-radius: 3px;
  background: rgba(34, 211, 238, 0.05); overflow: hidden;
}
.sys-meter-fill {
  display: block; position: absolute; inset: 0 auto 0 0;
  border-radius: 2px; background: var(--cy);
  box-shadow: 0 0 8px rgba(34, 211, 238, 0.45);
}
/* The Pi clocks itself down at 80 °C; the scale ends at the 85 °C hard
   clamp. Both are the board's numbers, so the mark says something that
   happens rather than something we decided to warn about. */
.sys-meter-mark {
  display: block; position: absolute; top: -1px; bottom: -1px;
  width: 1px; background: var(--warn-line); z-index: 1;
}
.sys-meter-note {
  margin: 5px 0 0; font-size: 9px; letter-spacing: 0.06em;
  color: var(--dim); font-variant-numeric: tabular-nums;
}

/* A source that has stopped answering. The values stay on screen — see the
   disconnected block below for why they are never blanked — but they go dim
   and the card's own caption says so, because a card full of dashes looks
   the same whether the machine is unreachable or every sensor on it failed.
   The Controller card keys off the same body.is-disconnected the motion
   lockout uses rather than a second staleness signal of its own. */
.sys-card.is-stale .sys-source::after,
body.is-disconnected .sys-card[data-source="pico"] .sys-source::after {
  content: " · not answering"; color: var(--warn);
}
.sys-card.is-stale dd,
.sys-card.is-stale .sys-meter-head span:last-child,
body.is-disconnected .sys-card[data-source="pico"] dd,
body.is-disconnected .sys-card[data-source="pico"] .sys-meter-head span:last-child {
  color: var(--dim);
}
/* The bar goes with its number. It keeps its width — the last reading is
   still the last reading — but stops glowing like something live. */
.sys-card.is-stale .sys-meter-fill,
body.is-disconnected .sys-card[data-source="pico"] .sys-meter-fill {
  opacity: 0.45; box-shadow: none;
}

/* The Site card's hand entry -- same input look as the control tab's fields
   (see .ctrl-axes-fields input above), laid out as a row that wraps rather
   than a grid, since it is three items, not a table. */
.site-form { display: flex; flex-wrap: wrap; gap: 8px; align-items: flex-end; }
.site-form label { display: flex; flex-direction: column; gap: 4px; }
.site-form input { width: 9em; }
.site-result { min-height: 1.2em; }

/* The power block is a card like the others now; its own separator from the
   old flat layout would be a second frame inside the first. */

/* --- dashboard: the panels under the chart ------------------------------- */

/* Never squeezed: the chart above absorbs the window's height instead
   (flex: 1 1 0), so these two keep the size their content needs. They wrap to
   one column well before phone width, since either one alone is unreadable at
   half of 400 px. */
.chart-hint {
  margin-left: auto;
  font-size: 10px;
  letter-spacing: 0.08em;
  color: var(--dim);
}

.dash-lower {
  flex: 0 0 auto;
  display: flex; flex-wrap: wrap; gap: 16px 28px;
  margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--hair);
}
.dash-panel { flex: 1 1 320px; min-width: 0; }
.dash-panel-title {
  margin: 0 0 8px; font-size: 9px; font-weight: 500;
  letter-spacing: 0.16em; text-transform: uppercase; color: var(--dim);
}

/* The list scrolls inside its own panel rather than growing it, the same
   reason .obj-list does: twenty entries are taller than the space the chart
   can spare, and a panel that grows would push the chart off the window. */
.event-list {
  list-style: none; margin: 0; padding: 0;
  max-height: 168px; overflow-y: auto;
}
.event-empty { font-size: 11px; color: var(--dim); }
.event-row {
  display: flex; gap: 10px; align-items: baseline;
  padding: 4px 0; border-bottom: 1px solid var(--hair);
  font-size: 11.5px; line-height: 1.4;
}
.event-row:last-child { border-bottom: 0; }
.event-time {
  flex: none; color: var(--dim); font-variant-numeric: tabular-nums;
}
.event-text { min-width: 0; }
/* Colour carries emphasis, never the meaning: the sentence beside it already
   says what happened, so a reader who cannot separate the two loses nothing. */
.event-row.is-warn .event-text { color: var(--warn); }
.event-row.is-ok .event-text { color: var(--ok); }

.mount-grid {
  display: grid; grid-template-columns: max-content max-content 1fr;
  gap: 6px 16px; margin: 0; align-items: baseline;
}
.mount-grid dt {
  font-size: 9px; letter-spacing: 0.16em; text-transform: uppercase;
  color: var(--dim);
}
.mount-grid dd { margin: 0; font-size: 13px; color: var(--ink); }
.mount-value { font-size: 17px; font-variant-numeric: tabular-nums; }
/* The three single-value rows run under both value columns; only the axes
   need the homing state in a column of its own. */
.mount-wide { grid-column: 2 / span 2; }
.mount-homed { font-size: 9px; letter-spacing: 0.14em; color: var(--dim); }
.mount-homed.is-homed { color: var(--ok); }
.mount-homed.is-not-homed { color: var(--warn); }

/* --- system view: power -------------------------------------------------- */

.power-note {
  margin: 0 0 10px; max-width: 52ch;
  font-size: 10px; line-height: 1.5; letter-spacing: 0.06em; color: var(--dim);
}
.power-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.power-question {
  font-size: 10px; letter-spacing: 0.14em; text-transform: uppercase;
  color: var(--warn);
}
/* pre-wrap: shutdownMessage() separates the mount caveat with a blank line,
   and that separation is the point — it is a second, independent warning. */
.power-result {
  margin: 10px 0 0; max-width: 52ch; white-space: pre-wrap;
  font-size: 10px; line-height: 1.5; letter-spacing: 0.06em; color: var(--dim);
}
.power-result.is-warn { color: var(--warn); }

/* --- joystick view ------------------------------------------------------- */

/* --- joystick view ------------------------------------------------------- */

/* A column so the stage can take the height the view has, and a three-part
   row inside it: the panels keep their own width and the pad gets the rest,
   which is what lets it grow with the window. */
.view[data-view="joystick"].is-active {
  display: flex; flex-direction: column; min-height: 0;
}
.jog-layout {
  flex: 1 1 auto; min-height: 0;
  display: flex; flex-wrap: wrap; gap: 16px; align-items: stretch;
}
.jog-panel {
  flex: 0 1 300px; min-width: 0;
  display: flex; flex-direction: column;
  padding: 14px 16px 12px;
  border: 1px solid var(--hair); border-radius: 6px;
  background: rgba(34, 211, 238, 0.025);
}
.jog-stage {
  flex: 1 1 320px; min-width: 0; min-height: 0;
  display: flex; flex-direction: column;
}
.jog-canvas-box {
  position: relative; flex: 1 1 auto; min-height: 140px; min-width: 0;
}
.jog-canvas-box > canvas {
  position: absolute; inset: 0; display: block; width: 100%; height: 100%;
}
.jog-stage .jog-canvas-box { min-height: 220px; }
#jog-pad { touch-action: none; cursor: grab; }
#jog-pad:active { cursor: grabbing; }
#jog-readout {
  flex: 0 0 auto; margin: 8px 0 0;
  text-align: center; font-size: 11px; letter-spacing: 0.08em;
  color: var(--dim); font-variant-numeric: tabular-nums;
}

.jog-now {
  display: grid; grid-template-columns: max-content 1fr;
  gap: 4px 12px; margin: 0 0 10px; align-items: baseline;
}
.jog-now dt {
  font-size: 9px; letter-spacing: 0.16em; text-transform: uppercase;
  color: var(--dim);
}
.jog-now-value {
  margin: 0; font-size: 21px; color: var(--ink);
  font-variant-numeric: tabular-nums;
}
.jog-headroom {
  margin: 0; font-size: 10px; line-height: 1.5; letter-spacing: 0.06em;
  color: var(--dim); font-variant-numeric: tabular-nums;
  white-space: pre-line;   /* one line per axis, from one text node */
}
.jog-headroom.is-near { color: var(--warn); }
.jog-sub {
  margin: 14px 0 8px; font-size: 9px; font-weight: 500;
  letter-spacing: 0.16em; text-transform: uppercase; color: var(--dim);
}
/* The track sits at the foot of its panel, mirroring the key hint at the
   foot of the other one. Both panels are as tall as the pad between them,
   so without this the left one holds its whole content at the top and
   reads as a card someone forgot to finish. */
.jog-panel .jog-sub:has(+ .jog-canvas-box) { margin-top: auto; }
/* Square, because the frame behind it is square in degrees: in a tall box
   a diagonal jog would not be drawn diagonally. */
.jog-panel .jog-canvas-box {
  flex: 0 0 auto; width: 100%; aspect-ratio: 1; min-height: 0;
}
#jog-trail { border: 1px solid var(--hair); border-radius: 4px; }
.jog-speeds { display: flex; flex-wrap: wrap; gap: 8px; }
/* The selected rate is stated by the button itself rather than by a label
   somewhere else: it decides what full deflection means, and a pad whose
   meaning is written elsewhere is a pad that gets misread. */
.jog-speeds .btn.is-selected {
  color: var(--ink); border-color: var(--cy-bright);
  background: rgba(34, 211, 238, 0.16);
}
.jog-limit {
  margin: 12px 0 0; min-height: 1.5em;
  font-size: 11px; line-height: 1.5; letter-spacing: 0.04em;
  color: var(--warn);
}
.jog-keys-hint { margin: auto 0 0; padding-top: 12px; }

/* --- disconnected ------------------------------------------------------ */

/* Values stay on screen but visibly stale. What must never happen is falling
   back to 0.00 deg: a confident zero on a machine with endstops is dangerous.
   The footer spans (HOMED/MOVING/TEMP/UART/GPS) are included here too — a
   stale footer that renders at full brightness reads as a confident state
   even when it is actually the em-dash/last-known fallback. */
body.is-disconnected .readout .value,
body.is-disconnected .readout .homed,
body.is-disconnected .foot span { opacity: 0.4; }

/* Link down: the sweep halts where it stands instead of resetting, and the
   mark goes quiet with the readouts. A paused arc says "stopped" in a way a
   hidden one cannot -- nothing there reads as nothing wrong. */
body.is-disconnected .brand-mark::after {
  animation-play-state: paused;
  opacity: 0.25;
}
body.is-disconnected .brand-mark::before { border-color: var(--hair); }
body.is-disconnected .brand-mark img { opacity: 0.45; }

/* Everything except STOP is locked out while the link is down. STOP is
   excluded from the selector itself rather than re-enabled afterwards: an
   override would silently stop protecting it the day the DOM moves the
   button. */
body.is-disconnected .view,
body.is-disconnected .btn:not(.btn-stop) {
  opacity: 0.5;
  pointer-events: none;
}

/* --- start screen -------------------------------------------------------- */

/* Above body::after (z-index 100) deliberately: the splash paints its own,
   weaker scan lines, and two rasters stacked on top of each other read as
   moire rather than as a screen. */
.splash {
  position: fixed; inset: 0; z-index: 200;
  background: var(--bg-far);
  opacity: 1;
  /* 600ms is FADE in splash.js, which waits exactly this long before it
     sets the hidden attribute. The two must agree. */
  transition: opacity 600ms ease-out;
}
.splash.is-done { opacity: 0; }

#splash-canvas { display: block; width: 100%; height: 100%; }
