/* style.css — ARC teleop webapp.
   NorthStar light theme, periwinkle accent, Rubik, card layout —
   the same visual language as the desktop launcher (operator/launcher.py) and
   the one the monitoring spec (docs/superpowers/specs/
   2026-08-03-monitoring-routing-design.md §I) says to extend rather than
   replace.

   File shape (Task 9's design pass):
     1. Tokens        — palette (unchanged) + new scale/shape/shadow tokens
     2. Base          — reset, body, focus floor
     3. Components    — .view, .brand, .card, .panel, buttons, .status-dot
     4. Views         — login, queue, monitor, info, landing, log, HUD
     5. Responsive    — the monitor grid is the only layout that needs it
   Add new rules to the section they belong to. Anything copy-pasted a third
   time belongs in §3. */

@font-face {
  font-family: "Rubik";
  src: url("./fonts/Rubik-Regular.woff2") format("woff2");
  font-weight: 400;
  font-display: swap;
}

@font-face {
  font-family: "Rubik";
  src: url("./fonts/Rubik-Medium.woff2") format("woff2");
  font-weight: 500 600;
  font-display: swap;
}

@font-face {
  font-family: "Rubik";
  src: url("./fonts/Rubik-Bold.woff2") format("woff2");
  font-weight: 700;
  font-display: swap;
}

/* ═══ 1. Tokens ═══════════════════════════════════════════════════════════
   The palette block is verbatim from the pre-Task-9 file and from spec §I —
   do not restate these hues anywhere else in this file, and do not add a
   second accent. Everything below the palette is new: derived values that
   were previously copy-pasted (shadows, focus washes) and the scales the
   grid / queue / status components needed. */
:root {
  /* Palette — spec §I, unchanged. */
  --bg:        #F5F6FA;  /* page background      */
  --surface:   #FFFFFF;  /* card / header        */
  --border:    #DDE1EE;  /* soft blue-grey border*/
  --accent:    #6B7FD4;  /* periwinkle primary   */
  --accent-ho: #8595DC;  /* periwinkle hover     */
  --green:     #2A9D72;  /* running / connected  */
  --yellow:    #C08A2A;  /* warning              */
  --red:       #C94F4F;  /* error / disconnected */
  --gray:      #B0BAD0;  /* inactive             */
  --text:      #1A2340;  /* dark navy primary    */
  --text-dim:  #7A89A8;  /* secondary label      */
  --text-faint: #97A2C2; /* tertiary label, lighter than --text-dim */
  --panel-tint: #EEF0F8; /* pale panel background, e.g. operator setup section */
  --badge-text: #8B96C0; /* muted badge text (e.g. "SOON") */

  /* Derived from --accent. These three rgba() values appeared inline in four
     places before; they are the same periwinkle at three strengths. */
  --accent-wash: rgba(107, 127, 212, 0.18); /* focus ring on inputs      */
  --accent-ring: rgba(107, 127, 212, 0.28); /* selected-tile ring        */
  --accent-tint: rgba(107, 127, 212, 0.08); /* hover fill, chips         */

  /* Robot modes (robotMode.js, the tile banner / session mode chip). They
     extend the palette instead of re-colouring it: policy is the existing
     accent, held is the existing --yellow (amber, hollow where flagged is
     solid), operator is the one new hue. */
  --purple:      #8A5BC7;                   /* operator in control       */
  --purple-tint: rgba(138, 91, 199, 0.12);
  --yellow-tint: rgba(192, 138, 42, 0.12);

  /* Elevation. One navy-tinted shadow at three depths, never a black one —
     a neutral shadow reads grey against --bg and flattens the cards. */
  --shadow-card:   0 1px 3px rgba(26, 35, 64, 0.06);
  --shadow-raised: 0 2px 8px rgba(26, 35, 64, 0.10);
  --shadow-bar:    0 -2px 10px rgba(26, 35, 64, 0.10);

  --font: "Rubik", Consolas, ui-monospace, monospace;
  --mono: "Rubik", Consolas, "Cascadia Mono", ui-monospace, "SFMono-Regular", monospace;

  /* Type scale. One family (spec §I forbids a second face), so hierarchy has
     to come from size/weight/tracking — which means the sizes have to be a
     scale and not ad hoc. 11px is the floor: the pre-pass file had 8px and
     8.3px labels that are not readable on a headset browser at arm's length,
     which is where half this app is used. */
  --fs-micro: 0.6875rem; /* 11px — section labels, badges, notes  */
  --fs-label: 0.75rem;   /* 12px — form labels, metadata          */
  --fs-body:  0.8125rem; /* 13px — buttons, body copy             */
  --fs-input: 0.9375rem; /* 15px — input text, robot ids          */
  --fs-lead:  1rem;      /* 16px — primary buttons, card subject  */
  --fs-title: 1.375rem;  /* 22px — the one h1                     */
  --tracking-label: 0.08em; /* uppercase micro-labels only */

  /* Spacing — 4px base. Use these, not new magic numbers. */
  --sp-1: 0.25rem;
  --sp-2: 0.5rem;
  --sp-3: 0.75rem;
  --sp-4: 1rem;
  --sp-5: 1.5rem;
  --sp-6: 2rem;
  --sp-7: 3rem;

  /* Shape */
  --r-sm:   0.45rem;  /* inputs, small buttons */
  --r-md:   0.5rem;   /* buttons              */
  --r-lg:   0.6rem;   /* cards, panels        */
  --r-pill: 999px;    /* status chips         */

  /* Layout shells. The narrow column is a form/list reading width. The
     monitor wall (.view-wide, see §4) has no width cap of its own: its tiles
     are 32:9, so the window's height, not a shell width, is what should
     bound them. */
  --shell:      30rem;
  --shell-pad:  1.25rem;

  /* Monitor grid. --monitor-cols is written by monitorDashboard.js from the
     actual number of assigned streams (falling back to max_streams only if
     that's somehow empty) — never hardcode a column count here. --tile-min
     is the width below which a tile stops being scannable and the grid
     should reflow instead (see §5); the exo is half the tile, so it is a
     16rem exo. --tile-aspect is the whole tile: wrist L, the exo, wrist R in
     one row, the exo 16:9 at the tile's full height and each wrist 16:9 at
     half the exo's width (so 8 + 16 + 8 wide by 9 tall, 32:9). The exo is
     the same size it would be with no wrists at all; the wrists sit
     centred beside it with the card's surface above and below them. Every
     feed fills its slot at its own shape, with no crop and no letterbox.
     These two are the default "side" layout's; #monitorGrid sets its own
     for the "under" one (wrists under the exo, see §4).
     --monitor-chrome is the height
     the wall must leave free in the window: the page above it (brand,
     divider, section label: about 12rem) plus the stop bar fixed at the
     bottom (about 12.5rem with all three groups on one row). #monitorGrid
     sizes its tiles to fit the rest. */
  --tile-aspect: 32 / 9;
  --tile-min: 32rem;
  --monitor-chrome: 25rem;

  color-scheme: light;
}

/* ═══ 2. Base ════════════════════════════════════════════════════════════ */
* { box-sizing: border-box; }
body {
  margin: 0;
  background: var(--bg);
  color: var(--text);
  font-family: var(--font);
  font-size: var(--fs-body);
  line-height: 1.5;
}

