/* style.css — phonecontrol portal (Task 14 / spec §15, wave/portal-ux rebuild).
   External because the CSP is style-src 'self' (no inline <style>, no style=).

   Design language: off-black operator surface, ONE desaturated blue accent, one
   gray family, tabular figures wherever a number ticks. Motion is restricted to
   opacity/transform at 150-250ms; color changes are instant.

   Type ramp: four sizes (--fs-s .. --fs-xl), weights stepped 400/500/600 —
   hierarchy comes from weight and size, never from bolding everything.

   Status color system: every device/stream state reads the same --st-* custom
   properties (chips, tile border tints, focus heading). GREEN means "live" and
   nothing else. Each state also owns a distinct glyph shape drawn by the chip's
   ::before (dot / ring / diamond / triangle / hollow / ! / ×) — no state is
   distinguishable by color alone. */

:root {
  --bg: #0d1117;            /* page — off-black, never pure #000 */
  --surface: #151a21;       /* cards, tiles, topbar */
  --raised: #1b2129;        /* inputs, hover surfaces */
  --ink: #e6edf3;
  --muted: #8d96a0;
  --faint: #8b97a3;
  --line: #2a313a;
  --line-strong: #3d444d;
  --accent: #6f9fd8;        /* the one desaturated accent */
  --accent-strong: #8ab4e6;
  --accent-ink: #0b0f14;    /* text on accent fills */
  --warn: #d29922;
  --danger: #e5534b;
  --stage-black: #07090c;   /* behind device pixels, not the page */

  /* the state enum — one token per state, one consumer vocabulary */
  --st-live: #63b06a;       /* green ONLY for live */
  --st-held: #6f9fd8;       /* a colleague's lease (the accent hue, distinct use) */
  --st-busy: #4fb3bf;       /* session active on the farm */
  --st-stale: #d29922;      /* the amber: watched but the frame aged */
  --st-idle: #8d96a0;       /* ready, unmeasured, unwatched — a fact, not a fault */
  --st-error: #e5534b;      /* the farm reports a fault */
  --st-offline: #e5534b;    /* not connected — red like error: a phone the farm
                               cannot see needs an operator too; the chip's ×
                               glyph still tells it apart from error's "!" */

  /* type ramp — 4 sizes, tabular figures wherever a number ticks */
  --fs-s: 12px;
  --fs-m: 13.5px;
  --fs-l: 16px;
  --fs-xl: 22px;

  --mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;

  --r-s: 5px;               /* chips, inline code */
  --r-m: 7px;               /* buttons, inputs */
  --r-l: 12px;              /* cards, tiles, containers */

  --t-fast: 150ms;
  --t-med: 220ms;
}

* { box-sizing: border-box; }
/* The quick bar is the mobile control strip (Home + premade swipes, always
   visible under the stream); the desktop rail already carries those controls. */
#quick-bar { display: none; }
[hidden] { display: none !important; }

body {
  margin: 0;
  background: var(--bg);
  color: var(--ink);
  font: 400 var(--fs-m)/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
        "Helvetica Neue", Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
}

h1, h2, h3 { margin: 0; letter-spacing: -0.01em; }
h1 { font-size: var(--fs-xl); font-weight: 600; }
h2 { font-size: var(--fs-l); font-weight: 600; }
h3 {
  font-size: var(--fs-s);
  font-weight: 600;
  color: var(--muted);
  letter-spacing: 0.06em;
  text-transform: uppercase;
}

code {
  font: 500 var(--fs-s) var(--mono);
  background: var(--raised); border: 1px solid var(--line);
  border-radius: var(--r-s); padding: 1px 5px;
}

/* Device identifiers ("farm-03") read as identifiers: monospace, weight 600,
   stable tracking — identical treatment in the focus header and the metadata
   card. (The grid tile's UDID tail rule is GONE: G81 took the UDID out
   of the operator's normal flow, nothing emits the tile UDID class anymore,
   and the dead rule was removed rather than left to re-wire silently.) */
.dev-id, .mono {
  font-family: var(--mono);
  font-weight: 600;
  letter-spacing: 0.02em;
  font-variant-numeric: tabular-nums;
}

.muted { color: var(--muted); font-size: var(--fs-s); }

/* Every interactive element: visible focus ring, press-down scale, and motion
   on opacity/transform only. */
:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  border-radius: var(--r-m);
}
.btn, .tile, summary, input, select {
  transition: opacity var(--t-fast) ease, transform var(--t-fast) ease;
}
.btn:active:not(:disabled), .tile:active { transform: scale(0.98); }

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

