/* Farmer design system: "Workshop instrument".
   A graphite machine panel: borders are the only depth, numbers lead, and one lime
   signal (the logo's) marks run / ready / now. Amber and red appear only when a
   printer needs a hand. Decisions and measurements: .interface-design/system.md.

   This file holds the shared system only. Page rules go in the page stylesheets,
   all linked from base.html: farm.css (Farm), ops.css (Queue, Library, History),
   setup.css (Printers, printer and camera pages, their dialogs).

    1 Fonts               7 Lamps, rails, state words   13 Flash, banner, empty
    2 Tokens: graphite    8 Buttons                     14 Dialogs
    3 Tokens: aluminium   9 Fields                      15 Thumbs and webcams
    4 State -> lamp      10 Panels, rack, channels      16 Motion
    5 Base and type      11 Readouts and gauge          17 Phone
    6 Layout and header  12 Tables and lists */


/* 1 · Fonts ---------------------------------------------------------------
   IBM Plex Sans Condensed 400/500/600, SIL OFL 1.1 (fonts/OFL.txt). The files are
   the unmodified upstream woff2 builds (github.com/IBM/plex); the licence reserves
   the name "Plex", so never ship a subset or edited build under this name. */
@font-face { font-family: "IBM Plex Sans Condensed"; font-style: normal; font-weight: 400; font-display: swap; src: url("fonts/IBMPlexSansCondensed-Regular.woff2") format("woff2"); }
@font-face { font-family: "IBM Plex Sans Condensed"; font-style: normal; font-weight: 500; font-display: swap; src: url("fonts/IBMPlexSansCondensed-Medium.woff2") format("woff2"); }
@font-face { font-family: "IBM Plex Sans Condensed"; font-style: normal; font-weight: 600; font-display: swap; src: url("fonts/IBMPlexSansCondensed-SemiBold.woff2") format("woff2"); }


/* 2 · Tokens: graphite panel (dark, the default) ---------------------------- */
:root {
  color-scheme: dark;

  /* Type: one condensed face with true tabular figures, so live numbers never jitter. */
  --face-plate: "IBM Plex Sans Condensed", "Arial Narrow", "Roboto Condensed", system-ui, sans-serif;
  --face-code: ui-monospace, "Cascadia Mono", Consolas, "DejaVu Sans Mono", monospace;
  --t-readout: 44px;  /* percent: readable from two metres */
  --t-gauge: 22px;    /* times, temperatures, page title */
  --t-name: 17px;     /* machine name, dialog title, wordmark */
  --t-body: 14px;
  --t-meta: 13px;     /* half step: sub-lines, help, file names in dense rows */
  --t-legend: 11px;   /* engraved legend: caps, tracked, 600 */
  --track-legend: .09em;

  /* Space: 4px base, workbench-tight. */
  --u1: 4px; --u2: 8px; --u3: 12px; --u4: 16px; --u5: 24px; --u6: 32px; --u7: 48px;
  --gutter: 24px;

  /* Shape: machined, not pillowy. key < panel < hood. */
  --r-key: 4px;
  --r-panel: 6px;
  --r-hood: 10px;
  --key-h: 40px;      /* every control that can sit next to another */
  --key-h-sm: 32px;
  --rail-w: 3px;      /* the lit status strip on top of a channel */
  --bar-h: 52px;      /* top bar */
  --dock-h: 60px;     /* phone bottom dock */
  --rack-min: 232px;  /* narrowest channel: five fit across 1280px */

  /* Motion: under 300ms, one curve, transform/opacity only (section 16). */
  --ease-out: cubic-bezier(.23, 1, .32, 1);
  --d-press: 120ms;
  --d-enter: 200ms;
  --d-exit: 150ms;

  /* Surfaces: one graphite hue, lightness steps only. */
  --frame: #0d0f10;        /* the room behind the panel: page background */
  --panel: #141719;        /* panel face: channels, panels, tables */
  --hood: #1b1f21;         /* one step up: dialogs, key hover */
  --well: #090b0c;         /* inset: inputs, gauge tracks, thumbnails */
  --seam-soft: rgb(255 255 255 / .05);
  --seam: rgb(255 255 255 / .085);
  --seam-strong: rgb(255 255 255 / .15);
  --field-edge: #646b68;   /* input outline: 3:1 on panel and hood */

  /* Ink: four levels. */
  --ink: #eceeec;          /* readouts, names */
  --ink-2: #b3b9b6;        /* supporting text */
  --ink-3: #868d8a;        /* legends, metadata: 5.3:1 on panel */
  --ink-off: #565d5a;      /* tick marks, disabled glyphs; never carries meaning alone */

  /* The one signal: the logo's lime, used as a lit lamp. */
  --signal: #b5f22a;       /* lamps, gauges, rails (graphics) */
  --signal-ink: #b5f22a;   /* lime text */
  --signal-fill: #b5f22a;  /* THE lime fill: one per view */
  --signal-edge: transparent;
  --on-signal: #0f1700;    /* text on the lime fill: 13.7:1 */
  --signal-wash: rgb(181 242 42 / .12);

  /* Semantic lamps, taken from the machine itself. */
  --kapton: #eea73f;       /* amber heat tape: paused, hold, needs a hand */
  --kapton-wash: rgb(238 167 63 / .13);
  --heater: #ff6d4d;       /* glowing heater cartridge: fault, cancel, delete */
  --heater-wash: rgb(255 109 77 / .12);
  --lamp-off: #2a2f31;     /* an unlit lamp: offline */
  --led-bar: #fff3d1;      /* the printer's light bar when on */
  --led-glow: rgb(255 243 209 / .35);

  /* Temperature traces (chart only). */
  --nozzle: #f08a4b;
  --bed: #6aa6e8;

  /* Webcam well: black in both themes (it frames a photo). */
  --cam-well: #070808;
  --cam-ink: #8f9693;

  --focus-ring: #b5f22a;
  --scrim: rgb(4 6 6 / .66);
}


/* 3 · Tokens: aluminium panel (light, follows the OS) ---------------------- */
@media (prefers-color-scheme: light) {
  :root {
    color-scheme: light;
    --frame: #e4e7e5;
    --panel: #f6f7f6;
    --hood: #ffffff;
    --well: #dde1df;
    --seam-soft: rgb(16 28 22 / .075);
    --seam: rgb(16 28 22 / .13);
    --seam-strong: rgb(16 28 22 / .24);
    --field-edge: #7a817d;
    --ink: #101312;
    --ink-2: #3a413e;
    --ink-3: #565e5a;
    --ink-off: #9ba29e;
    --signal: #4a7f00;       /* graphics: 4.5:1 on panel */
    --signal-ink: #3b6a00;   /* lime text: 6.0:1 on panel */
    --signal-fill: #b5f22a;
    --signal-edge: #4a7f00;  /* the lime fill needs an edge on white */
    --on-signal: #0f1700;
    --signal-wash: rgb(74 127 0 / .10);
    --kapton: #8f5400;
    --kapton-wash: rgb(196 125 20 / .14);
    --heater: #b92f18;
    --heater-wash: rgb(194 50 26 / .09);
    --lamp-off: #c9cecb;
    --led-bar: #101312;
    --led-glow: #f2d35c;
    --nozzle: #b4541c;
    --bed: #2a6cb8;
    --focus-ring: #3b6a00;
    --scrim: rgb(20 26 24 / .42);
  }
}