/* Quality floor: every interactive element gets the same visible keyboard
   ring, in the accent, without also drawing it on mouse click. Individual
   components may thicken it, none may remove it. */
:where(a, button, input, select, [tabindex]):focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

a { color: var(--accent); }

/* Author `display` always beats the UA sheet's `[hidden] { display: none }`,
   so any component styled `display: flex` ignores its own hidden attribute.
   That is how the monitor's stop bar came to render permanently — it is
   `display: flex`, and the JS that hides it only sets `.hidden = true`. Every
   view-toggle and resting state in this app is driven by that attribute, so
   the rule is global rather than per-component. */
[hidden] { display: none !important; }

/* ═══ 3. Components ══════════════════════════════════════════════════════ */

/* ── .view — the page shell ──
   Every view but one is a single reading column; #monitorDashboard opts into
   .view-wide. This replaced four identical id-keyed copies of the same three
   declarations. */
.view {
  max-width: var(--shell);
  margin: 0 auto;
  padding: 0 var(--shell-pad) var(--sp-6);
}

/* ── .brand — Northstar logo + NORTHSTAR | ARC lockup + tagline, on every view ──
   (Was id="brand" repeated five times, which is invalid HTML; the styling is
   unchanged, only the selector is now legal.) The lockup copies the console's
   .brand (console/src/app/globals.css): navy wordmark, hairline, ARC in ink. */
.brand { text-align: center; padding: var(--sp-6) 0 var(--sp-3); }
.brand .logo { display: block; width: 48px; height: 38px; margin: 0 auto; object-fit: contain; }
.brand h1 {
  margin: var(--sp-2) 0 0;
  font-size: var(--fs-title); font-weight: 600; color: #232B66;
  letter-spacing: -0.02em;
}
.brand h1 .arc {
  font-weight: 500; color: var(--text);
  padding-left: 12px; margin-left: 12px;
  border-left: 1px solid var(--border);
}
.brand .tagline { margin: var(--sp-1) 0 0; font-size: var(--fs-label); color: var(--text-dim); }
/* The tagline is "<page> · <who>" on every signed-in view. Naming the operator
   in the accent is the one place the app tells you whose session this is —
   there is no sign-out affordance, so it has to be legible. */
.brand .tagline span { color: var(--accent); font-weight: 600; }

.divider { height: 1px; background: var(--border); margin: 0 calc(-1 * var(--shell-pad)) var(--sp-5); }

/* ── .card — the surface idiom ──
   Every raised container in the app: the two forms, the dispatch card, a
   queue row, a camera tile. Was copy-pasted six times before this pass. */
.card {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--r-lg);
  box-shadow: var(--shadow-card);
}

/* ── .panel — the recessed grouping idiom ──
   The counterpart to .card: tinted and inset rather than raised, for grouping
   actions under a .section-label (operator setup, session). */
.panel {
  background: var(--panel-tint);
  border: 1px solid var(--border);
  border-radius: var(--r-lg);
  padding: var(--sp-4);
  margin-bottom: var(--sp-5);
}

/* ── .section-label — the structural eyebrow ──
   Marks a group, never decorates one. Uppercase + tracked so it reads as a
   label at 11px rather than as small body copy. */
.section-label {
  font-size: var(--fs-micro);
  color: var(--text-faint);
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  margin: 0 0 var(--sp-2);
}

/* ── Forms ── */
.form-card {
  padding: var(--sp-5);
  display: flex; flex-direction: column; gap: var(--sp-5);
}
.form-card label {
  display: flex; flex-direction: column; gap: var(--sp-2);
  font-size: var(--fs-label); font-weight: 600; color: var(--text-dim);
}
.form-card input, .form-card select {
  padding: var(--sp-2) var(--sp-3);
  border-radius: var(--r-sm); border: 1px solid var(--border);
  background: var(--bg); color: var(--text);
  font-size: var(--fs-input); font-family: var(--font);
}
.form-card input:focus, .form-card select:focus {
  outline: none;
  border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-wash);
}

.field-error { color: var(--red); font-size: var(--fs-label); }

/* ── Buttons ──
   One base + four intents. The base deliberately sets no width or margin:
   layout belongs to the container (.btn-block opts into full width), which is
   what let the per-view button rules below collapse to a colour choice. */
.btn {
  display: inline-flex; align-items: center; justify-content: center; gap: var(--sp-2);
  padding: var(--sp-3) var(--sp-4);
  border: 1px solid transparent; border-radius: var(--r-md);
  font: 600 var(--fs-body) var(--font);
  cursor: pointer;
  transition: background-color 0.12s ease, border-color 0.12s ease, opacity 0.12s ease;
}
.btn-block { width: 100%; }
.btn-lg { padding: var(--sp-3) var(--sp-4); font-size: var(--fs-lead); }
.btn-sm { padding: var(--sp-2) var(--sp-3); border-radius: var(--r-sm); font-size: var(--fs-micro); }

/* Primary: the expected next step. One per view, at most. */
.btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); }
.btn-primary:hover:not(:disabled) { background: var(--accent-ho); border-color: var(--accent-ho); }
/* A disabled primary goes grey rather than translucent-periwinkle: "Enter VR"
   is disabled whenever the browser has no WebXR, which is most of the time on
   a desk, and a faded blue slab still reads as the thing you are meant to
   press. Both Enter VR buttons (#enterVr, #dashEnterVr) share this. */
.btn-primary:disabled { background: var(--border); border-color: var(--border); color: var(--gray); opacity: 1; }

/* Quiet: everything else. Reads as a surface, not as a call to action. */
.btn-quiet { background: var(--surface); color: var(--text); border-color: var(--border); }
.btn-quiet:hover:not(:disabled) { border-color: var(--accent); background: var(--accent-tint); }

/* Danger: reserved for the two buttons in this app that change the world
   outside the browser — the monitor's Stop (freezes a moving robot) and the
   operator's release-and-exit (drops it out of the queue). Nothing else may
   be red; that scarcity is the whole signal. */