.btn {
  appearance: none;
  background: var(--raised);
  color: var(--ink);
  border: 1px solid var(--line-strong);
  border-radius: var(--r-m);
  padding: 6px 13px;
  font: 600 var(--fs-m)/1.4 inherit;
  font-family: inherit;
  cursor: pointer;
  white-space: nowrap;
}
.btn:hover:not(:disabled) { background: #232b35; border-color: var(--line-strong); }
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
.btn.primary:hover:not(:disabled) { background: var(--accent-strong); border-color: var(--accent-strong); }
.btn.danger { background: transparent; border-color: var(--danger); color: var(--danger); }
.btn.danger:hover:not(:disabled) { background: rgba(229, 83, 75, 0.12); }
.btn.ghost { background: transparent; border-color: transparent; color: var(--muted); }
.btn.ghost:hover:not(:disabled) { color: var(--ink); background: var(--raised); }

/* Hold-to-press gate on the destructive Release: the red fill sweeps the button
   over the hold window (app.js HOLD_MS) and only a completed hold fires. No
   inline style — the sweep is a class toggle over a fixed transition. */
.btn.hold-btn { position: relative; overflow: hidden; }
.btn.hold-btn::after {
  content: "";
  position: absolute;
  inset: 0;
  background: rgba(229, 83, 75, 0.3);
  transform: scaleX(0);
  transform-origin: left center;
  pointer-events: none;
}
.btn.hold-btn.holding::after {
  transform: scaleX(1);
  transition: transform 1.2s linear;
}

input, select {
  background: var(--raised);
  color: var(--ink);
  border: 1px solid var(--line-strong);
  border-radius: var(--r-m);
  padding: 7px 10px;
  font: inherit;
  width: 100%;
}
input::placeholder { color: var(--faint); }
input:focus-visible, select:focus-visible { outline: 2px solid var(--accent); outline-offset: 0; }

label {
  display: block;
  font-size: var(--fs-s);
  font-weight: 500;
  color: var(--muted);
  margin-bottom: 4px;
}

/* ---- chips: the ONE status component (grid, focus header, stream state) ----- */
/* The glyph lives in ::before (shape per state), never in the text content. */

.chip {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  border: 1px solid var(--line);
  border-radius: 999px;
  padding: 2px 10px;
  font-size: var(--fs-s);
  font-weight: 600;
  color: var(--muted);
  background: var(--surface);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
.chip::before {
  content: "";
  width: 6px; height: 6px;
  border-radius: 50%;
  background: var(--faint);
  flex: none;
}

.chip.st-live { color: var(--st-live); border-color: rgba(99, 176, 106, 0.45); }
.chip.st-live::before { background: var(--st-live); }
.chip.chip-live::before { animation: breathe 2.4s ease-in-out infinite; }

.chip.st-held { color: var(--st-held); border-color: rgba(111, 159, 216, 0.45); }
.chip.st-held::before { background: transparent; border: 1.5px solid var(--st-held); }

.chip.st-busy { color: var(--st-busy); border-color: rgba(79, 179, 191, 0.45); }
.chip.st-busy::before { background: var(--st-busy); border-radius: 1px; transform: rotate(45deg); }

.chip.st-stale { color: var(--st-stale); border-color: rgba(210, 153, 34, 0.45); }
.chip.st-stale::before {
  width: 0; height: 0;
  background: none; border-radius: 0;
  border-left: 4px solid transparent;
  border-right: 4px solid transparent;
  border-bottom: 6px solid var(--st-stale);
}

.chip.st-idle { color: var(--muted); }
.chip.st-idle::before { background: transparent; border: 1.5px solid var(--faint); }

.chip.st-error { color: var(--st-error); border-color: rgba(229, 83, 75, 0.5); }
.chip.st-error::before {
  content: "!"; width: auto; height: auto; background: none; border-radius: 0;
  font-size: 10px; font-weight: 700; line-height: 1;
}

.chip.st-offline { color: var(--st-offline); border-color: rgba(229, 83, 75, 0.5); }
.chip.st-offline::before {
  content: "×"; width: auto; height: auto; background: none; border-radius: 0;
  font-size: 11px; font-weight: 700; line-height: 1;
}

/* transitional stream states (never a device state, so never a --st- token):
   connecting pulses the accent; reconnecting borrows the stale amber because
   the operator is watching a feed that is not arriving. */
.chip.chip-connect { color: var(--accent); border-color: rgba(111, 159, 216, 0.45); }
.chip.chip-connect::before { background: var(--accent); animation: breathe 1.2s ease-in-out infinite; }

@keyframes breathe { 50% { opacity: 0.35; } }

.chip-row { display: flex; flex-wrap: wrap; gap: 8px; min-height: 22px; align-items: center; }

/* ---- topbar ----------------------------------------------------------------- */

#topbar {
  position: sticky;
  top: 0;
  z-index: 10;
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 9px 20px;
  background: var(--surface);
  border-bottom: 1px solid var(--line);
}
#brand { font-weight: 600; letter-spacing: 0.01em; font-size: var(--fs-m); }
.topbar-spacer { flex: 1; }
#who { color: var(--muted); font-size: var(--fs-m); }
.banner {
  background: rgba(210, 153, 34, 0.14);
  border: 1px solid rgba(210, 153, 34, 0.5);
  color: var(--warn);
  padding: 3px 10px;
  border-radius: 999px;
  font-size: var(--fs-s);
  font-weight: 600;
}

/* ---- layout ------------------------------------------------------------------ */

.container {
  max-width: 1120px;
  margin: 0 auto;
  padding: 20px;
}

/* ---- login -------------------------------------------------------------------- */

#login-view { display: flex; justify-content: center; padding: 9vh 20px 40px; }
#login-form {
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--r-l);
  padding: 28px;
  width: 360px;
  max-width: 100%;
  display: flex;
  flex-direction: column;
}
#login-form h1 { margin-bottom: 2px; }
.login-sub { margin: 0 0 18px; color: var(--muted); font-size: var(--fs-m); }
#login-btn { margin-top: 18px; }
.login-help {
  margin-top: 18px;
  padding-top: 14px;
  border-top: 1px solid var(--line);
}
.help-heading {
  margin: 0 0 6px;
  font-size: var(--fs-s);
  font-weight: 600;
  color: var(--muted);
}
.login-help ol { margin: 0; padding-left: 18px; color: var(--muted); font-size: var(--fs-m); }
.login-help li { margin: 2px 0; }
.login-help .hint { margin: 8px 0 0; }
.hint { color: var(--faint); font-size: var(--fs-s); }

.error { color: var(--danger); font-size: var(--fs-m); margin: 12px 0 0; }
/* The focus error is a sibling of the stage, not a child of the rail: at
   <=760px the rail is translated off the screen, so an error inside it is
   never seen. It reserves one line whether or not it has text — a message
   appearing must not push the stage (the command-lifecycle box is constant
   for the same reason). [hidden] still wins, so an empty error takes nothing. */
#focus-error {
  position: absolute;
  left: 16px;
  right: 16px;
  bottom: 16px;
  z-index: 6;
  margin: 0;
  padding: 8px 10px;
  background: var(--surface);
  border: 1px solid var(--danger);
  border-radius: var(--r-m);
}
.result { color: var(--muted); font-size: var(--fs-s); margin: 8px 0 0; min-height: 1.2em; }

/* ---- grid ---------------------------------------------------------------------- */

#grid-toolbar {
  display: flex;
  align-items: center;
  gap: 12px;
  flex-wrap: wrap;
  margin-bottom: 14px;
}
.grid-heading { display: flex; align-items: baseline; gap: 10px; flex: 1; }

#grid-filters { display: flex; align-items: center; gap: 8px; }
.filter-label {
  margin: 0;
  font-size: var(--fs-s);
  font-weight: 500;
  color: var(--faint);
  text-transform: uppercase;
  letter-spacing: 0.06em;
}
#grid-filters select {
  width: auto;
  padding: 5px 8px;
  font-size: var(--fs-s);
  font-weight: 500;
}

#device-grid {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
  gap: 14px;
}

.tile {
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--r-l);
  padding: 10px;
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.tile:hover { border-color: var(--line-strong); background: var(--raised); }

/* Per-card severity edge: the state tint stops being a hairline and becomes a
   2px edge (border + 1px inset shadow, so nothing reflows) on the states an
   operator is sorting for — offline/error red, stale amber, live green, and
   ONLY those; held/busy keep the subtle hairline and idle stays plain. */
.tile-st-live { border-color: rgba(99, 176, 106, 0.5); box-shadow: inset 0 0 0 1px rgba(99, 176, 106, 0.5); }
.tile-st-held { border-color: rgba(111, 159, 216, 0.3); }
.tile-st-busy { border-color: rgba(79, 179, 191, 0.3); }
.tile-st-stale { border-color: rgba(210, 153, 34, 0.6); box-shadow: inset 0 0 0 1px rgba(210, 153, 34, 0.6); }
.tile-st-error { border-color: rgba(229, 83, 75, 0.6); box-shadow: inset 0 0 0 1px rgba(229, 83, 75, 0.6); }
.tile-st-offline { border-color: rgba(229, 83, 75, 0.6); box-shadow: inset 0 0 0 1px rgba(229, 83, 75, 0.6); }