/* 4 · State -> lamp ---------------------------------------------------------
   Every status mark (lamp, state word, rail, gauge) reads these from the nearest
   [data-state] or [data-mode], so one attribute drives a whole channel:
     --lamp        colour             --lamp-r      radius (shape)
     --lamp-ink    state-word colour  --lamp-fill   fill (transparent = ring)
     --lamp-wash   halo / wash        --lamp-edge   inset ring (box-shadow)
     --rail        channel rail       --lamp-turn / --lamp-scale  diamond
     --gauge-fill  gauge pattern      --lamp-breath animation name (live states)
   Shapes, so colour never carries meaning alone: dot = running, square = held,
   ring = ready, diamond = fault, dim = no signal.
   Order matters: data-mode rules come before the data-state rules that refine them
   (a printer in mode "idle" can be in state "error"). */
:where([data-state], [data-mode]) {
  --lamp: var(--lamp-off); --lamp-ink: var(--ink-3); --lamp-wash: transparent;
  --lamp-r: 50%; --lamp-fill: initial; --lamp-edge: none; --lamp-turn: 0deg; --lamp-scale: 1;
  --lamp-breath: none; --rail: var(--lamp-off); --gauge-fill: initial;
}
/* Running: printing, sending, starting; "live" = the hub clock. */
:where([data-mode="printing"], [data-mode="sending"],
       [data-state="printing"], [data-state="sending"], [data-state="starting"], [data-state="live"]) {
  --lamp: var(--signal); --lamp-ink: var(--signal-ink); --lamp-wash: var(--signal-wash);
  --lamp-breath: lampbreath; --rail: var(--signal);
}
/* Not printing yet: the rail is lit at half strength. */
:where([data-mode="sending"], [data-state="sending"], [data-state="starting"]) {
  --rail: color-mix(in srgb, var(--signal) 45%, transparent);
}
/* Held. */
:where([data-mode="paused"], [data-state="paused"]) {
  --lamp: var(--kapton); --lamp-ink: var(--kapton); --lamp-wash: var(--kapton-wash);
  --lamp-r: 2px; --rail: var(--kapton);
  --gauge-fill: repeating-linear-gradient(135deg, var(--kapton) 0 4px, color-mix(in srgb, var(--kapton) 40%, transparent) 4px 7px);
}
/* Ready: idle, finished and cancelled printers all take the next job. */
:where([data-mode="idle"],
       [data-state="idle"], [data-state="standby"], [data-state="complete"],
       [data-state="completed"], [data-state="cancelled"]) {
  --lamp: var(--signal); --lamp-ink: var(--ink); --lamp-fill: transparent;
  --lamp-edge: inset 0 0 0 2px var(--signal);
  --rail: repeating-linear-gradient(90deg, var(--signal) 0 10px, transparent 10px 16px);
}
/* No signal. */
:where([data-mode="offline"], [data-state="offline"]) {
  --lamp: var(--lamp-off); --lamp-ink: var(--ink-3); --lamp-edge: inset 0 0 0 1px var(--seam-strong);
  --rail: var(--lamp-off);
}
/* A scan result that answered but whose actual print state is unknown (see app.js
   lampFor(): Moonraker's "ready" means Klipper initialized, not idle/free). Neutral
   on purpose -- never the "idle" ring above, which this app's own vocabulary
   reserves for "free now". */
:where([data-state="reachable"]) {
  --lamp: var(--ink-3); --lamp-fill: var(--ink-3); --lamp-ink: var(--ink-2);
}
/* Waiting in the queue (jobs only). */
:where([data-state="queued"]) {
  --lamp: var(--ink-3); --lamp-ink: var(--ink-2); --lamp-fill: transparent;
  --lamp-edge: inset 0 0 0 1.5px var(--ink-3); --rail: var(--ink-off);
}
/* Fault: last, so it wins over a printer's "idle" mode. */
:where([data-state="error"], [data-state="failed"]) {
  --lamp: var(--heater); --lamp-ink: var(--heater); --lamp-wash: var(--heater-wash);
  --lamp-r: 1px; --lamp-turn: 45deg; --lamp-scale: .86; --rail: var(--heater);
  --lamp-fill: initial; --lamp-edge: none;
}


/* 5 · Base and type ---------------------------------------------------------- */
*, *::before, *::after { box-sizing: border-box; }
/* Anything toggled with the `hidden` attribute stays hidden, whatever display a
   class sets later (app.js toggles el.hidden). */
[hidden] { display: none !important; }
/* Tab/Shift+Tab scrolls the focused element to the viewport edge with no padding
   of its own, so the sticky top bar (and, on phone, the fixed bottom dock) can
   cover it completely (WCAG 2.2 2.4.11 Focus Not Obscured). */
html { -webkit-text-size-adjust: 100%; background: var(--frame); scroll-padding-top: calc(var(--bar-h) + var(--u3)); }
body {
  margin: 0; min-height: 100vh;
  background: var(--frame); color: var(--ink);
  font: 400 var(--t-body)/1.45 var(--face-plate);
  font-variant-numeric: tabular-nums;
  font-synthesis-weight: none;  /* 600 is the heaviest cut: never fake a bold */
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}
a { color: inherit; text-decoration: none; }
b, strong, th { font-weight: 600; }
button, input, select, textarea { font: inherit; color: inherit; }
::selection { background: color-mix(in srgb, var(--signal) 35%, transparent); }
:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }

/* Off-screen until focused: a keyboard user's first Tab stop on every page, so the
   nav's own links (and, on the Farm page, the headline before the first channel)
   aren't the only way past the topbar. */
.skip-link {
  position: absolute; top: -40px; left: var(--u3); z-index: 100;
  padding: var(--u2) var(--u4); border-radius: var(--r-key);
  background: var(--panel); color: var(--ink); font-weight: 600;
  box-shadow: 0 0 0 1px var(--seam);
}
.skip-link:focus-visible { top: var(--u3); }

h1 {
  margin: 0; font-size: var(--t-gauge); font-weight: 600; line-height: 1.1;
  letter-spacing: -.01em; text-wrap: balance;
}
/* Section titles speak in the engraved-legend voice. */
h2 {
  margin: 0 0 var(--u3); font-size: var(--t-legend); font-weight: 600; line-height: 1.3;
  letter-spacing: var(--track-legend); text-transform: uppercase; color: var(--ink-2);
}
p { text-wrap: pretty; }

.icon { width: 1em; height: 1em; flex: none; vertical-align: -.125em; }