.btn-danger { background: var(--red); color: #fff; border-color: var(--red); font-weight: 700; }
.btn-danger:hover:not(:disabled) { opacity: 0.88; }

/* Danger-quiet: a destructive action that is not the page's subject (a queue
   row's Remove). Red word on a surface, not a red slab. */
.btn-danger-quiet { background: var(--surface); color: var(--red); border-color: var(--border); }
.btn-danger-quiet:hover:not(:disabled) { border-color: var(--red); background: rgba(201, 79, 79, 0.06); }

.btn:disabled { opacity: 0.5; cursor: not-allowed; }

/* ── .status-dot / .status-chip — the status vocabulary ──
   One component, three surfaces: a camera tile's overlay bar, a queue row,
   and the info page's dispatch label. The state names are robots.status
   verbatim (spec §E) so the class in the DOM always matches the value the
   control plane returned — no translation table to keep in sync.

     online     green   — streaming, nobody driving
     flagged    yellow  — a monitor escalated it; it is in the operator queue
     operating  accent  — an operator has it
     completed  grey    — just released, transient
     offline    grey
     error      red     — unreachable / freeze unconfirmed

   The bare .status-dot (no modifier) is green, which is what app.js's inline
   '<span class="status-dot"></span>dispatched' relies on. */
.status-dot {
  display: inline-block; flex: none;
  width: 0.45rem; height: 0.45rem;
  border-radius: 50%;
  background: var(--green);
  margin-right: var(--sp-1);
}
.status-dot.online    { background: var(--green); }
.status-dot.flagged   { background: var(--yellow); }
.status-dot.operating { background: var(--accent); }
.status-dot.completed { background: var(--gray); }
.status-dot.offline   { background: var(--gray); }
.status-dot.error     { background: var(--red); }
/* data_collection — a monitor ASSIGNED this robot for recording. Deliberately
   not a new hue: §1 forbids a second accent, and every remaining palette slot
   already means something louder than this state does (yellow = escalated,
   red = broken). It is periwinkle like `operating`, because both mean "this
   robot is an operator's to drive" — but hollow rather than solid, because
   nobody is driving it yet. Shape carries the difference the palette can't,
   and the chip says the word anyway. */
.status-dot.data_collection {
  background: transparent;
  box-shadow: inset 0 0 0 2px var(--accent);
}
/* Legacy aliases: app.js writes `pending` inline, and older markup used
   active/idle. Kept so nothing has to be rewritten to move to the names
   above; prefer the semantic names in new code. */
.status-dot.pending { background: var(--yellow); }
.status-dot.active  { background: var(--accent); }
.status-dot.idle    { background: var(--gray); }

/* Dot + word, for surfaces where the colour alone is not enough context —
   a queue row says "flagged" out loud, a tile's bar does not need to because
   every tile on that page shares the vocabulary and the wall is scanned by
   colour. Colour is never the only carrier of meaning here. */
.status-chip {
  display: inline-flex; align-items: center; gap: var(--sp-1);
  padding: 0.1rem var(--sp-2) 0.1rem var(--sp-1);
  border-radius: var(--r-pill);
  background: var(--panel-tint);
  font-size: var(--fs-micro); font-weight: 600; letter-spacing: 0.02em;
  color: var(--text-dim);
}
.status-chip.flagged   { color: var(--yellow); background: rgba(192, 138, 42, 0.10); }
.status-chip.operating { color: var(--accent); background: var(--accent-tint); }
.status-chip.online    { color: var(--green);  background: rgba(42, 157, 114, 0.10); }
.status-chip.error     { color: var(--red);    background: rgba(201, 79, 79, 0.08); }
/* Dimmer than .operating on purpose — an assignment is standing information,
   not a live session, and it should not out-shout a robot someone is holding. */
.status-chip.data_collection { color: var(--text-dim); background: var(--accent-tint); }

/* ── .tag-* — the episode quality vocabulary ──
   Same contract as .status-chip above: colour is never the only carrier. The
   word ("bad" / "medium" / "good") is inside the <select> itself, so these
   classes only tint the control that already says it out loud. Three tokens
   that already exist, in the order a reader expects them (red → yellow →
   green); no new hues. */
.tag-select {
  padding: var(--sp-1) var(--sp-2);
  border-radius: var(--r-sm);
  border: 1px solid var(--border);
  background: var(--surface);
  font: 600 var(--fs-micro) var(--font);
  color: var(--text-dim);
  cursor: pointer;
}
.tag-select.tag-bad    { color: var(--red);    border-color: var(--red);    background: rgba(201, 79, 79, 0.06); }
.tag-select.tag-medium { color: var(--yellow); border-color: var(--yellow); background: rgba(192, 138, 42, 0.08); }
.tag-select.tag-good   { color: var(--green);  border-color: var(--green);  background: rgba(42, 157, 114, 0.08); }
/* An in-flight PATCH: the tag on screen is the one we asked for, not yet the
   one the server confirmed. Says so quietly rather than blocking the control. */
.tag-select.is-saving { opacity: 0.6; }

.badge-soon {
  font-size: var(--fs-micro); background: var(--surface); color: var(--badge-text);
  padding: 0.1rem var(--sp-2); border-radius: var(--r-pill); font-weight: 700;
  border: 1px solid var(--border);
}

/* ── .view-nav — the page-level verb bar ──
   Sits between the divider and the first .section-label on the two operator
   queues. Left slot is "where else you can go", right slot is "leave" — the
   split is the affordance, so it survives even when a page grows a third
   destination. Deliberately in the flow rather than fixed like
   .monitor-logout: these views are a 30rem column with a real top edge, and a
   floating button would hover over the row cards for nothing. */
.view-nav {
  display: flex; gap: var(--sp-2); align-items: center; justify-content: space-between;
  margin: calc(-1 * var(--sp-2)) 0 var(--sp-5);
}

.fallback-link { text-align: center; margin: var(--sp-3) 0 0; }
.fallback-link a { font-size: var(--fs-micro); color: var(--text-dim); text-decoration-color: var(--border); }
.fallback-link a:hover { color: var(--accent); }

/* Resting state for an empty list. Dashed, never a card: an empty queue is
   the absence of work, not a thing. */
.empty-state {
  padding: var(--sp-6) var(--sp-4);
  text-align: center;
  font-size: var(--fs-label); color: var(--text-dim);
  border: 1px dashed var(--border); border-radius: var(--r-lg);
}

/* ═══ 4. Views ═══════════════════════════════════════════════════════════ */

/* ── Login ──
   The two buttons are the whole design problem on this page: they submit
   identical fields and differ only in the role asserted. Stacked (not
   side-by-side) with the operator button primary, because operator is the
   overwhelmingly common case and a 50/50 split would pretend otherwise. */
#signInButtons {
  margin: var(--sp-4) 0 0;
  display: flex; flex-direction: column; gap: var(--sp-2);
}
#loginError { margin-top: calc(-1 * var(--sp-3)); }

/* ── Queue (operator landing) ──
   One card per flagged robot, oldest first: id and how long it has waited on
   the left, the three actions on the right. Wraps to two lines on a narrow
   headset browser rather than squeezing the buttons. */
.queue-row {
  padding: var(--sp-3) var(--sp-4);
  margin-bottom: var(--sp-2);
  display: flex; flex-wrap: wrap; gap: var(--sp-3);
  align-items: center; justify-content: space-between;
}
.queue-row.is-busy { opacity: 0.55; }
.queue-meta { min-width: 9rem; }
.queue-robot { font-size: var(--fs-input); font-weight: 700; color: var(--text); }
/* Status and wait time on one line: the chip says what state the robot is in,
   the time says how urgent it is. Both are the reason this row exists. */
.queue-status { display: flex; align-items: center; gap: var(--sp-2); margin-top: var(--sp-1); }
.queue-since { font-size: var(--fs-micro); color: var(--text-dim); }
.queue-actions { display: flex; gap: var(--sp-1); margin-left: auto; }
#queueError { margin-top: var(--sp-2); }

/* ── Data collection queue ──
   Two lists on one page. The robot list is the intervention queue's layout
   verbatim (.queue-row and friends) because it is the same object with fewer
   verbs — a second near-identical row class is how the two pages would drift
   apart. Only the episode list below it is new, and it is a log, not a queue:
   flatter, denser, and its one control is a judgement rather than an action. */
#dcQueueError { margin-top: var(--sp-2); }
/* The assignment itself, under the robot id. Only the DC queue's rows have one
   — an intervention row's subject is the incident, not a task. */