.tile-id-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 6px;
}
/* Grid legibility: the farm NAME is the card's primary label — the largest,
   heaviest text on the tile. The machine UDID has no tile presence at all
   (G81 removed it from the operator's normal flow; it lives only behind the
   explicit affordances in app.js's renderMeta). */
.tile-id-row .tile-name {
  font-size: var(--fs-m);
  font-weight: 600;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.tile-id-row .chip { font-size: 10.5px; padding: 1px 8px; }

.thumb {
  width: 100%;
  aspect-ratio: 9 / 19;
  object-fit: contain;
  background: var(--stage-black);
  border-radius: var(--r-m);
  border: 1px solid var(--line);
}

/* Designed failure state, INSIDE the card frame (fix 1): device ID, what was
   lost, a Retry that drives the same loadGrid() as Refresh. The API carries no
   per-device last-seen timestamp, so there is deliberately none to show. */
.thumb-fallback {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 8px;
  background: var(--raised);
  border: 1px dashed var(--line-strong);
  text-align: center;
  padding: 10px;
}
/* The header above the box already carries the name + UDID tail, so the
   failure state states only what was lost (offline/feed lost) and offers the
   Retry — same loadGrid() as Refresh (app.js feedFallback). */
.thumb-fallback .tf-label {
  font-size: var(--fs-s);
  font-weight: 500;
  letter-spacing: 0.04em;
  color: var(--muted);
}
.thumb-fallback.st-offline .tf-label { color: var(--st-offline); }
.thumb-fallback.st-error .tf-label { color: var(--st-error); }
.tf-retry { padding: 4px 12px; font-size: var(--fs-s); }

/* metaRow now carries only "held by …" (the name moved up to lead the card) */
.tile-meta { display: flex; align-items: center; justify-content: space-between; gap: 6px; }
.tile-chips { display: flex; flex-wrap: wrap; gap: 6px; min-height: 20px; }
.tile-held { font-size: var(--fs-s); color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tile .btn { margin-top: auto; }

/* loading skeletons — tile-shaped, opacity pulse only */
.tile-skel .skel-thumb {
  width: 100%;
  aspect-ratio: 9 / 19;
  border-radius: var(--r-m);
  background: var(--raised);
  animation: pulse 1.3s ease-in-out infinite;
}
.tile-skel .skel-line {
  height: 11px;
  border-radius: var(--r-s);
  background: var(--raised);
  animation: pulse 1.3s ease-in-out infinite;
}
.tile-skel .skel-line.w60 { width: 60%; }
.tile-skel .skel-line.w40 { width: 40%; animation-delay: 0.15s; }
.tile-skel .skel-thumb { animation-delay: 0.07s; }
@keyframes pulse { 50% { opacity: 0.45; } }

.empty {
  border: 1px dashed var(--line-strong);
  border-radius: var(--r-l);
  padding: 48px 20px;
  text-align: center;
  color: var(--muted);
}
.empty-title { margin: 0 0 6px; font-weight: 600; color: var(--ink); }
.empty-hint { margin: 0; font-size: var(--fs-m); }

.error-banner {
  display: flex;
  align-items: center;
  gap: 12px;
  background: rgba(229, 83, 75, 0.1);
  border: 1px solid rgba(229, 83, 75, 0.45);
  color: var(--danger);
  border-radius: var(--r-m);
  padding: 10px 14px;
  margin-bottom: 14px;
  font-size: var(--fs-m);
}
.error-banner span { flex: 1; }

/* ---- focus ---------------------------------------------------------------------- */

#focus-toolbar { display: flex; align-items: flex-start; gap: 10px; flex-wrap: wrap; }

#focus-heading {
  border-left: 3px solid var(--line-strong);
  padding-left: 12px;
  display: flex;
  flex-direction: column;
  gap: 2px;
}
/* The focus header reads the same enum: the border takes the device's state. */
#focus-view[data-state="live"] #focus-heading { border-left-color: var(--st-live); }
#focus-view[data-state="held"] #focus-heading { border-left-color: var(--st-held); }
#focus-view[data-state="busy"] #focus-heading { border-left-color: var(--st-busy); }
#focus-view[data-state="stale"] #focus-heading { border-left-color: var(--st-stale); }
#focus-view[data-state="idle"] #focus-heading { border-left-color: var(--line-strong); }
#focus-view[data-state="error"] #focus-heading { border-left-color: var(--st-error); }
#focus-view[data-state="offline"] #focus-heading { border-left-color: var(--st-offline); }

.eyebrow {
  font-size: 10.5px;
  font-weight: 600;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--muted);
}
.focus-title-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
/* Polish: let the heading shrink so the toolbar chips wrap cleanly instead of
   pushing the toolbar wider at narrow widths. */
#focus-heading { min-width: 0; flex: 1 1 240px; }
#focus-name { font-size: var(--fs-xl); font-weight: 600; }
#focus-chips { min-height: 22px; }
#stream-chip-label { font-variant-numeric: tabular-nums; }
#focus-lease { font-variant-numeric: tabular-nums; }
/* Asked for H.264, got MJPEG: a ~20x egress bill, so it is not styled as normal. */
.chip.codec-warn { color: var(--warn); border-color: rgba(210, 153, 34, 0.5); }

.focus-layout {
  display: grid;
  /* The stage column sizes to the stage itself (the stage sizes to the
     viewport height); the rail takes the rest. */
  grid-template-columns: max-content minmax(300px, 1fr);
  gap: 20px;
  align-items: stretch;
}
@media (max-width: 760px) {
  .focus-layout { grid-template-columns: 1fr; }
}

/* The phone panel IS the product: size the stage to the viewport height. The
   wrap carries the aspect ratio itself (and keeps it here — the drill greps for
   it), so it keeps its height while BOTH media elements are hidden — between
   focus and a negotiated codec there is nothing in it but #stage-status, and a
   zero-height box is how "connecting…" and every stream error became invisible.
   On wide screens the HEIGHT drives the box (aspect-ratio derives the width);
   on narrow screens it falls back to width-driven so it always fits. */
#stage-wrap {
  position: relative;
  width: 100%;
  max-width: 320px;
  margin: 0 auto;
  background: var(--stage-black);
  border: 1px solid var(--line);
  border-radius: var(--r-l);
  overflow: hidden;
  aspect-ratio: 9 / 19;
}
@media (min-width: 761px) and (min-height: 600px) {
  #stage-wrap {
    width: auto;
    max-width: 45vw;
    height: min(calc(100vh - 200px), 860px);
  }
}
/* Both stage elements carry identical geometry on purpose: the tap/swipe
   mapping normalises against the rendered rect, so a negotiated codec must not
   be able to move where a tap lands. touch-action: none is what keeps the
   phone browser from stealing a swipe as a scroll or a double-tap zoom — and
   it does that on the stage ONLY, nowhere else on the page (the viewport meta
   keeps pinch-zoom of the page itself available). */