.small { font-size: var(--t-meta); }
.muted { color: var(--ink-3); }
.mono { font-family: var(--face-code); font-size: var(--t-meta); }
.addr { color: var(--ink-2); font-variant-numeric: tabular-nums; }  /* IPs and ports, set in the plate face */
.ellipsis { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.name { font-weight: 600; }
.vh {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
/* The engraved panel legend: the label voice everywhere. */
.legend {
  font-size: var(--t-legend); font-weight: 600; line-height: 1.2;
  letter-spacing: var(--track-legend); text-transform: uppercase; color: var(--ink-3);
}
.link {
  display: inline-flex; align-items: center; gap: var(--u1);
  color: var(--ink-2); font-size: var(--t-meta); font-weight: 500;
  transition: color var(--d-press) var(--ease-out);
}
.link:hover { color: var(--ink); }
.ev-warning { color: var(--kapton); }
.ev-error { color: var(--heater); }


/* 6 · Layout and header ------------------------------------------------------ */
main {
  max-width: 1280px; margin: 0 auto;
  padding: var(--u5) var(--gutter) var(--u7);
}
.page-head {
  display: flex; align-items: flex-end; justify-content: space-between;
  flex-wrap: wrap; gap: var(--u4); margin-bottom: var(--u5);
}
.page-sub { margin: 6px 0 0; color: var(--ink-3); font-size: var(--t-meta); }
.toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: var(--u2); }
.row { display: flex; align-items: center; justify-content: space-between; gap: var(--u2); }
.section { margin-top: var(--u6); }
.section-head {
  display: flex; align-items: center; justify-content: space-between;
  gap: var(--u3); margin-bottom: var(--u3);
}
.section-head h2 { margin: 0; }
/* Main column + side column. minmax(0, …) lets a wide table scroll inside its
   .table-wrap instead of widening the page. */
.split { display: grid; gap: var(--u5); grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); }
@media (max-width: 860px) { .split { grid-template-columns: minmax(0, 1fr); } }

/* Top bar: wordmark, nav keys, hub clock. Same --frame as the page: one room.
   The bar itself is full-bleed (background + seam span the viewport); its content
   sits in the same centred, max-1280 column as `main` (.topbar-inner), so the
   wordmark/nav and the page content always share one left edge. */
.topbar {
  position: sticky; top: 0; z-index: 20;
  height: var(--bar-h);
  background: var(--frame); border-bottom: 1px solid var(--seam);
}
.topbar-inner {
  display: flex; align-items: center; gap: var(--u5);
  max-width: 1280px; height: 100%; margin: 0 auto; padding: 0 var(--gutter);
}
.brand {
  display: inline-flex; align-items: center; gap: 9px; flex: none;
  font-size: var(--t-name); font-weight: 600; letter-spacing: -.01em;
}
.brand-logo { display: block; width: 24px; height: 24px; flex: none; }
.nav { display: flex; gap: 2px; }
.nav a {
  position: relative; display: inline-flex; align-items: center; gap: 7px;
  height: 36px; padding: 0 var(--u3); border-radius: var(--r-key);
  color: var(--ink-3); font-weight: 500; white-space: nowrap;
  transition: color var(--d-press) var(--ease-out), background-color var(--d-press) var(--ease-out);
}
.nav a .icon { width: 16px; height: 16px; }
.nav a:hover { color: var(--ink); }
.nav a[aria-current="page"] { color: var(--ink); background: var(--panel); }
/* "You are here": a lit strip on the bar's bottom seam under the active key. */
.nav a[aria-current="page"]::after {
  content: ""; position: absolute; left: var(--u3); right: var(--u3); bottom: -8px;
  height: 2px; border-radius: 1px; background: var(--signal);
}
.nav-count {
  display: inline-grid; place-items: center;
  min-width: 18px; height: 18px; padding: 0 5px; border-radius: 3px;
  background: var(--well); box-shadow: inset 0 0 0 1px var(--seam);
  color: var(--ink-2); font-size: var(--t-legend); font-weight: 600; line-height: 1;
}
.topbar-end { margin-left: auto; display: flex; align-items: center; gap: var(--u3); }
/* Settings' own key in the bar: the same "you are here" treatment .nav a
   already gets (panel fill + a lit strip on the bar's bottom seam), not a
   plain icon with no active state at all (design-review: on Settings,
   nothing in the bar used to say so). */
.topbar-end .btn.ghost[aria-current="page"] { position: relative; color: var(--ink); background: var(--panel); border-color: transparent; }
/* ::before, not ::after: the gear is also a .btn.square.sm, whose own ::after
   is the touch hit-area extension (inset: -4px, all four sides) -- an active-
   strip rule on that same ::after only had higher specificity for the sides
   it actually set (left/right/bottom/height), so `top` fell back to the hit-
   area rule's -4px and the strip floated near the top edge instead of sitting
   on the bar's bottom seam like the nav keys' own (design-review). A second,
   unrelated pseudo-element sidesteps the specificity fight entirely. */
.topbar-end .btn.ghost[aria-current="page"]::before {
  content: ""; position: absolute; left: 6px; right: 6px; bottom: -8px; height: 2px; border-radius: 1px; background: var(--signal);
}
/* The hub clock is the "now" every free-at time is read against; its lamp says
   whether live updates (the poller) are running. */
.hub-clock { display: inline-flex; align-items: center; gap: var(--u2); color: var(--ink-2); font-weight: 500; }


/* 7 · Lamps, rails, state words ---------------------------------------------- */
.lamp {
  position: relative; display: inline-block; flex: none; vertical-align: middle;
  width: 10px; height: 10px; border-radius: var(--lamp-r, 50%);
  background: var(--lamp-fill, var(--lamp, var(--lamp-off)));
  box-shadow: var(--lamp-edge, none);
  rotate: var(--lamp-turn, 0deg); scale: var(--lamp-scale, 1);
}
.lamp.sm { width: 8px; height: 8px; }
/* A reading, not a printer state (e.g. an automatic fan's on/off): never borrows
   --signal or the live/breathing halo, which both mean "take a look". */
.lamp.neutral { --lamp: var(--ink-off); --lamp-fill: transparent; --lamp-edge: inset 0 0 0 1.5px var(--ink-off); --lamp-wash: transparent; --lamp-breath: none; }
.lamp.neutral.on { --lamp-fill: var(--ink-3); --lamp-edge: none; }
/* The state word: lamp + one word (the app's own label), in the legend voice. */
.state {
  position: relative; display: inline-flex; align-items: center; gap: 7px;
  font-size: var(--t-legend); font-weight: 600; line-height: 1.2;
  letter-spacing: var(--track-legend); text-transform: uppercase; white-space: nowrap;
  color: var(--lamp-ink, var(--ink-3));
}
.state::before {
  content: ""; flex: none; width: 8px; height: 8px; border-radius: var(--lamp-r, 50%);
  background: var(--lamp-fill, var(--lamp, var(--lamp-off)));
  box-shadow: var(--lamp-edge, none);
  rotate: var(--lamp-turn, 0deg); scale: var(--lamp-scale, 1);
}
/* Halo: a pseudo-element whose opacity breathes while the state is live. */
.lamp::after, .state::after {
  content: ""; position: absolute; border-radius: var(--lamp-r, 50%);
  box-shadow: 0 0 0 3px var(--lamp-wash, transparent);
  pointer-events: none;
}
.lamp::after { inset: 0; }
.state::after {
  left: 0; top: 50%; width: 8px; height: 8px; margin-top: -4px;
  rotate: var(--lamp-turn, 0deg); scale: var(--lamp-scale, 1);
}
/* A row of lamps in a well: one per printer, the farm at a glance. */
.lamp-row {
  display: inline-flex; align-items: center; gap: 6px; padding: 6px 9px;
  border-radius: 999px; background: var(--well); box-shadow: inset 0 0 0 1px var(--seam);
}
/* Small labelled marker. "Free now" is an outline with lime text, never a fill. */
.chip {
  display: inline-flex; align-items: center; gap: 6px; height: 22px; padding: 0 var(--u2);
  border-radius: 3px; border: 1px solid var(--seam-strong);
  color: var(--ink-2); font-size: var(--t-meta); font-weight: 600; white-space: nowrap;
}
.chip.free { border-color: var(--signal); color: var(--signal-ink); }