.queue-task { font-size: var(--fs-micro); color: var(--text-dim); margin-top: var(--sp-1); }
/* Space between "assigned robots" and "recent episodes" — two subjects, and at
   the default label gap the second one reads as part of the first list. Keyed
   off #dcQueueError because that node always sits directly before the episodes
   label, hidden or not ([hidden] removes it from flow, not from the sibling
   axis). */
#dcQueueError + .section-label { margin-top: var(--sp-6); }

.episode-row {
  padding: var(--sp-2) var(--sp-3);
  margin-bottom: var(--sp-1);
  display: flex; flex-wrap: wrap; gap: var(--sp-2);
  align-items: center; justify-content: space-between;
}
.episode-meta { min-width: 9rem; }
/* Robot id leads, task is the thing that distinguishes two episodes from the
   same robot, so it gets its own line rather than being appended to the id. */
.episode-robot { font-size: var(--fs-label); font-weight: 700; color: var(--text); }
.episode-task { font-size: var(--fs-micro); color: var(--text-dim); }
.episode-when { font-size: var(--fs-micro); color: var(--text-faint); margin-left: auto; }
.episode-tag { display: flex; align-items: center; gap: var(--sp-2); }

.pagination {
  display: flex; align-items: center; justify-content: center;
  gap: var(--sp-3); margin-top: var(--sp-3);
}
.pagination-label { font-size: var(--fs-micro); color: var(--text-dim); }

/* ── Monitor dashboard (camera-tile wall) ──
   Wider than every other view: the others are forms and lists that read best
   in a narrow column, this one is a wall of video whose whole job is to be
   scannable at a glance from a desk. It takes the whole window width less
   --shell-pad. It used to stop at 72rem, which was fine for 16:9 tiles, but a
   32:9 tile is limited by width long before height: one robot at 1920x1080
   got a 576px exo out of 72rem and gets a 940px one now. #monitorGrid's
   height cap is what keeps the wall a sensible size on a big window.
   Bottom padding leaves room for the fixed stop bar so the last row of
   tiles is never covered by it. */
.view-wide { max-width: none; }
/* Clears the fixed stop bar (≈3.75rem tall, more when it wraps on a phone) so
   the last row of tiles is never sitting underneath it. */
#monitorDashboard { padding-bottom: 7rem; }

/* The wall fits the window's height, not just its width: with the view as
   wide as the window, a 32:9 tile the full width of a 1920x1080 window is
   already 529px tall, so two rows of them run off the bottom long before
   the wall runs out of width. So the grid's width is capped
   at what lets --monitor-rows rows fit in the window less --monitor-chrome:
   each row gets (that height - the gaps) / rows, a tile is that times
   --tile-aspect wide, and the wall is --monitor-cols of those plus the gaps.
   Centred when the cap bites; the cap never widens anything, so a wide
   short window still never scrolls sideways.
   Never below --tile-min, though: with many robots on a short window an
   exo that small stops being worth looking at, and the wall scrolls
   instead. Both counts are written by monitorDashboard.js (gridColumns /
   gridRows); the defaults are the old 2-column wall's. */
#monitorGrid {
  display: grid;
  grid-template-columns: repeat(var(--monitor-cols, 2), minmax(0, 1fr));
  gap: var(--sp-3);
  --fit-row-h: calc((100vh - var(--monitor-chrome) - (var(--monitor-rows, 1) - 1) * var(--sp-3)) / var(--monitor-rows, 1));
  --fit-tile-w: max(var(--tile-min), var(--fit-row-h) * var(--tile-aspect));
  max-width: calc(var(--monitor-cols, 2) * var(--fit-tile-w) + (var(--monitor-cols, 2) - 1) * var(--sp-3));
  margin-inline: auto;
}
#monitorError { margin-top: var(--sp-2); }

/* One row: wrist L, the exo, wrist R, the same left-to-right order as the
   headset HUD. The exo's column is twice a wrist's (1fr 2fr 1fr), which at
   --tile-aspect makes it 16:9 at the tile's full height; each wrist slot
   is 16:9 at its column's width and centred in it vertically. The exo's
   overlays (placeholder, ping, REC, bar) are absolutely positioned children
   placed in the exo grid area, so that area, not the whole tile, is their
   containing block: they sit over the exo and never over a wrist. The
   1px gap shows the card's surface as a hairline between the feeds. */
.monitor-tile {
  position: relative; overflow: hidden; cursor: pointer;
  padding: 0;
  aspect-ratio: var(--tile-aspect);
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) minmax(0, 1fr);
  grid-template-rows: minmax(0, 1fr);
  grid-template-areas: "wristL exo wristR";
  align-items: center;
  gap: 1px;
  transition: border-color 0.12s ease, box-shadow 0.12s ease;
}
.monitor-tile-canvas,
.monitor-tile-msg,
.monitor-tile-ping,
.monitor-tile-bar,
.monitor-tile-rec { grid-area: exo; }
.monitor-tile:hover { border-color: var(--accent); }
.monitor-tile:focus-visible { outline-offset: 3px; }
/* The selected tile is the one the stop bar is acting on. It has to be
   unmistakable, because the bar names a robot and every tile looks alike. */
.monitor-tile.is-selected {
  border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-ring);
}
.monitor-tile-canvas {
  display: block; width: 100%; height: 100%; object-fit: cover;
  background: var(--panel-tint);
}
/* A wrist slot: its canvas with its own small placeholder over it, 16:9 at
   its column's width (half the exo's) and so half the exo's height, centred
   by the tile's align-items. The frame is already stretched to the canvas
   (drawStretch), so the canvas fills the slot the same way. The placeholder
   is smaller and dimmer than the exo's: a missing wrist feed is detail, not
   the tile's headline. */
.monitor-tile-wrist { position: relative; min-width: 0; min-height: 0; aspect-ratio: 16 / 9; }
.monitor-tile-wrist-L { grid-area: wristL; }
.monitor-tile-wrist-R { grid-area: wristR; }
.monitor-tile-wrist-canvas {
  display: block; width: 100%; height: 100%;
  background: var(--panel-tint);
}
.monitor-tile-wrist-msg {
  position: absolute; inset: 0;
  display: flex; align-items: center; justify-content: center;
  font-size: var(--fs-micro); color: var(--text-dim); opacity: 0.8;
  text-align: center; padding: 0 var(--sp-1);
  pointer-events: none;
}

/* The "under" layout, picked with the switch at the top right
   (#monitorWristLayout; monitorDashboard.js writes the attribute): the exo
   across the top, wrist L and wrist R under it. Two rows, 2:1 in height,
   which is what makes each slot 16:9 at a 32:27 tile (16 wide by 9 + 4.5
   tall). The exo is the full tile width, so --tile-min is back to the exo
   width the old exo-only tile had. Same tile DOM as "side", so nothing but
   these rules changes when a monitor switches. The wall's width and the
   height cap above follow --tile-aspect, so they need nothing extra; with
   one robot the cap is what keeps the tile from growing to the window's
   width (806px wide at 1920x1080 rather than 1880px). */