#stage, #stage-video, #stage-canvas {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: contain;
  touch-action: none;
  cursor: crosshair;
  user-select: none;
  -webkit-user-select: none;
  -webkit-touch-callout: none;
}
/* View-only zoom (app.js pinch): the transform rides CSSOM custom writes, and
   the wrap's overflow:hidden is the crop window the zoom pans across. */
#stage, #stage-video, #stage-canvas { will-change: transform; }

/* IOS-STREAM-FIX D3/D4: the focus-entry poster rides THIS overlay element, not
   the stage <img>'s src — the img transports (startStreamImg / the adopted
   prewarm) swap img.src themselves, and a still parked there died at the swap,
   leaving the slow-drip bring-up leg black AND unlabeled. Same geometry as the
   stage surfaces (the wrap's aspect-ratio box), pointer-events:none so a tap
   during bring-up still reaches the stage input gate's honest refusal instead
   of a dead zone. It sits before #stage-status in DOM order, so the label
   paints over the still; removed by app.js stagePosterEnd at the first real
   paint. */
.stage-poster {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: contain;
  pointer-events: none;
}

/* Reset from a view zoom (app.js #stage-unzoom): overlaid on the stage, only
   visible once a pinch has zoomed in. */
.stage-unzoom {
  position: absolute;
  top: 10px;
  right: 10px;
  z-index: 5;
  touch-action: manipulation;
  background: rgba(21, 26, 33, 0.85);
}

/* Bottom-sheet grip: exists only where the rail IS a sheet (≤760px). On
   desktop it is display:none, so it is neither visible nor focusable and the
   rail's markup is unchanged for the desktop layout. */
#sheet-handle {
  display: none;
}
/* The command sentence. A reserved line in the toolbar, not a scrim on the
   phone. Empty still occupies the line, so the stage below it does not jump
   when a command starts or ends. */
.cmd-state {
  margin: 0;
  min-height: 1.4em;
  color: var(--muted);
  font-size: var(--fs-s);
}
.stage-status {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 16px;
  text-align: center;
  color: var(--muted);
  font-size: var(--fs-m);
  /* PORTAL-UX-ENTRY: this line is the poster's honesty label ("still preview —
     the live stream is starting…", LATENCY-FIX) and every stream error — it
     renders OVER device pixels, and muted grey over a busy phone wallpaper
     (observed live on the home screen) is unreadable. The shadow lifts the
     text off the picture without a scrim that would dim the still; the text
     itself is untouched (same copy, same element, still pointer-events:none). */
  text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9), 0 0 14px rgba(0, 0, 0, 0.7);
  pointer-events: none;
}

/* ---- the rail: grouped cards ----------------------------------------------------- */

.rail {
  display: flex;
  flex-direction: column;
  gap: 12px;
  min-width: 0;
}
.panel {
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--r-l);
  padding: 12px 14px;
}
.panel summary {
  cursor: pointer;
  font-weight: 600;
  font-size: var(--fs-m);
  list-style-position: inside;
}
.panel summary:hover { color: var(--accent-strong); }
.panel[open] summary { margin-bottom: 10px; }

#type-row { display: flex; gap: 8px; margin-top: 8px; }
#type-text { flex: 1; }

/* Premade directional swipes (app-parity): a cross pad under Send keys, same
   card padding/radius tokens as the rest of the rail, same .btn hover/active/
   focus states — the pad adds no colors of its own. Grid areas place the four
   arrows in a cross; the empty corners stay empty. */
#swipe-pad {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 6px;
  max-width: 186px;
  margin-top: 8px;
}
#swipe-pad .swipe-btn {
  padding: 8px 0;
  font-size: var(--fs-l);
  line-height: 1;
  min-width: 0;
}
#swipe-up { grid-column: 2; grid-row: 1; }
#swipe-left { grid-column: 1; grid-row: 2; }
#swipe-right { grid-column: 3; grid-row: 2; }
#swipe-down { grid-column: 2; grid-row: 3; }

/* device/session metadata card — fills the middle of the rail */
.meta-list {
  margin: 10px 0 0;
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 5px 14px;
  align-items: baseline;
}
.meta-list dt {
  font-size: var(--fs-s);
  font-weight: 500;
  color: var(--muted);
  white-space: nowrap;
}
.meta-list dd {
  margin: 0;
  font-size: var(--fs-s);
  text-align: right;
  font-variant-numeric: tabular-nums;
  overflow-wrap: anywhere;
}
.meta-list dd.mono { font-size: var(--fs-s); }

.gated-body, .jobs-body, .recover-body { display: flex; flex-direction: column; }
.gated-body .btn, .jobs-body .btn, .recover-body .btn { align-self: flex-start; margin-top: 10px; }
.confirm-echo { font-size: var(--fs-s); color: var(--muted); margin: 0 0 8px; }

/* ONE danger pattern (fix 4): red-tinted card, red controls, pinned to the
   bottom of the rail. Release (hold-to-press) and admin recover (typed
   device-key confirm + step-up) share this single treatment. */
#danger-panel {
  margin-top: auto;
  border-color: rgba(229, 83, 75, 0.45);
  background: rgba(229, 83, 75, 0.04);
}
#danger-panel h3 { color: var(--danger); }
#focus-release { width: 100%; margin-top: 10px; }
.danger-hint { margin: 7px 0 0; font-size: var(--fs-s); color: var(--faint); }
#recover-panel { margin-top: 12px; border-top: 1px solid rgba(229, 83, 75, 0.25); padding-top: 10px; }
#recover-panel summary { color: var(--danger); }
#recover-panel summary:hover { color: var(--danger); }

/* ---- mobile: phone-in-phone (≤760px) ---------------------------------------------
   The same vanilla markup, re-laid out for a phone held in one hand:
     grid view  → a horizontal snap-scrolling strip of live thumbnails
     focus view → a fixed full-bleed stage (device id/state/exit on top) with
                  the whole control rail as a bottom sheet over it
   The breakpoint is the same one app.js's MOBILE media query tests, so JS
   behavior (thumb taps, sheet collapse, pinch zoom) and this layout can never
   disagree about what kind of screen they are on. Nothing above this block
   applies only to mobile; nothing here applies at >760px. */