/* 8 · Buttons (keys) ---------------------------------------------------------
   .btn          secondary: outlined, ink text
   .btn.primary  THE lime fill: one per view (Resume, Add all, Save)
   .btn.danger   outlined, heater text (Cancel, Remove)
   .btn.ghost    no outline until hover (row tools, close)
   .btn.sm       32px; hit area extended to 40 (44 on phone) by ::after
   .btn.square   icon-only, square (needs aria-label)
   .btn.light    printer light key: lit when aria-pressed="true" / .on */
.btn {
  position: relative; appearance: none;
  display: inline-flex; align-items: center; justify-content: center; gap: 6px;
  height: var(--key-h); padding: 0 14px; border-radius: var(--r-key);
  border: 1px solid var(--seam-strong); background: transparent; color: var(--ink);
  font-size: var(--t-body); font-weight: 500; line-height: 1; white-space: nowrap;
  cursor: pointer; user-select: none;
  transition: background-color var(--d-press) var(--ease-out), border-color var(--d-press) var(--ease-out),
    color var(--d-press) var(--ease-out), transform var(--d-press) var(--ease-out);
}
.btn .icon { width: 16px; height: 16px; }
.btn:hover:not(:disabled) { background: var(--hood); border-color: var(--field-edge); }
.btn:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
.btn.primary {
  background: var(--signal-fill); color: var(--on-signal); border-color: var(--signal-edge); font-weight: 600;
}
.btn.primary:hover:not(:disabled) {
  background: color-mix(in srgb, var(--signal-fill) 88%, #fff); border-color: var(--signal-edge);
}
.btn.danger { color: var(--heater); }
.btn.ghost { border-color: transparent; color: var(--ink-2); }
.btn.ghost:hover:not(:disabled) { color: var(--ink); background: var(--hood); border-color: transparent; }
.btn.ghost.danger { color: var(--heater); }
/* Touch has no :hover, so a ghost key (Farm presets, Library "+ Queue"/"Hide
   printed", History "Print again", ...) otherwise shows no key shape at all
   until pressed -- a quiet outline gives it one without the hover wash. */
@media (hover: none) {
  .btn.ghost:not(:disabled) { border-color: var(--seam); }
}
.btn:disabled, .btn[aria-disabled="true"] {
  color: var(--ink-3); background: transparent; border-color: var(--seam); cursor: default;
}
.btn.ghost:disabled { border-color: transparent; }
/* A disabled danger key (e.g. "Delete 0 other files") must look off like every
   other disabled key, never keep its full heater red -- .btn.danger/.btn.ghost.danger
   above otherwise out-specifies the generic :disabled rule just above this. */
.btn.danger:disabled, .btn.ghost.danger:disabled { color: var(--ink-3); }
.btn.sm { height: var(--key-h-sm); padding: 0 10px; font-size: var(--t-meta); }
.btn.sm .icon { width: 14px; height: 14px; }
.btn.square { width: var(--key-h); padding: 0; }
.btn.square.sm { width: var(--key-h-sm); }
.btn.sm::after { content: ""; position: absolute; inset: -4px 0; }
.btn.square.sm::after { inset: -4px; }
/* Printer light key. */
.btn.light { color: var(--ink-3); }
.btn.light:is(.on, [aria-pressed="true"]) { color: var(--led-bar); background: var(--hood); border-color: var(--seam-strong); }
.btn.light:is(.on, [aria-pressed="true"]) .icon { fill: var(--led-glow); }
.btn.light.pending { border-style: dashed; }
/* A button wrapped in a form lines up like a bare one. */
form.inline { display: inline-flex; margin: 0; }

/* A control on its way (a command sent, waiting for the printer to confirm; see
   app.js's climate submit handler): a dashed outline plus the same opacity pulse
   as the light key's .pending, instead of the native disabled look alone, which
   reads as "not allowed" rather than "under way". Kept at full opacity/ink (not
   the generic :disabled dimming) so the value stays easy to read while it waits. */
.btn.busy { border-style: dashed; }
:is(input, select):disabled.busy { opacity: 1; color: var(--ink); border-style: dashed; }
@media (prefers-reduced-motion: no-preference) {
  :is(input, select):disabled.busy { animation: pending 1.2s ease-in-out infinite; }
}


/* 9 · Fields ------------------------------------------------------------------
   Inputs are wells: darker than the panel (they receive), with a 3:1 outline. */
input:where(:not([type=checkbox], [type=radio], [type=file], [type=range], [type=hidden], [type=color])),
select, textarea {
  max-width: 100%; min-width: 0;
  height: var(--key-h); padding: 0 var(--u3); border-radius: var(--r-key);
  background: var(--well); color: var(--ink);
  border: 1px solid var(--field-edge);
  font: 500 var(--t-body)/1.2 var(--face-plate); font-variant-numeric: tabular-nums;
  transition: border-color var(--d-press) var(--ease-out);
}
textarea { height: auto; min-height: 88px; padding: var(--u2) var(--u3); line-height: 1.45; }
:is(input, select, textarea):hover:not(:disabled) { border-color: var(--ink-3); }
:is(input, select, textarea):focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 1px; }
:is(input, select, textarea):disabled { color: var(--ink-2); opacity: .7; }
::placeholder { color: var(--ink-3); opacity: 1; }
input[type=search] { -webkit-appearance: none; appearance: none; }
/* Select: the native list, drawn with our own chevron. */
select {
  -webkit-appearance: none; appearance: none; padding-right: 32px; cursor: pointer;
  background-image:
    linear-gradient(45deg, transparent 50%, var(--ink-2) 50%),
    linear-gradient(135deg, var(--ink-2) 50%, transparent 50%);
  background-position: calc(100% - 17px) 52%, calc(100% - 12px) 52%;
  background-size: 5px 5px, 5px 5px;
  background-repeat: no-repeat;
}
input.sm, select.sm { height: var(--key-h-sm); font-size: var(--t-meta); }
input[type=checkbox], input[type=radio] {
  width: 18px; height: 18px; margin: 0; flex: none; accent-color: var(--signal); cursor: pointer;
}
/* A checkbox that stores a setting the user chose, not printer/selection state
   (the notification event switches, "Stay signed in"): the same reasoning as
   the range sliders just below -- lime is reserved for "something needs a
   hand", and a column of checked boxes otherwise reads as the page's biggest
   lime mass, outweighing the one real primary action above it (design-review).
   File-selection checkboxes (printer_files.html) and the mid-print no-JS
   confirm ones (printer_detail.html) are deliberately left --signal: picking
   rows, or confirming a risky change, is exactly the "take a look" lime is for. */
.prefs-form input[type=checkbox], .auth-card input[type=checkbox] { accent-color: var(--ink-2); }
/* Range (the Temperatures & fans panel's fan sliders): neutral instrument styling,
   not --signal -- a fan percentage is a setting, not printer state, and lime is
   reserved for "something needs a hand" (a slider at 80% draws the single largest
   lime mass on the page otherwise, several with Advanced fans open). */