#monitorGrid[data-wrist-layout="under"] {
  --tile-aspect: 32 / 27;
  --tile-min: 18rem;
}
#monitorGrid[data-wrist-layout="under"] .monitor-tile {
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  grid-template-rows: minmax(0, 2fr) minmax(0, 1fr);
  grid-template-areas: "exo exo" "wristL wristR";
  align-items: stretch;
}
#monitorGrid[data-wrist-layout="under"] .monitor-tile-wrist { aspect-ratio: auto; }
/* Placeholder for a tile with no feed — "connect failed", the normal case for
   a robot whose relay is unreachable. One dead tile must look dead without
   taking the wall down. */
.monitor-tile-msg {
  position: absolute; inset: 0;
  display: flex; align-items: center; justify-content: center;
  font-size: var(--fs-micro); color: var(--text-dim);
  text-align: center; padding: 0 var(--sp-2);
  pointer-events: none;
}
/* Ping/latency overlay — top-left corner over the video feed. */
.monitor-tile-ping {
  position: absolute; top: var(--sp-2); left: var(--sp-2);
  font-size: var(--fs-micro); font-weight: 600; font-variant-numeric: tabular-nums;
  color: var(--text); background: rgba(255, 255, 255, 0.78);
  backdrop-filter: blur(2px);
  padding: 1px var(--sp-2); border-radius: var(--radius-xs, 4px);
  pointer-events: none; line-height: 1.4;
}
/* Sign-out button pinned to top-left of a view - was monitor-only, now also
   used by the operator queue view (both are role landing pages reached
   straight from login). */
.view-logout {
  position: fixed; top: var(--sp-3); left: var(--sp-3); z-index: 10;
}
/* The monitor's wrist layout switch, pinned top right opposite Sign out.
   A two-button segmented control in the style of the monitor mockup's
   tiles-per-row control (webapp/mock/monitor-ux, .seg / .per-row): each
   button draws its layout as boxes instead of words, the exo the big one —
   three in a row for "side", one over two for "under". The words are the
   buttons' title and aria-label. The lit one is the layout on screen. */
.wrist-layout-switch {
  position: fixed; top: var(--sp-3); right: var(--sp-3); z-index: 10;
  display: inline-flex; padding: 2px;
  border: 1px solid var(--border); border-radius: var(--r-pill);
  background: var(--surface);
}
.wrist-layout-switch button {
  display: inline-flex; align-items: center; justify-content: center;
  padding: 0.4rem 0.6rem; border: 0; border-radius: var(--r-pill);
  background: none; color: var(--text-dim); cursor: pointer;
}
.wrist-layout-switch button.is-on { background: var(--accent); color: #fff; }
.wrist-layout-switch button:not(.is-on):hover { color: var(--text); }
.wl-icon { display: inline-grid; gap: 1px; align-items: center; }
.wl-icon i { display: block; border-radius: 1px; background: currentColor; opacity: 0.85; }
.wl-side { grid-template-columns: auto auto auto; }
.wl-side .wl-exo { width: 12px; height: 7px; }
.wl-side .wl-wrist { width: 6px; height: 3.5px; }
.wl-under { grid-template-columns: auto auto; }
.wl-under .wl-exo { grid-column: 1 / -1; width: 13px; height: 7px; }
.wl-under .wl-wrist { width: 6px; height: 3.5px; }
/* Overlaid on the video rather than beside it, so every tile is the same
   shape whether or not frames ever arrive. */
.monitor-tile-bar {
  position: absolute; left: 0; right: 0; bottom: 0;
  display: flex; align-items: center; gap: var(--sp-1);
  padding: var(--sp-2) var(--sp-3);
  background: rgba(255, 255, 255, 0.90);
  backdrop-filter: blur(2px);
  border-top: 1px solid var(--border);
  font-size: var(--fs-micro);
  pointer-events: none;
}
.monitor-tile-id { font-weight: 700; color: var(--text); }
/* The robot's account, beside its id: staff logins only (they see every account). */
.monitor-tile-company { color: var(--text-dim); white-space: nowrap; }
.monitor-tile-note {
  margin-left: auto; font-size: var(--fs-micro); color: var(--red); font-weight: 600;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}

/* REC badge, top-right over the video (the ping overlay owns top-left).
   Red is the one exception to "nothing else may be red" (§ buttons): a red
   REC is the universal sign for "this is being recorded", it is a state and
   never a button, and a monitor scanning the wall must find the recording
   tile without reading. The inset ring repeats it at tile scale. It is drawn
   by a ::after laid over the whole tile, not by the tile's own box-shadow:
   an inset shadow paints under the tile's children, and the exo and wrist
   canvases cover the tile edge to edge, so the ring only ever showed as two
   red ticks at the ends of the hairline between the exo and the wrists.
   Being on the ::after also leaves the tile's own box-shadow to
   .is-selected's outer ring, so the two still stack. */
.monitor-tile-rec {
  position: absolute; top: var(--sp-2); right: var(--sp-2);
  display: inline-flex; align-items: center; gap: var(--sp-1);
  font-size: var(--fs-micro); font-weight: 700; font-variant-numeric: tabular-nums;
  color: #fff; background: var(--red);
  padding: 1px var(--sp-2); border-radius: var(--r-pill);
  pointer-events: none; line-height: 1.4;
}
.monitor-tile-rec::before,
#monitorRecordState::before {
  content: ''; width: 0.4rem; height: 0.4rem; border-radius: 50%; background: currentColor;
}
.monitor-tile.is-recording::after {
  content: ''; position: absolute; inset: 0;
  border-radius: inherit; box-shadow: inset 0 0 0 2px var(--red);
  pointer-events: none;
}

/* Mode banner (robotMode.js, Proposal P1) — just above the tile's bar, over
   the video. Colour, an icon AND words, never colour alone; the status word
   comes first and the mode qualifies it ("Online · Policy running"), with
   the instruction / hold reason on a second line. Tones: existing statuses
   keep their colours; policy is the accent, held is an amber OUTLINE (same
   hue as flagged, hollow where flagged is solid, like data_collection's
   hollow dot), operator is purple. Anchored to the bar (its parent) so it
   sits above it whatever the bar's height. */
.mode-banner {
  position: absolute; left: var(--sp-2); right: var(--sp-2); bottom: calc(100% + var(--sp-1));
  display: flex; align-items: center; gap: var(--sp-2);
  padding: 0.2rem var(--sp-2);
  border-radius: var(--r-sm);
  border: 1.5px solid transparent;
  background: rgba(10, 12, 18, 0.72);
  color: #fff;
  font-size: var(--fs-label);
  line-height: 1.25;
  min-width: 0;
  backdrop-filter: blur(2px);
  pointer-events: none;
}
.mode-banner-icon { flex: none; font-size: 0.9em; width: 1.1em; text-align: center; }
.mode-banner-text { display: flex; align-items: baseline; gap: var(--sp-2); flex: 1; min-width: 0; }
.mode-banner-label { flex: none; font-weight: 700; white-space: nowrap; }
/* One line: the detail sits to the label's right and wraps there when it is
   long, so the banner only grows for an instruction that needs the room. */
.mode-banner-detail { flex: 1; min-width: 0; text-align: right; font-size: var(--fs-micro); opacity: 0.85; overflow-wrap: anywhere; }