@media (max-width: 760px) {
  /* viewport-fit=cover makes the page extend under the notch, so every fixed
     bar pads itself with the real inset instead of hoping for a margin. */
  #topbar {
    padding-top: calc(9px + env(safe-area-inset-top));
    padding-bottom: 9px;
    padding-left: 12px;
    padding-right: 12px;
    gap: 8px;
  }
  /* The signed-in email is the widest thing in the bar: unbounded, it pushes
     Log out off the right edge of a 390px phone (seen live in the first
     cutover's own screenshot). Ellipsize it — the Account view names the
     operator in full. */
  #who {
    max-width: 32vw;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  /* No stray width may open a sideways scroll on the grid page: every mobile
     view is a vertical stack, so horizontal overflow is always a defect. */
  body { overflow-x: hidden; }
  /* iOS Safari zooms the page when a focused control's font-size is under
     16px. The floor is on the control, not the viewport: maximum-scale and
     user-scalable=no would stop the zoom by taking pinch-zoom away, which is
     the accessibility regression. Desktop keeps the smaller ramp. */


  /* Grid: two columns, vertical. A snap strip hid tiles past the first two
     and elided names. Names stay visible; the focus view still carries detail. */
  #device-grid {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 10px;
    overflow-x: visible;
    padding-bottom: 8px;
  }
  #device-grid .tile {
    width: auto;
    min-width: 0;
    margin: 0;
  }
  #device-grid .tile-name {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  /* Hold-to-Release inside the sheet: selection UI on a held button is exactly
     the long-press conflict the hold gate must not fight. */
  .btn { -webkit-user-select: none; user-select: none; }

  /* Focus: the stage IS the page. Fixed, notch-to-sheet. IOS-ROUND2 (N1): the
     height is the SMALL viewport (svh), not the dynamic one — 100dvh tracks the
     collapsing Safari toolbar, so a layout sized to dvh extends under the
     bottom toolbar whenever the chrome is expanded (measured on-device, real
     iOS WebKit: the control sheet + Release sat below the toolbar at max
     scroll and no scroll could bring them back, because the stage's
     touch-action:none owns every drag). svh is the smallest viewport the chrome
     can present, so the fixed view — and everything anchored inside it —
     always fits the visible page, toolbar present or collapsed. The 100vh
     line stays as the fallback for engines without svh. */
  body.in-focus { overflow: hidden; }
  .in-focus #topbar { display: none; }   /* the focus toolbar already carries id/state/exit */
  #focus-view {
    position: fixed;
    inset: 0;
    z-index: 20;
    height: 100vh;
    height: 100dvh;
    background: var(--bg);
    overflow: hidden;
  }
  #focus-view .container {
    display: flex;
    flex-direction: column;
    height: 100%;
    max-width: none;
    padding: 8px 12px calc(104px + env(safe-area-inset-bottom));
  }
  #focus-toolbar { flex: none; align-items: center; gap: 5px; padding: 2px 4px 6px; }
  /* ONE row: back, name, chips, Home. Everything at caption size — the chips
     are health, not a headline; the stream is the headline. */
  #focus-heading .eyebrow { display: none; }
  #focus-heading { padding-left: 0; border-left: none; }
  #focus-name { font-size: var(--fs-m); }
  /* The chip row wraps on a phone (stream + codec + lease + the G41 command
     chip can exceed one line at 390px): the 4px column gap is right inside a
     row, but WRAPPED rows need their own air or the second chip row sits
     pressed against the first — the entry header is where the wrap happens. */
  #focus-chips { gap: 4px; row-gap: 6px; }
  /* The command chip is appended to this row on the first command, and at
     390px that chip wraps onto a second line. A row that grows pushes the
     stage down, so the second line is reserved whether or not the chip is
     there yet — the stage box is then the same from idle through failed. */
  #focus-chips { min-height: 54px; }
  /* The stage fills a pinned slot. flex:1 plus height:auto let a chip wrap
     or a sheet opening change the phone's box, which is the resize the
     operator sees as the screen jumping. The slot is the small viewport
     minus the toolbar and the sheet, and it does not track the command line. */
  #stage-wrap {
    flex: 0 0 auto;
    height: calc(100svh - 168px - env(safe-area-inset-bottom));
    max-height: calc(100svh - 168px - env(safe-area-inset-bottom));
  }
  .chip { font-size: var(--fs-s); padding: 2px 7px; }
  #focus-toolbar .btn { padding: 6px 9px; font-size: var(--fs-s); }
  .focus-layout {
    display: flex;
    flex-direction: column;
    flex: 1;
    min-height: 0;
    gap: 0;
  }
  /* The error sits OVER the stage, not under it: a line below the stage is
     off the bottom of a 390px screen, and a line in the flow would shrink
     the stage when the message appears. The stage box is the thing that
     must not move. */
  #focus-error {
    position: absolute;
    left: 12px;
    right: 12px;
    bottom: calc(108px + env(safe-area-inset-bottom));
    z-index: 33;
    margin: 0;
    padding: 8px 10px;
    background: var(--surface);
    border: 1px solid var(--danger);
    border-radius: var(--r-m);
  }
  #stage-wrap {
    flex: 1 1 auto;
    width: 100%;
    max-width: none;
    height: auto;
    aspect-ratio: auto;   /* the viewport supplies the height here; the desktop rule keeps its own */
    margin: 0;
    border: none;
    border-radius: 0;
  }

  /* The control rail as a collapsible bottom sheet over the stage. Collapsed
     slides everything below the 52px handle off-screen; the handle is the
     rail's first child, so it is the sheet's head in both states. */
  .rail {
    /* A POPUP, not a layout column: the stage owns the full screen and the
       controls slide up over it only when asked (the floating #sheet-handle
       pill). Sam's call after the always-expanded panel covered the whole
       bottom. Absolute inside the fixed 100svh focus view (IOS-ROUND2 N1: svh,
       not dvh — a dvh-sized view extends under the expanded Safari toolbar, and
       the sheet anchored inside it went with it), and --kb lifts it above the
       keyboard. Closed = pushed fully below the screen; open = floats over the
       lower stage. */
    position: absolute;
    left: 8px;
    right: 8px;
    bottom: calc(62px + env(safe-area-inset-bottom) + var(--kb, 0px));
    z-index: 32;
    /* PORTAL-UX-ENTRY: 52dvh cut the danger zone's Release hint flush
       mid-sentence at the scroll fold (live + fixture-measured: the first
       four panels need ~425px, the fold gave ~439px at 844 — but ~373px on a
       667px phone, clipping the hint the moment the sheet opens). 56dvh fits
       the stream tools + Release without burying the stage. IOS-ROUND2 (N1):
       the bound is 56svh now — same number where it mattered (56% of 844 =
       473px either way), but svh never GROWS past the chrome-proof viewport,
       so sheet bottom (62px + safe-area inset) + sheet height can never reach
       past the fixed view's visible edge, toolbar expanded or collapsed. */
    max-height: 56svh;
    overflow-y: auto;
    /* N1 scroll containment: a drag that starts on the sheet scrolls the
       sheet and stops at its edge — it must never chain into the page (the
       stage's touch-action:none would then trap it as a device gesture). */
    overscroll-behavior: contain;
    -webkit-overflow-scrolling: touch;
    background: var(--surface);
    border: 1px solid var(--line-strong);
    border-radius: 16px;
    padding: 8px 12px calc(12px + env(safe-area-inset-bottom));
    box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.55);
    transform: translateY(calc(100% + 80px));
    transition: transform var(--t-med) ease;
  }
  .rail.sheet-open { transform: translateY(0); }
  /* the toggle lives at the right end of the quick bar */
  #sheet-handle {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 6px;
    min-width: 44px;
    min-height: 44px;
    height: 44px;
    padding: 0 12px;
    background: var(--surface);
    border: 1px solid var(--accent);
    border-radius: var(--r-m);
    color: var(--ink);
    font: 600 var(--fs-s)/1 inherit;
    font-family: inherit;
    /* "Close controls" (the open label) is wider than "Controls": without a
        nowrap the pill wraps to two lines and the quick bar's height jumps
        mid-session — one line, ellipsis if it ever cannot fit. */
    white-space: nowrap;
    cursor: pointer;
    touch-action: manipulation;
    flex: 1.2 1 44px;
  }
  .sheet-grip { display: none; }
  .sheet-handle[aria-expanded="true"] { border-color: var(--line-strong); color: var(--muted); }

  /* THE QUICK BAR: the desktop app's bottom control bar, portal edition — Home
     and the four premade swipes pinned to the bottom of the phone screen while
     the stream runs. Desktop keeps everything in the side rail, so this is
     display:none above 760px. */
  #quick-bar {
    position: absolute;
    left: 0;
    right: 0;
    bottom: calc(10px + env(safe-area-inset-bottom));
    z-index: 31;
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 4px;
    padding: 0 8px;
  }
  /* 44px is the floor for a primary touch control. The release pill and the
     sheet handle were under it (40 and 42), and the quick-bar buttons only
     cleared it when the row had room to spare. min-width/min-height hold the
     floor whatever the row does; the desktop layout never sees this block. */
  #quick-bar .btn {
    /* 15% puts exactly the six icon buttons on the first row (6 x 15% plus the
       gaps fit 390; a seventh does not), so Release and the sheet handle wrap
       to a second row whose width does not depend on their labels. One row
       cannot hold eight 44px controls at 390, and a label-driven wrap would
       jump the bar when the handle reads "Close controls". */
    flex: 1 0 15%;
    min-width: 44px;
    min-height: 44px;
    padding: 10px 0;
    font-size: var(--fs-l);
    text-align: center;
    background: var(--surface);
    border: 1px solid var(--line-strong);
    color: var(--ink);
  }
  #quick-bar .btn:disabled { opacity: 0.45; }
  /* IOS-ROUND2 (N1): the always-reachable release affordance. The on-device
     receipt: the sheet's own Release sat below the expanded Safari toolbar at
     max scroll and NO scroll could bring it into reach — an iPhone operator
     could not end their lease one-handed. This pill is app.js-created (the
     lane owns no index.html change), rides the quick bar — the ONE row pinned
     inside the safe area, above the sheet fold, visible whenever the focus
     view is — and carries the SAME hold-to-release gate as the danger-zone
     button (startHold/cancelHold, HOLD_MS sweep), never a lighter path. */
  #quick-bar .qb-release {
    flex: 1.2 1 44px;
    font-size: var(--fs-s);
    white-space: nowrap;
    border-color: var(--danger);
    color: var(--danger);
  }

  /* The pad is a single ROW now: a cross layout spends ~140px of height the
     42dvh panel does not have, and four arrows in a line read just as well. */
  #swipe-pad {
    max-width: none;
    width: 100%;
    margin: 6px 0 0;
    display: flex;
    flex-direction: row;
    gap: 8px;
  }
  #swipe-pad .swipe-btn {
    flex: 1;
    padding: 10px 0;
    font-size: 18px;
  }
  .rail .panel { padding: 10px 0; }
  .rail .panel h3 { margin-bottom: 7px; }
  #type-row { display: flex; gap: 6px; }
  #type-row input { flex: 1; min-width: 0; }
  .meta-list { font-size: var(--fs-s); }
  /* ORDER IS THE UX: the release button — the control a hands-on operator needs
     most — sits directly under the swipe pad, visible without scrolling. The
     device facts and the admin-only details panels go below the fold. */
  /* PORTAL-UX-ENTRY (2026-09-20): the Capture panel — added to the rail after
     this order block was written — has no id, so it silently fell out of the
     order list and computed order 0, which jumped it ABOVE Send keys on the
     phone (observed live: the sheet opened CAPTURE → SEND KEYS → SWIPE →
     DANGER, and the Release hint clipped flush at the sheet fold). The block's
     own contract is written right below: Release sits DIRECTLY under the swipe
     pad, visible without scrolling. So capture takes the slot AFTER the danger
     zone (a stream tool under the safety control), not between swipe and
     danger, and everything below shifts one slot. It is the rail's third
     <section> (nth-of-type counts sections, not .panel classes). Any FUTURE
     unnamed rail panel defaults to 0 again — the entry-layout drill pins the
     whole computed sequence, so a new panel cannot silently jump the keys a
     second time. */
  .rail { display: flex; flex-direction: column; }
  .rail > .panel:nth-of-type(1) { order: 1; }   /* send keys */
  .rail > .panel:nth-of-type(2) { order: 2; }   /* swipe */
  .rail > #danger-panel { order: 3; }
  .rail > .panel:nth-of-type(3) { order: 4; }   /* capture — first of the secondary tools */
  .rail > #meta-panel { order: 5; }
  .rail > #gated-panel, .rail > #jobs-panel { order: 6; }
  /* The new controls ride below the established order: the agent chat, then
     its context editor. A panel without an order defaults to 0 and would jump
     ABOVE the keys on a phone — the popup's whole point is typing stays top. */
  .rail > #agent-panel, .rail > #ctx-panel { order: 7; }
}