input[type=range] { width: 100%; height: var(--key-h-sm); margin: 0; accent-color: var(--ink-2); cursor: pointer; background: transparent; }
input[type=range]:disabled { cursor: default; opacity: .6; }
input[type=range]:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; border-radius: var(--r-key); }
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: var(--u4); }
.field label { font-size: var(--t-meta); font-weight: 500; color: var(--ink-2); }
.field .help { font-size: var(--t-meta); color: var(--ink-3); }
/* Checkbox fields read as one line: box, then its label; help below. */
.field:has(> input[type=checkbox]) { flex-direction: row; flex-wrap: wrap; align-items: center; column-gap: var(--u2); }
.field:has(> input[type=checkbox]) > input { order: -1; }
.field:has(> input[type=checkbox]) > :is(.help, .errorlist) { flex-basis: 100%; }
.errorlist { margin: 0; padding: 0; list-style: none; color: var(--heater); font-size: var(--t-meta); }
/* A field marked invalid (_field.html: `f.errors`, or a caller's own
   `force_invalid`) gets the same heater outline every other fault does --
   generic enough that the standalone sign-in page (app.css only, no
   setup.css) needs it too, not just the setup/scan forms. */
.field.invalid > :is(input, select, textarea) { border-color: var(--heater); }
/* A non-field error (login.html's own wrong-password message) sits right
   under the field it's about with no gap otherwise -- same spacing as every
   field's own `.help`/`.errorlist` line. */
.form-errors .errorlist { margin-bottom: var(--u4); }
/* Input with a leading icon. */
.search-field { position: relative; display: inline-flex; align-items: center; }
.search-field .icon { position: absolute; left: var(--u3); width: 16px; height: 16px; color: var(--ink-3); pointer-events: none; }
.search-field input { padding-left: 36px; }


/* 10 · Panels, rack, channels ------------------------------------------------- */
.panel { background: var(--panel); border: 1px solid var(--seam); border-radius: var(--r-panel); }
.panel-head {
  display: flex; align-items: center; justify-content: space-between; gap: var(--u3);
  min-height: 48px; padding: var(--u3) var(--u4); border-bottom: 1px solid var(--seam-soft);
}
.panel-head h2 { margin: 0; }
.panel-body { padding: var(--u4); }

/* The rack: one channel per printer, any count. Channels auto-fit (5 across at
   1280px, 1 on a phone); each spans --bands rows of the rack through subgrid, so
   the same reading sits at the same height in every channel of a row. */
.rack {
  --bands: 5;
  display: grid; align-items: stretch; column-gap: var(--u3);
  grid-template-columns: repeat(auto-fill, minmax(min(100%, var(--rack-min)), 1fr));
}
.channel {
  position: relative; min-width: 0; overflow: hidden;
  display: grid; grid-row: span var(--bands); grid-template-rows: subgrid; row-gap: 0;
  margin-bottom: var(--u3); padding-top: var(--rail-w);
  background: var(--panel); border: 1px solid var(--seam); border-radius: var(--r-panel);
}
@supports not (grid-template-rows: subgrid) {
  .channel { display: flex; flex-direction: column; }
}
/* The status rail: a lit strip along the top, readable across the room. */
.channel::before {
  content: ""; position: absolute; inset: 0 0 auto; height: var(--rail-w);
  background: var(--rail, var(--lamp-off));
}
/* Bands are separated by 16px of space, never by seams. */
.band { min-width: 0; padding: var(--u4) var(--u4) 0; }
.band:last-child { padding-bottom: var(--u4); }


/* 11 · Readouts and gauge ----------------------------------------------------- */
.readout { font-size: var(--t-readout); font-weight: 600; line-height: .95; letter-spacing: -.02em; }
.readout small { margin-left: 1px; font-size: var(--t-gauge); font-weight: 500; letter-spacing: 0; color: var(--ink-2); }
.reading { font-size: var(--t-gauge); font-weight: 600; line-height: 1.1; letter-spacing: -.01em; }
.reading small { font-size: var(--t-meta); font-weight: 500; letter-spacing: 0; color: var(--ink-3); }
/* Readout pair: legend above a 22px value (old markup puts the value first). */
.stats { display: flex; flex-wrap: wrap; align-items: flex-end; gap: var(--u6); }
.stat { display: flex; flex-direction: column; gap: var(--u1); }
.stat > b { font-size: var(--t-gauge); font-weight: 600; line-height: 1; letter-spacing: -.01em; }
.stat > b .muted { font-size: var(--t-body); font-weight: 500; letter-spacing: 0; }
.stat > span:not([data-sum]), .stat > .legend {
  order: -1; font-size: var(--t-legend); font-weight: 600; line-height: 1.2;
  letter-spacing: var(--track-legend); text-transform: uppercase; color: var(--ink-3);
}
/* Gauge: progress in the lamp colour (hatched when held). Set the fill with
   style="--p:62" (or an inline width). .ticked adds a tick every 10%. */
.gauge {
  position: relative; height: 6px; border-radius: 2px;
  background: var(--well); box-shadow: inset 0 0 0 1px var(--seam);
}
.gauge > i {
  position: absolute; inset: 0 auto 0 0; display: block;
  width: calc(var(--p, 0) * 1%); max-width: 100%; border-radius: inherit;
  background: var(--gauge-fill, var(--lamp, var(--signal)));
}
.gauge.ticked { margin-bottom: 7px; }
.gauge.ticked::after {
  content: ""; position: absolute; left: 0; right: 0; top: 9px; height: 3px;
  background: repeating-linear-gradient(90deg, var(--ink-off) 0 1px, transparent 1px 10%);
}


/* 12 · Tables and lists ------------------------------------------------------- */
.table-wrap { overflow-x: auto; }
.table { width: 100%; border-collapse: collapse; font-size: var(--t-body); }
.table th {
  height: 40px; padding: 0 var(--u4); text-align: left; white-space: nowrap;
  font-size: var(--t-legend); font-weight: 600; letter-spacing: var(--track-legend);
  text-transform: uppercase; color: var(--ink-3); border-bottom: 1px solid var(--seam);
}
.table td { height: 52px; padding: var(--u2) var(--u4); vertical-align: middle; border-bottom: 1px solid var(--seam-soft); }
.table tr:last-child td { border-bottom: 0; }
/* The action column: right-aligned, never wraps, never hidden. */
.table .end { text-align: right; white-space: nowrap; }
.table .num { text-align: right; }
/* Controls taller than a text line set the line box (so the cell centres them);
   state words centre on the text line. */
.table td :is(.btn, select, input:not([type=checkbox]), form.inline) { vertical-align: top; }
.table td .state { vertical-align: middle; }
.table td > .thumb { display: block; }
.list { list-style: none; margin: 0; padding: 0; }
.list li {
  display: flex; align-items: center; justify-content: space-between; gap: var(--u3);
  min-height: 44px; padding: var(--u2) 0; border-top: 1px solid var(--seam-soft);
}
.list li:first-child { border-top: 0; }
/* A row list used for both the Farm page's Activity log and the printer page's
   Recent jobs/Activity (printer_detail.html): shared here rather than in farm.css,
   a page file, so the printer page isn't reaching into the Farm page's sheet. */
.rows { list-style: none; margin: 0; padding: 0; }
.rows li { display: grid; align-items: center; gap: var(--u3); min-height: 48px; padding: 0 var(--u4); }
.rows li + li { border-top: 1px solid var(--seam-soft); }
.log-rows li { grid-template-columns: 44px 10px minmax(0, 1fr); min-height: 40px; font-size: var(--t-meta); }
.log-rows time { color: var(--ink-3); font-weight: 500; }
.log-rows .m { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink-2); }
/* The lamp carries the state; only a failure turns the words heater. */
.log-rows .m.ev-warning { color: var(--ink-2); }
.log-rows .m.ev-error { color: var(--heater); }
.log-rows .m b { margin-right: 6px; color: var(--ink); }