.mode-banner[data-tone="offline"] { background: rgba(60, 64, 76, 0.85); color: #d5d9e4; }
.mode-banner[data-tone="idle"]    { background: rgba(10, 12, 18, 0.6); border-color: rgba(255, 255, 255, 0.45); }
.mode-banner[data-tone="policy"]  { background: var(--accent); border-color: var(--accent); }
.mode-banner[data-tone="held"]    { background: rgba(10, 12, 18, 0.7); border-color: var(--yellow); color: #F3D28A; }
.mode-banner[data-tone="operator"] { background: var(--purple); border-color: var(--purple); }
.mode-banner[data-tone="flagged"] { background: var(--yellow); border-color: var(--yellow); color: #1A1406; }
.mode-banner[data-tone="data_collection"] { background: rgba(10, 12, 18, 0.7); border-color: var(--accent); color: #C7D0F5; }
.mode-banner.is-pending .mode-banner-detail { color: #F3D28A; opacity: 1; }
/* A policy that is running breathes (same rule as the live dot: the one
   motion cue, off under prefers-reduced-motion where the words still say it). */
.mode-banner[data-tone="policy"] .mode-banner-icon { animation: banner-pulse 1.6s ease-in-out infinite; }
@keyframes banner-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }

/* The same mode as a chip in a session row, after the status chip: same
   .status-chip component, toned by data-tone. */
.mode-chip { gap: var(--sp-1); padding-left: var(--sp-2); max-width: 100%; }
.mode-chip > span:last-child { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.mode-chip-icon { flex: none; }
.mode-chip[data-tone="policy"]   { color: var(--accent); background: var(--accent-tint); }
.mode-chip[data-tone="held"]     { color: var(--yellow); background: var(--yellow-tint); box-shadow: inset 0 0 0 1px var(--yellow); }
.mode-chip[data-tone="operator"] { color: var(--purple); background: var(--purple-tint); }
.mode-chip[data-tone="flagged"]  { color: var(--yellow); background: var(--yellow-tint); }
.mode-chip[data-tone="data_collection"] { color: var(--text-dim); background: var(--accent-tint); }
.mode-chip[data-tone="idle"], .mode-chip[data-tone="offline"] { color: var(--text-dim); background: var(--panel-tint); }
.mode-chip.is-pending { color: var(--yellow); }
.mode-chip[data-tone="policy"] .mode-chip-icon { animation: banner-pulse 1.6s ease-in-out infinite; }

/* The one moment of motion in the app. A pulsing dot means frames are arriving
   right now, and on a wall of near-identical stills that is the single fact a
   monitor most needs and can least see. The dot breathes; nothing else on the
   page moves. Disabled under prefers-reduced-motion (§5), where the dot's
   colour still carries the state.

   `streaming` is a client-side-only state (monitorCameraTile.js): video is
   live, but nothing here has asked the control plane what the robot's status
   IS, so the dot stays neutral grey and pulses with a grey halo. `online` — a
   colour claim about the server's view — is reserved for when a tile can
   actually poll for it; until then a tile never asserts it. */
.monitor-tile[data-status="online"] .status-dot,
.monitor-tile[data-status="streaming"] .status-dot {
  position: relative;
  animation: dot-live 2.4s ease-in-out infinite;
}
.monitor-tile[data-status="online"] .status-dot { --dot-pulse: 42, 157, 114; }
.monitor-tile[data-status="streaming"] .status-dot { --dot-pulse: 176, 186, 208; } /* --gray */
@keyframes dot-live {
  0%, 100% { box-shadow: 0 0 0 0 rgba(var(--dot-pulse, 42, 157, 114), 0.55); }
  60%      { box-shadow: 0 0 0 0.28rem rgba(var(--dot-pulse, 42, 157, 114), 0); }
}

/* ── Stop bar ──
   Fixed to the viewport, full width, one at a time — deliberately NOT a
   per-tile button: flagging a robot is a safety action and deserves a second,
   deliberate click somewhere other than where the first one landed. */
/* ── Sessions (below the tile wall) ──
   One card per robot with a relay. A row is read left to right as: who, what
   state, who is driving and how far away they are, then the health chips.
   Text sizes and the card are the queue row's; nothing here is a new idiom
   except .health-chip. */
#monitorSessionsLabel { margin-top: var(--sp-6); }
#monitorSessionsBanner { margin-bottom: var(--sp-2); }
.session-row {
  padding: var(--sp-3) var(--sp-4);
  margin-bottom: var(--sp-2);
  display: flex; flex-direction: column; gap: var(--sp-2);
}
/* Held values after a failed poll: still readable, visibly not live. */
.session-row.is-stale { opacity: 0.55; }
.session-head { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2) var(--sp-3); }
.session-robot { font-size: var(--fs-input); font-weight: 700; color: var(--text); }
.session-operator, .session-ping { font-size: var(--fs-label); color: var(--text); }
/* An estimate (two link RTTs added up) must not read as a measurement. */
.session-ping.is-estimated { color: var(--text-dim); font-style: italic; }
.session-age { font-size: var(--fs-micro); color: var(--text-faint); margin-left: auto; }
.session-chips { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
.session-reasons { font-size: var(--fs-label); color: var(--text); }
.session-row[data-level="warn"] .session-reasons { color: var(--yellow); }
.session-row[data-level="bad"] .session-reasons { color: var(--red); }
.session-relay-error { font-size: var(--fs-label); color: var(--red); }
.session-detail { font-size: var(--fs-micro); color: var(--text-dim); }

/* One per segment of the path (sessionHealth.js SEGMENT_ORDER). The four
   states reuse the status vocabulary's tokens: green ok, yellow warn, red
   bad, grey for "nothing to judge" (no operator connected, an older robot).
   The segment the classifier blames is outlined, so the one to look at is
   findable without reading colours. */
.health-chip {
  padding: 0.1rem var(--sp-2);
  border-radius: var(--r-pill);
  border: 1px solid transparent;
  background: var(--panel-tint);
  font-size: var(--fs-micro); font-weight: 600; letter-spacing: 0.02em;
  color: var(--text-faint);
}
.health-chip.ok   { color: var(--green);  background: rgba(42, 157, 114, 0.10); }
.health-chip.warn { color: var(--yellow); background: rgba(192, 138, 42, 0.10); }
.health-chip.bad  { color: var(--red);    background: rgba(201, 79, 79, 0.08); }
.health-chip.is-blamed { border-color: currentColor; }

/* ── Incidents (below the sessions) ──
   The log sessionLog.js keeps: one line per incident, newest first, in one
   card. Reuses the session row's text sizes and .health-chip for the blamed
   segment; an open incident carries the word "open". */
#monitorIncidentsLabel { margin-top: var(--sp-6); }
#monitorIncidentsBar, #monitorIncidentsConfirm {
  display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2);
  margin-bottom: var(--sp-2);
}
#monitorIncidentsCount { font-size: var(--fs-micro); color: var(--text-faint); margin-right: auto; }
#monitorIncidentsConfirm { font-size: var(--fs-label); color: var(--text); }
#monitorIncidentsConfirmText { margin-right: auto; }
#monitorIncidentsNote { font-size: var(--fs-label); color: var(--yellow); margin-bottom: var(--sp-2); }
/* A session row filters the list when clicked; the outline says which one. */
#monitorSessions .session-row { cursor: pointer; }
.session-row.is-filtered { border-color: var(--accent); }
.incident-row {
  display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--sp-1) var(--sp-3);
  padding: var(--sp-2) var(--sp-4);
  border-top: 1px solid var(--border);
  font-size: var(--fs-label); color: var(--text);
}
.incident-row:first-child { border-top: 0; }
.incident-time, .incident-duration { font-variant-numeric: tabular-nums; }
.incident-time { font-size: var(--fs-micro); color: var(--text-dim); }
.incident-robot { font-weight: 700; }
.incident-duration { color: var(--text-dim); min-width: 4.5rem; }
.incident-reason { flex: 1 1 12rem; min-width: 0; }
.incident-open, .incident-ended { font-size: var(--fs-micro); margin-left: auto; }
.incident-open { font-weight: 700; letter-spacing: 0.02em; text-transform: uppercase; color: var(--yellow); }
.incident-row[data-level="bad"] .incident-open { color: var(--red); }
.incident-ended { color: var(--text-faint); }

/* A grid, not a wrapping row: the three groups always sit Policy · Recording
   · Stop / Assign left to right, each with its label on top and its own
   controls under it, and Stop stays at the right edge whatever the groups'
   heights are. The info line, the confirm card and the message lines are
   full-width rows. (The mockup's §4 bar, monitor-ux-mock-ui.) */
#monitorStopBar {
  position: fixed; left: 0; right: 0; bottom: 0; z-index: 20;
  display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) 23rem;
  align-items: start; gap: var(--sp-2) var(--sp-5);
  padding: var(--sp-2) var(--sp-5) var(--sp-3);
  background: var(--surface); border-top: 1px solid var(--border);
  box-shadow: var(--shadow-bar);
  /* A tall bar on a short window scrolls inside itself, never the page. */
  max-height: 80vh; overflow: auto;
}
#monitorStopInfo { grid-column: 1 / -1; display: flex; align-items: baseline; gap: var(--sp-2); }
#monitorStopInfo .section-label { margin: 0; }
#monitorStopRobot { font-size: var(--fs-input); font-weight: 700; color: var(--text); }
/* The bar's two text boxes: the recording task and the policy instruction,
   one style so they read as the same kind of thing with different jobs. */