/* ---- account view -------------------------------------------------------------
   One shell, two payloads: the owner's team/invites/matrix and the
   operator's read-only self-view. Every row, chip and dot below reuses the
   tokens the grid already speaks — the account view adds no new colors, it
   only adds the two role chips, which are identity, not device state. */

#account-toolbar {
  display: flex;
  align-items: flex-start;
  gap: 10px;
  margin-bottom: 18px;
}
#account-heading {
  border-left: 3px solid var(--line-strong);
  padding-left: 12px;
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
}
#account-heading h2 { font-size: var(--fs-xl); }

.section-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  margin: 24px 0 10px;
}
#account-owner .section-head:first-child { margin-top: 4px; }

.card-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.card-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--r-m);
  padding: 10px 14px;
}
.card-row-tap { cursor: pointer; }
.card-row-tap:hover { border-color: var(--line-strong); background: var(--raised); }
.row-main {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-wrap: wrap;
  min-width: 0;
  flex: 1;
}
.row-actions { display: flex; align-items: center; gap: 8px; flex: none; }
.row-chev { color: var(--faint); font-size: 20px; line-height: 1; }
.team-email { font-weight: 600; font-size: var(--fs-m); overflow-wrap: anywhere; }
.muted-line { color: var(--muted); font-size: var(--fs-s); margin: 2px 0 0; }

.empty .btn { margin-top: 14px; }

/* Role chips: text-only (no state dot — a role is not a device state).
   OWNER takes the accent, VA stays muted: the reader scanning for who can
   grant access is looking for the accent. */