/* 13 · Flash, banner, empty --------------------------------------------------- */
.flashes { display: flex; flex-direction: column; gap: var(--u2); margin-bottom: var(--u4); }
.flash {
  position: relative; padding: var(--u3) var(--u4) var(--u3) 19px;
  background: var(--panel); border: 1px solid var(--seam); border-radius: var(--r-panel);
  overflow: hidden; font-size: var(--t-body);
  --lamp: var(--ink-3);
}
.flash::before { content: ""; position: absolute; inset: 0 auto 0 0; width: var(--rail-w); background: var(--lamp); }
.flash.success { --lamp: var(--signal); }
.flash.warning { --lamp: var(--kapton); }
.flash.error { --lamp: var(--heater); }
/* Hub-wide notice under the top bar (poller not running). */
.banner {
  display: flex; align-items: center; justify-content: center; gap: var(--u2);
  padding: var(--u2) var(--gutter); font-size: var(--t-meta); text-align: left;
  background: var(--kapton-wash); border-bottom: 1px solid color-mix(in srgb, var(--kapton) 35%, transparent);
}
/* Empty state: a marked well, one sentence, the way forward. */
.empty {
  display: flex; flex-direction: column; align-items: center; gap: var(--u3);
  padding: var(--u7) var(--u5); text-align: center; color: var(--ink-2);
}
.empty > .icon:first-child {
  width: 56px; height: 56px; padding: 14px; color: var(--ink-3);
  background: var(--well); border: 1px solid var(--seam-soft); border-radius: var(--r-panel);
}
.empty-title { margin: 0; font-size: var(--t-name); font-weight: 600; color: var(--ink); }
.empty p { margin: 0; max-width: 52ch; }
.empty .toolbar { justify-content: center; margin-top: var(--u1); }
/* A smaller empty state for a panel rather than a whole page (printer_detail.html's
   offline/no-climate panels, the camera/printer "nothing here" wells, ...). Shared
   here, not redefined per page file (previously duplicated verbatim in ops.css and
   setup.css as .empty.compact / .setup-empty.compact). */
.empty.compact { padding: var(--u6) var(--u5); gap: var(--u2); }
.empty.compact > .icon:first-child { width: 44px; height: 44px; padding: 11px; }
.empty.compact p:not(.empty-title) { font-size: var(--t-meta); }


/* 14 · Dialogs (native <dialog>; app.js opens and closes them) -----------------
   .dlg-head (title + close) · .dlg-body (scrolls) · .dlg-foot (actions band; sticky
   when it sits inside a form in .dlg-body). The closed state must stay display:none:
   an author `display` rule always beats the UA's. */
dialog { --dlg-pad: var(--u5); }
dialog:not([open]) { display: none; }
dialog[open] {
  display: flex; flex-direction: column;
  width: min(600px, calc(100vw - 32px)); max-height: min(86vh, 760px);
  margin: auto; padding: 0;
  background: var(--hood); color: var(--ink);
  border: 1px solid var(--seam-strong); border-radius: var(--r-hood);
}
dialog::backdrop { background: var(--scrim); }
/* The scan dialog grows as rows arrive (app.js); centred with margin: auto above,
   that growth moves its top edge (and every row/button in it) up and down under
   the pointer while results are still arriving. Anchor it near the top instead, so
   it only grows downward. */
dialog.scan-dlg[open] { margin-top: min(12vh, 120px); margin-bottom: auto; }
/* Shared confirm dialog (base.html, app.js askConfirm()): narrower than the
   default 600px -- it is a sentence and two keys, never a form to scroll. */
dialog.confirm-dlg[open] { width: min(420px, calc(100vw - 32px)); max-height: none; }
.confirm-dlg .dlg-body p { margin: 0; color: var(--ink-2); }
/* The title carries a real file name too (data-confirm-title, set per form)
   with no break opportunities of its own -- `anywhere` lets a long one wrap
   instead of running past the dialog's padding and getting cut at its edge.
   The message itself (data-confirm) is kept free of file names on purpose
   now (see [data-confirm-name] below), so this no longer needs to cover it. */
.confirm-dlg .dlg-head h2 { overflow-wrap: anywhere; }
/* A file name worth naming (data-confirm-name, askConfirm() in app.js): its
   own line rather than folded into the sentence above, which used to break
   it mid-word with nowhere sensible to wrap ("…PLA_white_0.12m / m_N4Max_
   9h40m.gcode", design-review) -- clips with an ellipsis instead, same as
   every other file name in the app, with the full name in its own title. */
.confirm-dlg [data-confirm-name] { margin-top: var(--u2); color: var(--ink); }
.dlg-head {
  flex: none; display: flex; align-items: center; justify-content: space-between; gap: var(--u3);
  padding: var(--u3) var(--u4) var(--u3) var(--dlg-pad); min-height: 60px;
  border-bottom: 1px solid var(--seam);
}
.dlg-head h2 {
  margin: 0; font-size: var(--t-name); font-weight: 600; letter-spacing: -.005em;
  text-transform: none; color: var(--ink);
}
.dlg-x { width: var(--key-h-sm); padding: 0; }
.dlg-x .icon { width: 16px; height: 16px; }
.dlg-body { padding: var(--dlg-pad); overflow-y: auto; scroll-padding-top: 8px; }
/* Tabbing to a field scrolls it to the bottom of this scroll area, which the
   sticky .dlg-foot below then sits on top of -- give it room to clear it. */
.dlg-body:has(.dlg-foot) { scroll-padding-bottom: calc(var(--key-h) + var(--u3) + var(--dlg-pad) + 9px); }
.dlg-foot {
  flex: none; display: flex; align-items: center; justify-content: flex-end; gap: var(--u2);
  padding: var(--u3) var(--dlg-pad); background: var(--hood); border-top: 1px solid var(--seam);
}
/* Inside a scrolling form: pinned to the bottom of the visible area, full width. */
.dlg-body:has(.dlg-foot) { padding-bottom: 0; }
.dlg-body .dlg-foot {
  position: sticky; bottom: 0; z-index: 1;
  margin: var(--u4) calc(-1 * var(--dlg-pad)) 0; padding-bottom: var(--dlg-pad);
}
/* Rows built by app.js (scan results, printers added by IP). */
.edit-row { display: flex; align-items: center; gap: var(--u2); }
.edit-row input { flex: 1; }
[data-scan], .edit-row .btn { min-width: 80px; }
[data-added-list] { margin-bottom: var(--u4); }
.added-tag { display: inline-flex; align-items: center; gap: 6px; color: var(--ink-3); }
.added-tag .icon { color: var(--signal-ink); }


/* 15 · Thumbs and webcams ------------------------------------------------------ */
.thumb {
  width: 48px; height: 48px; flex: none; object-fit: contain;
  border-radius: var(--r-key); background: var(--well);
}
.thumb.placeholder { display: grid; place-items: center; color: var(--ink-3); }
.thumb.placeholder .icon { width: 18px; height: 18px; }
/* .cam: a 16:9 snapshot band; .live: the full camera view on detail pages. */
.cam, .live { position: relative; display: block; overflow: hidden; aspect-ratio: 16 / 9; background: var(--cam-well); }
.cam img, .live img { display: block; width: 100%; height: 100%; }
.cam img { object-fit: cover; }
/* The default outward ring (:focus-visible, section 5) is clipped by this and the
   containing .channel/.farmcam, which both also clip overflow: pull it inside. */