#monitorAssignTask, #monitorPolicyInstruction {
  padding: var(--sp-1) var(--sp-2);
  border-radius: var(--r-sm); border: 1px solid var(--border);
  background: var(--surface); color: var(--text);
  font: 600 var(--fs-micro) var(--font);
  /* Grows into its group's free space so a saved value reads in full; a
     narrow group wraps it onto its own line rather than squeezing it. */
  flex: 1 1 12rem; min-width: 0;
}
/* Under its own label, the task box takes the whole line. */
#monitorAssignTask { flex-basis: 100%; }
#monitorAssignTask:focus, #monitorPolicyInstruction:focus { outline: none; border-color: var(--accent); }
/* Typed while the policy runs and not yet sent to it: amber until Update
   (or the next Start) carries it over. */
#monitorPolicyInstruction[data-state="unsent"] { border-color: var(--yellow); }
/* TEMPORARY: the Instruction field is a dropdown, locked while a policy runs. */
#monitorPolicyInstruction { cursor: pointer; }
#monitorPolicyInstruction:disabled { cursor: not-allowed; opacity: 0.7; }
/* The bar's labelled groups (Policy · Recording · Stop / Assign): one grid
   cell each, the label on its own line on top, the same controls as before
   wrapping under it. A reason line, the New hint or a long value wraps
   INSIDE its cell, so the other groups never move. */
.bar-group {
  display: flex; flex-wrap: wrap; align-items: center; align-content: flex-start;
  gap: var(--sp-2); min-width: 0;
}
.bar-group > .section-label, .bar-group-label { margin: 0; flex-basis: 100%; }
#monitorPolicy { grid-column: 1; }
#monitorRecord { grid-column: 2; }
#monitorStopGroup { grid-column: 3; }
/* A hairline between neighbouring groups. */
#monitorRecord, #monitorStopGroup { border-left: 1px solid var(--border); padding-left: var(--sp-4); }
/* A robot with no policy command hides the Policy group (monitorPolicy.js);
   Recording takes its room so Stop / Assign stays put on the right. */
#monitorPolicy[hidden] ~ #monitorRecord { grid-column: 1 / 3; border-left: 0; padding-left: 0; }
/* The one-time hint takes a line of its own under the Policy controls. */
#monitorPolicyHint {
  display: inline-flex; align-items: center; gap: var(--sp-1); flex-basis: 100%;
  font-size: var(--fs-micro); color: var(--text-dim);
}
#monitorPolicyHint b {
  padding: 0 var(--sp-1); border-radius: var(--r-sm);
  background: var(--accent); color: #fff; font-weight: 700; letter-spacing: 0.02em; text-transform: uppercase;
}
#monitorPolicyHintClose { padding: 0 var(--sp-1); color: var(--text-dim); }
.bar-check {
  display: inline-flex; align-items: center; gap: var(--sp-1);
  font-size: var(--fs-micro); color: var(--text-dim); cursor: pointer;
}
.bar-check input { margin: 0; accent-color: var(--accent); }
.bar-check:has(input:disabled) { cursor: default; opacity: 0.6; }
/* Why a button is disabled, in words beside it (robotMode.js's allowedActions):
   a greyed button alone does not say. Same size as the message line. */
.bar-reason { font-size: var(--fs-micro); color: var(--text-dim); min-width: 0; max-width: 100%; }
/* The Instruction box's amber state in words: edited while the policy runs
   and not sent to it yet. */
.bar-unsent { font-size: var(--fs-micro); font-weight: 600; color: var(--yellow); white-space: nowrap; }
#monitorStopError, #monitorAssignError { grid-column: 1 / -1; margin: 0; }
/* Assign · Stop · ✕ keep their place: the right edge of the bar. The Assign
   reason, when there is one, is the line above them. */
#monitorStopActions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); margin-left: auto; }
#monitorAssignReason { flex-basis: 100%; text-align: right; }

/* Record group: button, REC clock, then (after a stop) the grade buttons.
   The button stays quiet even while recording: it is not the page's danger
   action (Stop is), so it only takes a red word and border, and the REC clock
   beside it carries the state. */
#monitorRecordBtn.is-recording { color: var(--red); border-color: var(--red); }
#monitorRecordState {
  display: inline-flex; align-items: center; gap: var(--sp-1);
  font-size: var(--fs-micro); font-weight: 700; font-variant-numeric: tabular-nums;
  color: var(--red);
}
#monitorTagPicker { display: inline-flex; align-items: center; flex-wrap: wrap; gap: var(--sp-1); }
#monitorTagPicker .section-label { margin: 0 var(--sp-1) 0 0; }
/* The grade buttons reuse the .tag-* vocabulary (bad red, medium yellow,
   good green, same tokens as .tag-select and hud/panels/tagPanel.js). Unpicked
   ones are a coloured word on a surface; the picked one (medium until the
   monitor chooses) is filled, the way the headset's selected row is. */