.role-chip::before { display: none; }
.role-chip { font-size: 10.5px; padding: 1px 8px; letter-spacing: 0.05em; }
.role-chip.role-owner { color: var(--accent-strong); border-color: rgba(111, 159, 216, 0.45); }
.role-chip.role-operator { color: var(--muted); }

/* "Active" is membership, not a device state, so it borrows the accent's
   hollow ring — green stays reserved for a live feed, per the enum. */
.chip.chip-status { color: var(--accent-strong); border-color: rgba(111, 159, 216, 0.45); }
.chip.chip-status::before { background: transparent; border: 1.5px solid var(--accent-strong); }

/* Row revoke is quiet until pointed at — one danger treatment, not a red
   button on every row. */
.btn.danger-ghost { background: transparent; border-color: transparent; color: var(--muted); }
.btn.danger-ghost:hover:not(:disabled) {
  color: var(--danger);
  border-color: rgba(229, 83, 75, 0.4);
  background: rgba(229, 83, 75, 0.08);
}

/* Column-header dots: the state enum compressed to a shape that survives a
   narrow column; the drill-in rows carry the full labeled chips instead. */
.dot {
  width: 8px; height: 8px;
  border-radius: 50%;
  display: inline-block;
  flex: none;
}
.dot.st-live { background: var(--st-live); }
.dot.st-held { background: transparent; border: 1.5px solid var(--st-held); width: 7px; height: 7px; }
.dot.st-busy { background: var(--st-busy); border-radius: 2px; transform: rotate(45deg); }
.dot.st-stale { background: transparent; border-left: 5px solid transparent; border-right: 5px solid transparent; border-bottom: 8px solid var(--st-stale); border-radius: 0; }
.dot.st-idle { background: transparent; border: 1.5px solid var(--faint); width: 7px; height: 7px; }
.dot.st-error { background: var(--st-error); }
.dot.st-offline { background: var(--st-offline); }

/* The matrix: real table semantics (a keyboard user arrows through it like
   any other table), with checkbox cells that inherit the row's hover. */
.matrix {
  width: 100%;
  border-collapse: separate;
  border-spacing: 0;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--r-l);
  font-size: var(--fs-m);
}
.matrix th {
  text-align: left;
  font-size: var(--fs-s);
  font-weight: 600;
  color: var(--muted);
  padding: 9px 10px;
  border-bottom: 1px solid var(--line);
  white-space: nowrap;
}
.matrix td { padding: 7px 10px; border-bottom: 1px solid var(--line); }
.matrix tbody tr:last-child td { border-bottom: none; }
.matrix tbody tr:hover { background: var(--raised); }
.matrix .col-head { display: flex; align-items: center; gap: 6px; }
.matrix .col-name { font-size: var(--fs-s); }
.matrix td .team-email { margin-right: 8px; }
.matrix .row-busy { opacity: 0.55; }
.matrix .row-busy input { pointer-events: none; }

/* Checkboxes opt OUT of the text-input treatment (which is width:100%) and
   take the accent instead of the UA blue. */
.matrix input[type="checkbox"],
.assign-row input[type="checkbox"],
.picker-all input[type="checkbox"] {
  width: 16px; height: 16px;
  min-width: 16px;
  padding: 0;
  accent-color: var(--accent);
  cursor: pointer;
}

/* Mobile swap: the table and the drill-in never render at the same width.
   >760px the list/detail are dead weight (display:none), ≤760px the table
   is — a 390px-wide matrix would be a horizontal scroll trap, so it is not
   attempted. */
@media (min-width: 761px) {
  .matrix-mobile, .operator-detail { display: none; }
}

.operator-detail {
  display: flex;
  flex-direction: column;
  gap: 8px;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--r-l);
  padding: 12px 14px;
}
.detail-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.operator-detail .btn.ghost { align-self: flex-start; }

.assign-row {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 9px 12px;
  border: 1px solid var(--line);
  border-radius: var(--r-m);
  background: var(--raised);
  cursor: pointer;
  margin: 0;
}
.assign-row:hover { border-color: var(--line-strong); }
.assign-row .assign-name { flex: 1; font-size: var(--fs-m); overflow-wrap: anywhere; }
.assign-row .hint { flex-basis: 100%; margin: 0; }
.assign-row-all { border-color: var(--line-strong); font-weight: 600; }
.assign-row-all .assign-name { flex: 0 1 auto; }
.assign-row-all .hint { flex-basis: auto; margin-left: auto; }
#assign-rows.all-on .assign-row { opacity: 0.45; }

/* ---- invite modal --------------------------------------------------------------- */

.modal-backdrop {
  position: fixed;
  inset: 0;
  z-index: 60;
  background: rgba(7, 9, 12, 0.7);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 16px;
  padding-bottom: calc(16px + 100dvh - 100svh);
}
.modal {
  background: var(--surface);
  border: 1px solid var(--line-strong);
  border-radius: var(--r-l);
  padding: 20px;
  width: 460px;
  max-width: 100%;
  max-height: calc(100dvh - 32px);
  overflow-y: auto;
  box-shadow: 0 18px 48px rgba(0, 0, 0, 0.5);
}
.modal-narrow { width: 420px; }
.modal h2 { margin-bottom: 10px; }
.modal-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  margin-bottom: 8px;
}
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; }

/* The confirm modal's hold sweep runs over the same CONFIRM_HOLD_MS the JS
   enforces — a sweep and a gate on different clocks would let a completed
   sweep fire nothing, or a fire happen under an incomplete sweep. */
.modal .hold-btn.holding::after { transition-duration: 900ms; }

#invite-form { display: flex; flex-direction: column; }
#invite-form label { margin: 10px 0 4px; }
#invite-form label:first-child { margin-top: 0; }

#assign-picker {
  border: 1px solid var(--line);
  border-radius: var(--r-m);
  padding: 8px 12px 12px;
  margin: 14px 0 0;
  min-width: 0;
}
#assign-picker legend {
  font-size: var(--fs-s);
  font-weight: 600;
  color: var(--muted);
  padding: 0 6px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
}
.picker-all {
  display: flex;
  align-items: center;
  gap: 8px;
  margin: 2px 0 10px;
  font-weight: 600;
  color: var(--ink);
}
.picker-all .hint { margin-left: auto; }
.assign-rows {
  display: flex;
  flex-direction: column;
  gap: 6px;
  max-height: 220px;
  overflow-y: auto;
}

/* The share moment: a check that earns its space, the link in a copyable
   block, and the one sentence that says what the link actually is. */