.cam:focus-visible { outline-offset: -3px; }
.live { border-radius: var(--r-panel); }
.live img { object-fit: contain; }
/* Loading: shown until the first frame arrives (.loaded, never removed again) or
   the camera gives up. Most .cam/.camtile tiles are removed outright once they give up
   (farm.css .farmcam:not(:has(.cam))::before is their placeholder); the farm page's one
   big camera (.farmcam.lg .cam, farm.css) and every .live instead get .dead -- sized to
   show a real .live-empty panel (below) rather than vanish, and worth retrying. */
.cam::before, .live::before {
  content: "Connecting to camera…"; position: absolute; inset: 0;
  display: grid; place-items: center; padding: var(--u2);
  color: var(--cam-ink); font-size: var(--t-meta); text-align: center;
}
.cam:has(.loaded)::before, .live:has(.loaded)::before, .cam.dead::before, .live.dead::before { content: none; }
/* Fallback for a dead .live with no .live-empty panel of its own (printer_detail.html's
   .pd-cam, camera_form.html's .cam-preview; camera_detail.html's own panel, right
   below, covers that page). Without this, a dead webcam there is left an empty
   black 16:9 box with no text once app.js's refreshSnap() adds .dead. */
.live.dead:not(:has([data-f="live-dead"]))::after {
  content: "Camera not reachable"; position: absolute; inset: 0;
  display: grid; place-items: center; padding: var(--u2);
  color: var(--cam-ink); font-size: var(--t-meta); text-align: center;
}
/* .live-empty (data-f="live-dead"): the same icon-well + title + body + actions
   pattern as every other empty state (.empty, section 13), filling the box the
   dead camera's own <img> would otherwise leave black. .cam/.live stay dark in
   both themes (--cam-well/--cam-ink, a video feed's own background, already used
   for "Connecting to camera..." right before this) -- so is this, rather than
   flipping to a light panel the instant it gives up, right under an unrelated
   question. */
.live-empty {
  position: absolute; inset: 0; justify-content: center;
  background: var(--cam-well); color: var(--cam-ink);
  /* --signal-fill is the one token that stays the bright lime in both themes (see
     section 3): the default --focus-ring's light-theme value is tuned for a light
     panel and would barely show against this always-dark box. */
  --focus-ring: var(--signal-fill);
}
.live-empty .empty-title { color: var(--cam-ink); }
.live-empty > .icon:first-child { background: rgb(255 255 255 / .06); border-color: rgb(255 255 255 / .12); }
.live-empty .btn { color: var(--cam-ink); border-color: rgb(255 255 255 / .18); }
.live-empty .btn:hover:not(:disabled) { background: rgb(255 255 255 / .08); border-color: rgb(255 255 255 / .28); }
/* Thumbnails and webcam frames fade in once loaded (app.js adds .loaded). */
.fade-img { opacity: 0; transition: opacity 240ms var(--ease-out); }
.fade-img.loaded { opacity: 1; }


/* 16 · Motion ------------------------------------------------------------------
   Under 300ms, cubic-bezier(.23,1,.32,1), transform and opacity only. Movement is
   authored inside no-preference; under reduced motion the safety net at the end
   collapses everything to an instant change (animationend still fires, which the
   dialog close in app.js waits for). Loops (lamp breath, pending) are opacity only. */
/* Dialog: a fade in both modes (base), plus a small scale where motion is welcome. */
dialog[open] { animation: fadeIn var(--d-enter) var(--ease-out); }
dialog.closing { animation: fadeOut var(--d-exit) var(--ease-out) forwards; }
dialog[open]::backdrop { animation: fadeIn var(--d-enter) var(--ease-out); }
dialog.closing::backdrop { animation: fadeOut var(--d-exit) var(--ease-out) forwards; }
@media (prefers-reduced-motion: no-preference) {
  dialog[open] { animation-name: hoodIn; }
  dialog.closing { animation-name: hoodOut; }
  .btn:active:not(:disabled) { transform: scale(.97); }
  .lamp::after, .state::after { animation: var(--lamp-breath, none) 2.4s ease-in-out infinite; }
  .btn.light.pending .icon, .btn.busy { animation: pending 1.2s ease-in-out infinite; }
  .flash { animation: flashIn var(--d-enter) var(--ease-out); }
  [data-scan-out] li, [data-added-list] li {
    animation: rowIn 240ms var(--ease-out) backwards;
    animation-delay: calc(var(--stagger, 0) * 50ms);
  }
  .spin { animation: spin .9s linear infinite; }
}
@keyframes fadeIn { from { opacity: 0; } }
@keyframes fadeOut { to { opacity: 0; } }
@keyframes hoodIn { from { opacity: 0; transform: scale(.97); } }
@keyframes hoodOut { to { opacity: 0; transform: scale(.98); } }
@keyframes lampbreath { 50% { opacity: .2; } }
@keyframes pending { 50% { opacity: .4; } }
@keyframes flashIn { from { opacity: 0; transform: translateY(-4px); } }
@keyframes rowIn { from { opacity: 0; transform: translateY(4px); } }
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: .001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: .001ms !important;
    scroll-behavior: auto !important;
  }
}


/* 17 · Phone --------------------------------------------------------------------
   Board-first, 16px gutters, 44px touch targets; nav becomes a bottom dock. */
@media (max-width: 760px) {
  :root { --gutter: 16px; --key-h: 44px; }
  dialog { --dlg-pad: var(--u4); }
  /* .dlg-head's right padding is a fixed --u4; on desktop that plus the close
     button's own 8px inner space happens to equal --dlg-pad (24px), lining its
     glyph up with .dlg-body's content edge, but --dlg-pad shrinks to --u4 here
     without the head's padding following it, leaving the glyph 8px short. */
  .dlg-head { padding-right: calc(var(--dlg-pad) - 8px); }
  /* The fixed bottom dock (below) covers the same focus-scroll edge the top bar
     does on desktop -- see the html rule in section 5. */
  html { scroll-padding-bottom: calc(var(--dock-h) + env(safe-area-inset-bottom) + var(--u3)); }
  .topbar-inner { gap: var(--u3); }
  .nav {
    position: fixed; z-index: 20; left: 0; right: 0; bottom: 0; gap: 0;
    height: calc(var(--dock-h) + env(safe-area-inset-bottom));
    padding: 0 var(--u1) env(safe-area-inset-bottom);
    background: var(--frame); border-top: 1px solid var(--seam);
  }
  .nav a {
    flex: 1; min-width: 0; flex-direction: column; justify-content: center; gap: var(--u1);
    height: var(--dock-h); padding: 0; border-radius: 0;
    font-size: var(--t-legend); font-weight: 600; letter-spacing: .02em;
  }
  .nav a .icon { width: 20px; height: 20px; }
  .nav a[aria-current="page"] { background: transparent; }
  .nav a[aria-current="page"]::after { top: -1px; bottom: auto; left: 28%; right: 28%; }
  .nav-count { position: absolute; top: 6px; left: calc(50% + 6px); min-width: 16px; height: 16px; padding: 0 4px; }
  main { padding: var(--u4) var(--gutter) calc(var(--dock-h) + var(--u6) + env(safe-area-inset-bottom)); }
  .page-head { align-items: flex-start; }
  .btn.sm::after { inset: -6px 0; }
  .btn.square.sm::after { inset: -6px; }
  dialog[open] { width: calc(100vw - 16px); max-height: calc(100dvh - 32px); }
  /* The desktop rule above anchors the scan dialog at min(12vh, 120px) from the top
     *in addition to* this max-height, so on a short phone screen the two margins
     stack past 100dvh and push the footer ("Add all N") off-screen once enough rows
     arrive (a dialog fixed in the top layer, so scrolling the page can't reach it).
     Anchor it at the same small inset as every other dialog on phones instead. */
  dialog.scan-dlg[open] { margin-top: var(--u4); }
  .table th, .table td { padding-left: var(--u3); padding-right: var(--u3); }
  .table td, .table th { white-space: nowrap; }
  .hide-sm { display: none; }
  /* Rows/cells (table th/td above, .board-grid) sit at the 12px (--u3) inset on
     phones; every other panel's head and body stayed at the desktop 16px (--u4),
     so a head's legend sat 4px right of its own content below it. One inset on
     phones, for every panel shape (not just the ones with a table or the board):
     the head, the plain padded body, and the row list that carries its own
     padding instead of sitting inside a .panel-body. (setup.css's own phone block
     does the same for .readings/.facts/.facts-note, defined there.) */
  .panel-head, .panel-body, .rows li {
    padding-left: var(--u3); padding-right: var(--u3);
  }
}