.tag-btn { background: var(--surface); border-color: var(--border); font-weight: 600; }
.tag-btn.tag-bad    { color: var(--red); }
.tag-btn.tag-medium { color: var(--yellow); }
.tag-btn.tag-good   { color: var(--green); }
.tag-btn.tag-bad.is-selected    { color: #fff; background: var(--red);    border-color: var(--red); }
.tag-btn.tag-medium.is-selected { color: #fff; background: var(--yellow); border-color: var(--yellow); }
.tag-btn.tag-good.is-selected   { color: #fff; background: var(--green);  border-color: var(--green); }
/* Delete is not red: red is kept for the two buttons that move or release a
   robot (see .btn-danger). Neutral at rest; armed (first click, waiting for
   the confirming second) it inverts so the change is unmissable. */
.tag-btn.tag-delete { color: var(--text-dim); margin-left: var(--sp-2); font-weight: 500; }
.tag-btn.tag-delete.is-armed { color: var(--surface); background: var(--text); border-color: var(--text); }
.tag-btn:hover:not(:disabled) { border-color: currentColor; }
.tag-btn.is-selected:hover:not(:disabled) { opacity: 0.88; }
/* Full-width row under the groups, like the two error slots. Only 'error' is
   red; capture_active and "saved, couldn't tag" are information. */
#monitorRecordMsg { grid-column: 1 / -1; margin: 0; font-size: var(--fs-label); color: var(--text-dim); }
#monitorRecordMsg[data-kind="error"] { color: var(--red); }
#monitorRecordMsg[data-kind="ok"] { color: var(--green); }
/* The 30 s policy timeout: not a refusal, the robot just never answered. */
#monitorRecordMsg[data-kind="warn"] { color: var(--yellow); }
#monitorStopBtn { padding: var(--sp-2) var(--sp-6); }
#monitorStopClose { color: var(--text-dim); }

/* Mid widths: Policy and Recording side by side, Stop / Assign on its own
   full row underneath with the buttons at the right edge, as today. */
@media (max-width: 72rem) {
  #monitorStopBar { grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr); }
  #monitorStopGroup {
    grid-column: 1 / -1;
    border-left: 0; padding-left: 0;
    border-top: 1px solid var(--border); padding-top: var(--sp-2);
  }
}
/* Narrow: the groups stack, each full width, a rule between them. */
@media (max-width: 52rem) {
  #monitorStopBar { grid-template-columns: minmax(0, 1fr); gap: var(--sp-2) 0; }
  #monitorPolicy, #monitorRecord, #monitorStopGroup, #monitorPolicy[hidden] ~ #monitorRecord { grid-column: 1; }
  #monitorRecord, #monitorStopGroup {
    border-left: 0; padding-left: 0;
    border-top: 1px solid var(--border); padding-top: var(--sp-2);
  }
  #monitorPolicy[hidden] ~ #monitorRecord { border-top: 0; padding-top: 0; }
  #monitorStopActions { margin-left: 0; width: 100%; }
  #monitorAssignReason { text-align: left; }
  #monitorStopBtn, #monitorAssignBtn { flex: 1; padding: var(--sp-2) var(--sp-3); }
}

/* ── Info page (#dashboard) ──
   Reached from a queue row's Info action, and where exiting VR lands after a
   queue-originated session. */
#dispatchCard { padding: var(--sp-5); margin-bottom: var(--sp-4); text-align: center; }
#dispatchLabel {
  display: flex; align-items: center; justify-content: center;
  font-size: var(--fs-micro); color: var(--text-dim);
  letter-spacing: var(--tracking-label); text-transform: uppercase;
  margin-bottom: var(--sp-2);
}
#dispatchRobot { font-size: 1.1rem; font-weight: 700; color: var(--text); }
#dispatchSub { font-size: var(--fs-micro); color: var(--text-dim); margin-bottom: var(--sp-4); }

#operatorActions { display: flex; flex-direction: column; gap: var(--sp-2); }
#operatorActions .btn { justify-content: space-between; text-align: left; }
#operatorActions .btn:disabled { color: var(--gray); }

#sessionActions { display: flex; flex-direction: column; gap: var(--sp-2); }
#episodeTag { display: flex; flex-direction: column; gap: var(--sp-2); }
.vr-note { font-size: var(--fs-micro); color: var(--text-faint); font-weight: 400; }

#releaseError { margin-top: var(--sp-2); }

/* Names the robot the red button will release. Quiet, but never absent: the
   button is only shown for an explicitly-selected robot (app.js), and saying
   which one is what makes that selection visible rather than merely correct. */
#releaseTarget {
  font-size: var(--fs-label);
  color: var(--text-dim);
  margin-bottom: var(--sp-3);
}
#releaseTarget strong { color: var(--text); font-weight: 700; }

/* ── Landing (manual setup, pre-existing flow) ── */
#landing .btn { margin-top: var(--sp-3); }

/* ── Log ──
   Lives outside every view, so it is constrained to the reading column to
   keep it from spanning the viewport on the narrow pages. */
#log {
  max-width: var(--shell); margin: var(--sp-4) auto 0;
  padding: var(--sp-2) var(--sp-3);
  font-family: var(--mono); font-size: var(--fs-micro); color: var(--text-dim);
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-sm);
  min-height: 4rem; max-height: 12rem; overflow: auto;
  /* pre-wrap alone will not break the relay URLs and tokens this log prints,
     so a single connect line used to force a horizontal scrollbar. */
  white-space: pre-wrap; overflow-wrap: anywhere;
}

/* ── HUD overlay (2D dev mirror) ── */
#hud {
  position: fixed; top: var(--sp-3); left: var(--sp-3); z-index: 10;
  font: 0.8rem/1.5 var(--mono); color: var(--text);
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-md);
  padding: var(--sp-2) var(--sp-3); pointer-events: none; min-width: 12rem;
  box-shadow: var(--shadow-raised);
}
#hud .ok   { color: var(--green); }
#hud .warn { color: var(--yellow); }
#hud .bad  { color: var(--red); }

#xr-status { font-size: var(--fs-body); color: var(--text-dim); text-align: center; margin: 0 0 var(--sp-5); }

/* ═══ 5. Responsive ══════════════════════════════════════════════════════
   The four narrow views are a single 30rem column with fluid padding and need
   no breakpoints — they already fit a phone. The monitor wall is the only
   layout with a fixed column count, and it is the only one used on a desk
   window that can be half a screen wide. */

/* Below the wide shell, stop honouring --monitor-cols and let the tiles
   decide: auto-fit never produces more columns than fit, never fewer than
   one, and collapses to a single full-width tile for a one-stream monitor.
   min(100%, …) is what keeps it from overflowing under 18rem. */
@media (max-width: 900px) {
  #monitorGrid {
    grid-template-columns: repeat(auto-fit, minmax(min(100%, var(--tile-min)), 1fr));
    /* The height cap is worked out from --monitor-cols, which auto-fit
       ignores here: a narrow window is a phone or a half-screen, and
       scrolling the wall is the right answer on either. */
    max-width: none;
  }
}

/* Phone-width: the stop bar loses some side padding (its groups already
   stack from 52rem down), and the tile overlay bar gets its padding back. */
@media (max-width: 30rem) {
  :root { --shell-pad: 0.9rem; }
  #monitorStopBar { padding: var(--sp-2) var(--sp-4) var(--sp-3); }
  .queue-actions { margin-left: 0; width: 100%; }
  .queue-actions .btn { flex: 1; }
  /* Once the episode row wraps, `margin-left: auto` would push the timestamp
     to the far right of its own new line, away from the tag it belongs with. */
  .episode-when { margin-left: 0; }
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
}