.done-badge {
  width: 44px; height: 44px;
  border-radius: 50%;
  border: 2px solid var(--accent);
  color: var(--accent-strong);
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 22px;
  font-weight: 600;
  margin: 4px 0 14px;
  animation: done-in 250ms ease;
}
.done-badge::before { content: "✓"; }
@keyframes done-in { from { transform: scale(0.8); opacity: 0; } }
.done-email { font-weight: 600; color: var(--ink); }
.link-box {
  display: flex;
  align-items: center;
  gap: 10px;
  margin: 14px 0 10px;
  background: var(--raised);
  border: 1px solid var(--line-strong);
  border-radius: var(--r-m);
  padding: 10px 12px;
}
.invite-link-text {
  flex: 1;
  overflow-wrap: anywhere;
  border: none;
  background: none;
  padding: 0;
}
.link-box .btn { flex: none; }

/* ---- operator self-view chips ------------------------------------------------------ */

.chip-list {
  list-style: none;
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin: 0;
  padding: 0;
}
.chip-item {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 999px;
  padding: 5px 12px;
}
.chip-item-name { font-size: var(--fs-m); font-weight: 600; }
.chip-item .chip, .preview-key { font-size: 10.5px; padding: 1px 8px; }

/* ---- invite accept page (/invite?code=) --------------------------------------------- */

.invite-container { display: flex; justify-content: center; padding-top: 8vh; }
.invite-card {
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--r-l);
  padding: 28px;
  width: 420px;
  max-width: 100%;
  display: flex;
  flex-direction: column;
}
.brand-mark { font-weight: 600; letter-spacing: 0.01em; color: var(--muted); margin-bottom: 20px; }
.invite-card h1 { font-size: var(--fs-xl); margin: 6px 0 10px; }
.invite-card #invite-preview-phones { margin: 2px 0 6px; }
.preview-key {
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 999px;
  padding: 5px 12px;
}
#invite-expiry { margin: 6px 0 4px; }
#accept-form { display: flex; flex-direction: column; margin-top: 12px; }
#accept-form label { margin: 10px 0 4px; }
#accept-btn { margin-top: 16px; }
#invite-dead .eyebrow { color: var(--warn); }
#dead-action { margin-top: 16px; text-decoration: none; text-align: center; }

/* Clipboard fallback target: reachable for selection, invisible to layout. */
.clip-helper {
  position: fixed;
  left: -9999px;
  top: 0;
  opacity: 0;
}

/* ---- account, mobile (≤760px) -------------------------------------------------------- */
@media (max-width: 760px) {
  #matrix-desktop { display: none; }
  #nav-account { padding: 6px 10px; }

  .card-row { padding: 12px 14px; }
  /* iOS Safari zooms a focused control under 16px. The floor is here, in the
     block that owns the forms, and it names the controls whose own rules
     would otherwise win: the grid filters set --fs-s and the context textarea
     inherits the body size. Desktop keeps the smaller ramp. Zoom stays on —
     the viewport meta is untouched. */
  input, select, textarea, #grid-filters select { font-size: 16px; }

  /* A row's facts cannot share one line at 390px — squeezing the email into
     a shrinking flex item wraps it letter by letter. Stack instead: facts on
     top, actions right-aligned under them. */
  .card-row { flex-direction: column; align-items: stretch; gap: 8px; }
  .row-actions { justify-content: flex-end; }
  /* The drill-in affordance would wrap onto its own line under the column
     stack; pin it to the row's corner instead. */
  .card-row-tap { position: relative; }
  .card-row-tap .row-chev { position: absolute; top: 14px; right: 14px; }

  /* Modals dock to the bottom like the focus sheet: thumb-reachable actions,
     one hand, no reach across the screen. */
  /* padding is a shorthand. Zeroing it here wiped the dvh/svh gap on the
     base .modal-backdrop, so a phone confirm sat under the expanded Safari
     toolbar. Keep the dock; keep the bottom push. */
  .modal-backdrop {
    align-items: flex-end;
    padding-top: 0;
    padding-right: 0;
    padding-left: 0;
    padding-bottom: calc(16px + 100dvh - 100svh);
  }
  .modal {
    width: 100%;
    max-height: 88dvh;
    border-radius: 16px 16px 0 0;
    border-left: none;
    border-right: none;
    border-bottom: none;
    padding: 18px 16px calc(18px + env(safe-area-inset-bottom));
  }

  .invite-container { padding-top: 5vh; }
  .invite-card { padding: 20px 18px; }
}

/* ---- capture + agent chat + agent context (the lease-bound tools) ------------
   All three reuse the panel/hint/btn/modal vocabulary above — the only new
   shapes are the chat log (a transcript, not an input) and the screenshot
   frame (a contain-fit image, like the stage). No new colors: the accent is
   the operator's actions, the danger hue is reserved for what can hurt. */

#agent-row { display: flex; gap: 6px; }
#agent-row input { flex: 1; min-width: 0; }

.agent-log {
  max-height: 200px;
  overflow-y: auto;
  border: 1px solid var(--line);
  border-radius: var(--r-m);
  background: var(--bg);
  padding: 8px 10px;
  margin-bottom: 8px;
  font-size: var(--fs-s);
}
.agent-log:empty { display: none; }
.agent-line { margin: 0 0 6px; color: var(--muted); overflow-wrap: anywhere; }
.agent-line:last-child { margin-bottom: 0; }
.agent-line.agent-user { color: var(--ink); }
.agent-line.agent-action { color: var(--accent); }
.agent-line.agent-summary { color: var(--st-live); }
.agent-line.agent-error { color: var(--danger); }
.agent-line.agent-status { color: var(--faint); font-style: italic; }

#ctx-panel label { display: block; margin: 10px 0 4px; }
#ctx-panel label:first-of-type { margin-top: 0; }
#ctx-panel textarea {
  background: var(--raised);
  color: var(--ink);
  border: 1px solid var(--line-strong);
  border-radius: var(--r-m);
  padding: 7px 10px;
  /* 16px, not inherit: the body is 13.5px, and iOS zooms a focused textarea
     under 16. The size holds at every width — a notes field is not denser for
     being on a desktop. */
  font: 16px inherit;
  width: 100%;
  resize: vertical;
}
#ctx-panel textarea:focus-visible { outline: 2px solid var(--accent); outline-offset: 0; }
#ctx-panel textarea::placeholder { color: var(--faint); }
#ctx-panel .btn { margin-top: 8px; }

.ctx-accounts { display: flex; flex-direction: column; gap: 6px; }
.ctx-account { display: flex; gap: 6px; }
.ctx-account input { min-width: 0; }
.ctx-account input:first-child { flex: 1; }
.ctx-account input:nth-child(2) { flex: 1; }
.ctx-account .btn { flex: 0 0 auto; padding: 7px 10px; }

#shot-img {
  max-width: 100%;
  max-height: 62dvh;
  border: 1px solid var(--line);
  border-radius: var(--r-m);
  background: var(--stage-black);
  display: block;
  margin: 10px auto 0;
}
#shot-status { min-height: 1.2em; }

@media (prefers-reduced-motion: reduce) {
  .chip.chip-connect::before,
  .chip.chip-live::before,
  .tile-skel .skel-thumb,
  .tile-skel .skel-line {
    animation: none;
  }
}