/* 18 · Standalone pages (registration/login.html, farm/display_forbidden.html,
   farm/offline.html) -- no topbar, no base.html: nothing here has (or, for
   offline.html, can rely on) a session, so there is nothing for the nav/
   sign-out chrome to do, and no farm data these pages could safely show
   (design-review + access: offline.html used to extend base.html and leak
   exactly that to an anonymous visitor on the public link). Just one centred
   card, the same `.panel` surface and one `.btn.primary` fill every other
   page uses. See .interface-design/system.md -> "Sign-in page". */
.auth {
  display: flex; align-items: center; justify-content: center;
  min-height: 100vh; padding: var(--u5) var(--gutter);
}
.auth-card { width: min(100%, 360px); padding: var(--u5); }
.auth-brand {
  display: flex; align-items: center; gap: var(--u2); margin-bottom: var(--u3);
  font-size: var(--t-name); font-weight: 600;
}
/* The one heading-like thing on the page used to be the wordmark itself (no
   <h1> at all, design-review) -- a real title, under it and smaller, so the
   page still reads "brand, then what this is" rather than promoting the logo
   to a heading it was never meant to be. */
.auth-title { margin: 0 0 var(--u5); font-size: var(--t-gauge); }
.auth-note-block { margin: 0 0 var(--u4); }
.auth-note { margin: 0 0 var(--u2); color: var(--ink-2); font-size: var(--t-meta); }
/* The createsuperuser command, in a well (app.css's own inset surface) like
   any other "type this" readout, not plain inline text at body weight --
   it's the one thing on a first-run visit that actually does something.
   `tabindex="0"` (login.html) plus `user-select: all` (one tap/click selects
   the whole command, nothing more) make it easy to copy without a mouse drag. */
.auth-cmd {
  margin: 0; padding: var(--u3); background: var(--well); border-radius: var(--r-key);
  white-space: nowrap; overflow-x: auto; user-select: all;
}
/* The sign-in page's own first-run command: wraps instead of scrolling off
   the edge with no visible sign that it does (design-review -- at both 390
   and 1280 the un-wrapped command cut off mid-word, "...manage.py createsu").
   Narrower than .auth-cmd's own well, so a long flag/path still breaks at a
   readable point rather than mid-character. display_forbidden.html reuses
   the base `.auth-cmd` well styling above for its own (much shorter) kiosk
   command and is deliberately left scrolling, not wrapped -- that panel has
   no scroll affordance to recover a wrapped word with the login page's own
   gutter around it to wrap into (see that page's own `.empty .mono` rule). */
body[data-page="login"] .auth-cmd { white-space: pre-wrap; overflow-wrap: anywhere; overflow-x: visible; }
.auth-submit { width: 100%; justify-content: center; margin-top: var(--u2); }
/* The usual fieldset reset (border/margin/padding a bare <fieldset> adds):
   this must change nothing about the fields' own layout, only make them
   disable as a group (login.html, first-run state). */
.auth-fields { margin: 0; padding: 0; border: 0; min-width: 0; }
.auth-note-block .toolbar { margin-top: var(--u3); }
/* display_forbidden.html reuses .empty (app.css section 13) inside the same
   card; its own icon/title/text sizing is unaffected by the auth styling above. */
.auth-card.empty { width: min(100%, 420px); }
/* display_forbidden.html only ever shows on the 480x320 kiosk panel display.html
   itself answers for (spec 011/012) -- same "always dark" rule (an unattended
   panel bolted next to the printers must not go bright just because the Pi's
   desktop happens to be light) and no pointer to show a cursor for. Token
   values copied from display.css's own "Forced theme" section (not shared via
   a stylesheet link: that file's board/tile rules have nothing to do with this
   page) -- keep the two in sync if those change. */
body[data-page="display-forbidden"] {
  cursor: none;
  color-scheme: dark;
  --frame: #0d0f10; --panel: #141719; --hood: #1b1f21; --well: #090b0c;
  --seam-soft: rgb(255 255 255 / .05); --seam: rgb(255 255 255 / .085); --seam-strong: rgb(255 255 255 / .15);
  --field-edge: #646b68;
  --ink: #eceeec; --ink-2: #b3b9b6; --ink-3: #868d8a; --ink-off: #565d5a;
  --signal: #b5f22a; --signal-ink: #b5f22a; --signal-fill: #b5f22a; --signal-edge: transparent; --on-signal: #0f1700;
  --signal-wash: rgb(181 242 42 / .12);
  --kapton: #eea73f; --kapton-wash: rgb(238 167 63 / .13);
  --heater: #ff6d4d; --heater-wash: rgb(255 109 77 / .12);
  --lamp-off: #2a2f31;
  --focus-ring: #b5f22a;
}
/* `scripts/setup-kiosk.sh`, `docs/pi-display.md` and `?key=...` must never
   break mid-word at their own hyphens/slashes (design-review) -- the panel
   has no scroll to recover a word that did. */
body[data-page="display-forbidden"] .empty .mono { white-space: nowrap; }
/* The card at its *real* size (320px tall, not a desktop preview of it): the
   generic `.empty.compact` (section 13) is still too roomy here, so this page
   gets its own, tighter pass -- smaller well, tighter gaps -- instead of
   letting the card's own bottom run off the panel with nothing to scroll it
   into view with. */
@media (max-height: 320px) {
  body[data-page="display-forbidden"] .auth { padding: var(--u3) var(--u4); }
  body[data-page="display-forbidden"] .auth-card.empty { padding: var(--u4); gap: var(--u2); }
  body[data-page="display-forbidden"] .auth-card.empty > .icon:first-child { width: 32px; height: 32px; padding: 8px; }
  body[data-page="display-forbidden"] .empty-title { font-size: var(--t-body); }
  body[data-page="display-forbidden"] .empty p { font-size: var(--t-meta); }
  body[data-page="display-forbidden"] .auth-cmd { padding: var(--u2); }
}

