:root {
  color-scheme: light dark;  /* native form controls + scrollbars track the mode */
  /* Duke brand tokens — https://brand.duke.edu/colors */
  --duke-navy: #012169;
  --duke-royal-blue: #00539B;
  --duke-light-blue: #0577B1;
  --duke-persimmon: #E89923;
  --duke-copper: #C84E00;

  --bg: #f5f5f7;
  --surface: #ffffff;
  --ink: #1d1d1f;
  --ink-muted: #6e6e73;
  /* translucent-on-ink borders — adapt to any bg without a mode override */
  --border-subtle:  rgba(0,0,0,0.06);
  --border-default: rgba(0,0,0,0.10);
  --border-strong:  rgba(0,0,0,0.15);
  --line: var(--border-default);  /* legacy alias */
  --bg-elev: rgba(0,0,0,0.05);  /* recessed track inside a --surface element */
  --accent: var(--duke-royal-blue);
  --accent-hover: var(--duke-navy);
  --accent-ink: #ffffff;
  --brand-heading: var(--duke-navy);  /* mode-aware heading color */
  --danger: #d70015;
  --success: #30a46c;
  --radius: 8px;  /* tighter — reads as panel, not floating card */
  --shadow: 0 1px 2px rgba(0,0,0,0.04);  /* kept minimal; floating surfaces set their own */

  /* Pip-core tokens — map the shared assistant module's vars onto our palette. */
  --pip-surface: var(--surface);
  --pip-ink: var(--ink);
  --pip-ink-muted: var(--ink-muted);
  --pip-border: var(--border-default);
  --pip-accent: var(--duke-persimmon);
}

@media (prefers-color-scheme: dark) {
  :root {
    /* Neutral near-black, not catwatcher's saturated navy — this is a tool, not a product surface */
    --bg: #1a1d20;
    --surface: #212529;
    --ink: #e9ecef;
    --ink-muted: #adb5bd;
    --border-subtle:  rgba(255,255,255,0.06);
    --border-default: rgba(255,255,255,0.10);
    --border-strong:  rgba(255,255,255,0.15);
    --bg-elev: rgba(255,255,255,0.05);
    --brand-heading: var(--duke-light-blue);  /* navy on near-black is unreadable; lift to light-blue */
    --shadow: 0 1px 2px rgba(0,0,0,0.3), 0 8px 24px rgba(0,0,0,0.5);
  }
}

* { box-sizing: border-box; }
/* Make HTML `hidden` attribute win over our own `display` rules. Without this,
   e.g. `button.icon { display: inline-flex }` shows a button marked hidden. */
[hidden] { display: none !important; }

html, body {
  margin: 0;
  height: 100%;
  overflow: hidden;  /* app-shell: scrolling happens inside <main>, not the page */
  background: var(--bg);
  color: var(--ink);
  font-family: system-ui, -apple-system, "SF Pro Text", "Helvetica Neue", Arial, sans-serif;
  font-size: 15px;
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
}

/* App shell — full-viewport chrome + flex-grow content region.
   Foundation for future detached panels, trays, and multi-region layouts. */
.app { display: flex; flex-direction: column; height: 100dvh; }

.topbar {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 0 20px;
  height: 52px;
  flex-shrink: 0;
  background: var(--bg);
  border-bottom: 1px solid var(--line);
}
.topbar-title {
  font-size: 17px;
  letter-spacing: -0.01em;  /* tighter tracking reads as wordmark */
  margin: 0;
}
.topbar-title-btn {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  background: transparent;
  border: none;
  padding: 4px 6px;
  margin: -4px -6px;
  border-radius: 6px;
  font: inherit;
  letter-spacing: inherit;
  color: inherit;
  cursor: pointer;
}
.topbar-title-btn:hover { background: var(--line); }
.topbar-title-caret {
  width: 12px;
  height: 12px;
  color: var(--ink-muted);
  margin-left: 2px;
}
/* Wordmark: "Better" (bold Duke navy/light-blue) + "Robotics" (regular ink).
   Hue shift (blue vs base text) + weight gives clear separation without color noise. */
.topbar-title .logo-accent { font-weight: 700; color: var(--brand-heading); }
.topbar-title .logo-base { font-weight: 400; color: color-mix(in srgb, var(--ink) 75%, transparent); }
.topbar-spacer { flex: 1; }
.topbar-actions { display: flex; gap: 6px; align-items: center; }
.topbar-actions .icon { font-size: 18px; }

/* Avatar button — classroom-local identity. Initials over seeded-hue bg (hsl-from-hash).
   When name is unset, falls back to default bg + "?" glyph. */
.avatar-btn {
  width: 32px;
  height: 32px;
  min-height: 32px;
  min-width: 32px;
  padding: 0;
  border: none;
  border-radius: 50%;
  background: var(--ink-muted);
  color: white;
  font-size: 12px;
  font-weight: 600;
  line-height: 1;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  transition: transform 0.1s ease-out, box-shadow 0.1s ease-out;
}
.avatar-btn:hover { transform: scale(1.05); box-shadow: 0 2px 6px rgba(0,0,0,0.15); }
.avatar-btn:active { transform: scale(0.95); }

.menu-header {
  padding: 6px 10px 8px;
  font-size: 12px;
  color: var(--ink-muted);
  border-bottom: 1px solid var(--line);
  margin-bottom: 4px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
main {
  flex: 1;
  overflow-y: auto;
  width: 100%;
  padding: 32px 24px 48px;
}

h1 {
  font-size: 28px;
  font-weight: 600;
  letter-spacing: -0.01em;
  margin: 0 0 8px;
  color: var(--brand-heading);
}

.card {
  background: var(--surface);
  border: 1px solid var(--border-default);
  border-radius: var(--radius);
  padding: 24px;
  margin-bottom: 16px;
}
.row {
  display: flex;
  align-items: center;
  gap: 16px;
  justify-content: space-between;
}

.label { font-weight: 500; display: flex; align-items: center; gap: 8px; }

.meta {
  color: var(--ink-muted);
  font-size: 13px;
  font-family: "SF Mono", ui-monospace, "JetBrains Mono", Menlo, monospace;
}
/* Prose counterpart to .meta: muted helper text that is NOT an identifier.
   .meta renders SF Mono (the "this is data" register) which reads as log
   output when used on explanatory copy. Use .hint for helper sentences. */
.hint {
  color: var(--ink-muted);
  font-size: 13px;
  line-height: 1.45;
}

button {
  font: inherit;
  border: none;
  cursor: pointer;
  padding: 10px 16px;
  border-radius: 10px;
  min-height: 44px;
  background: var(--accent);
  color: var(--accent-ink);
  font-weight: 500;
  transition: transform 0.1s cubic-bezier(0.2, 0.8, 0.2, 1), opacity 0.2s;
}
button:active { transform: scale(0.98); }
button:disabled { opacity: 0.4; cursor: not-allowed; }
/* Apple HIG-style hover/press. Hover brightens 8%, press darkens 6% —
   `filter` works relative to the element's own color so it's correct in
   light and dark modes. Secondary adds a subtle bg tint on top. */
button:not(:disabled):hover { filter: brightness(1.08); }
button:not(:disabled):active { filter: brightness(0.94); }
button.secondary { background: transparent; color: var(--ink); border: 1px solid var(--line); }
button.secondary:not(:disabled):hover { background: var(--border-subtle); }
button.sm { min-height: 36px; padding: 6px 12px; font-size: 14px; }
button.danger { background: var(--danger); }
/* Destructive + secondary — bordered, no fill, danger-red text. Apple HIG
   pattern for "risky but not the primary action." Used on Regenerate next to
   benign Copy/Import so the destructive weight is legible without screaming. */
button.secondary.danger {
  background: transparent;
  color: var(--danger);
  border-color: color-mix(in srgb, var(--danger) 35%, transparent);
}
button.secondary.danger:not(:disabled):hover {
  background: color-mix(in srgb, var(--danger) 8%, transparent);
}
button.icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  background: transparent;
  color: var(--ink-muted);
  border: 1px solid transparent;
  min-height: 36px;
  min-width: 36px;
  padding: 0 10px;
  font-size: 18px;
  line-height: 1;
}
button.icon:hover { background: var(--line); color: var(--ink); }
/* Pressed icon-button (aria-pressed=true): camera flip, future toggles.
   Subtle filled look without an extra class — aria-pressed already encodes
   the state for assistive tech, the visual just reflects it. */
button.icon[aria-pressed="true"] { background: var(--line); color: var(--ink); }
button.icon.sm { min-height: 28px; min-width: 28px; padding: 0 6px; font-size: 14px; }

/* SVG icons inherit size from font-size (1em) and color from currentColor. */
.icon-svg { width: 1em; height: 1em; display: inline-block; vertical-align: middle; }

/* form fields (prepare dialog) */
.field { margin-bottom: 12px; }
.field:last-child { margin-bottom: 0; }
.field label {
  display: block;
  font-size: 13px;
  color: var(--ink-muted);
  margin-bottom: 4px;
}
.field input, .field textarea {
  width: 100%;
  font: inherit;
  padding: 8px 10px;
  border: 1px solid var(--line);
  border-radius: 8px;
  background: var(--surface);
  color: var(--ink);
}
.field textarea {
  font-family: "SF Mono", ui-monospace, Menlo, monospace;
  font-size: 13px;
}
.field input:focus, .field textarea:focus {
  outline: none;
  border-color: var(--accent);
}
.field-overlay { position: relative; }
.field-overlay textarea {
  padding-right: 84px;
  padding-bottom: 32px;
  resize: none;  /* native resize grip lives where the overlay button sits */
}
.overlay-action {
  position: absolute;
  bottom: 10px;
  right: 10px;
  min-height: unset;
  padding: 4px 10px;
  font-size: 11px;
  font-weight: 400;
  line-height: 1.4;
  background: var(--surface);
  color: var(--ink-muted);
  border: 1px solid var(--line);
  border-radius: 6px;
}
.overlay-action:hover { color: var(--ink); background: var(--line); }

.menu {
  position: fixed;
  margin: 0;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 10px;
  box-shadow: 0 10px 30px rgba(0,0,0,0.12), 0 2px 8px rgba(0,0,0,0.04);
  padding: 6px;
  min-width: 200px;
  max-width: min(280px, calc(100vw - 16px));
}
.menu::backdrop { background: transparent; }
.menu-item {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  text-align: left;
  background: transparent;
  color: var(--ink);
  border: none;
  padding: 8px 10px;
  border-radius: 6px;
  font-size: 14px;
  min-height: 32px;
  cursor: pointer;
}
.menu-item .icon-svg { width: 16px; height: 16px; color: var(--ink-muted); flex-shrink: 0; }
.menu-item:hover { background: var(--line); }
.menu-item.destructive { color: var(--danger); }
.menu-item.destructive .icon-svg { color: var(--danger); }
.menu-item.destructive:hover { background: rgba(215, 0, 21, 0.08); }
a.menu-item { text-decoration: none; }
/* Menu groups: visual separator between conceptual sections (lifecycle /
   update / info / composition / connection). Border-top on every group
   except the first creates the divider; :has() hides any group whose
   items are all currently hidden so empty groups don't leave stray
   separators. */
.menu-group + .menu-group {
  margin-top: 4px;
  padding-top: 4px;
  border-top: 1px solid var(--line);
}
.menu-group:not(:has(.menu-item:not([hidden]))) {
  display: none;
}
.menu-meta {
  display: flex;
  justify-content: space-between;
  gap: 12px;
  padding: 8px 10px;
  font-size: 12px;
  color: var(--ink-muted);
}
.menu-meta-value { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.hard-refresh-list {
  margin: 0 0 12px;
  padding-left: 20px;
  font-size: 13px;
  color: var(--ink-muted);
}
.hard-refresh-list li { margin-bottom: 2px; }

.status {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  font-size: 13px;
  color: var(--ink-muted);
}
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--ink-muted); }
.status-row { display: inline-flex; align-items: center; gap: 6px; }
.dot.connected  { background: var(--success); }
.dot.error      { background: var(--danger); }
/* Pulsing amber while a connect is in flight. Same token as Pip's voice so the
   "activity happening" feeling is consistent across the UI. */
.dot.connecting { background: var(--duke-persimmon); animation: dot-pulse 1.4s ease-in-out infinite; }
.dot.firmware-down { background: var(--duke-persimmon); }
@keyframes dot-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
@media (prefers-reduced-motion: reduce) { .dot.connecting { animation: none; } }

/* Type badge — small pill next to the robot name identifying the hardware
   platform (Pi vs ESP32). Populated from fw-info.type on first connect. */
.type-badge {
  font-size: 10px;
  font-weight: 600;
  letter-spacing: 0.04em;
  padding: 2px 6px;
  border-radius: 4px;
  background: var(--border-subtle);
  color: var(--ink-muted);
  flex-shrink: 0;
}
.type-badge.type-pi    { color: var(--success); }
.type-badge.type-esp32 { color: var(--accent); }
/* Lightweight network-lane hint on the collapsed row. Signals "fast OTA path
   available" without the user having to expand. */
.transport-chip {
  font-size: 10px;
  font-weight: 500;
  letter-spacing: 0.04em;
  padding: 2px 6px;
  border-radius: 4px;
  background: var(--border-subtle);
  color: var(--success);
}

/* Card-style robot tile. Padding generous enough to read as a card (not a
   single-line pill). Left edge stripe carries status — colored stripe is more
   substantive than the previous in-row dot, and survives next to the action
   button without competing for visual weight. Default neutral; overridden by
   the .status-* classes below based on connect state. */
.robot {
  padding: 12px 16px 12px 19px;  /* +3px left to clear the stripe */
  border-left: 3px solid var(--border-subtle);
  /* No transition on stripe color — status changes are rare (a few per
     session) and the animated repaint earns no perceptible value. */
}
.robot.status-connected     { border-left-color: var(--success); }
.robot.status-connecting    { border-left-color: var(--duke-persimmon); }
.robot.status-error         { border-left-color: var(--danger); }
.robot.status-firmware-down { border-left-color: var(--duke-persimmon); }

/* Identity row gets a touch more presence as the card's headline.
   .robot-meta is the secondary line under it (WiFi state, uptime, etc) —
   only rendered when there's actual data, so empty cards stay compact. */
.robot .label-btn { font-size: 15px; }

/* Secondary row pairs the meta line and the CTA on one row — both are
   subordinate to the name. Saves ~25 px of vertical wasted space vs. the
   previous CTA-on-its-own-row layout. Meta aligned under the name (26 px
   matches the chevron + gap). CTA right-aligns via margin-left:auto so it
   sits at the card's far edge whether meta is present or not. */
.robot-secondary {
  display: flex;
  align-items: center;
  margin-top: 6px;
}
.robot-meta {
  margin-left: 26px;
  font-size: 12px;
  color: var(--ink-muted);
  font-variant-numeric: tabular-nums;
  /* Truncate so a long meta line doesn't push the CTA off the card. */
  flex: 1;
  min-width: 0;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* Empty wrapper hides itself — the wrapper is always emitted (so the
   patcher can fill it without renderEntry), but it shouldn't take layout
   space when there's no data yet. */
.robot-meta:empty { display: none; }

/* System line inside the expanded card body. Holds the precise diagnostic
   numbers (IP, RAM, temp, RSSI) that the primary-row meta deliberately
   omits to keep the list view glanceable. The body is the user's implicit
   "show me more" surface, so this row has room to be specific. Wraps
   instead of truncating — no width pressure here. */
.robot-system {
  margin: 8px 0 12px 26px;
  font-size: 12px;
  color: var(--ink-muted);
  font-variant-numeric: tabular-nums;
}
.robot-system:empty { display: none; }

/* Warning chips appear in the secondary row only when something is
   degraded (hot SoC, weak BLE link). The slot is always emitted; the
   patcher fills it. Empty slot collapses. */
.robot-warnings-slot:empty { display: none; }
.robot-warnings { display: inline-flex; gap: 6px; margin-left: 8px; }
.warning-chip {
  font-size: 11px;
  padding: 2px 8px;
  border-radius: 999px;
  font-variant-numeric: tabular-nums;
  border: 1px solid currentColor;
}
.warning-chip.warning-warm { color: #d4a44a; }
.warning-chip.warning-weak { color: #d4a44a; }
.warning-chip.warning-bad  { color: #d97a5a; }

/* Stale-handle hint — appears under the Re-pair button when Chrome's
   per-origin permission for this device has expired. Sets the user's
   expectation that the next click opens the OS chooser, and that the
   robot-side bond is fine. Same .meta visual register as the secondary
   row, indented to align with the body's content edge so it reads as
   "extra info about this card" rather than a top-level state line. */
.robot-stale-hint {
  margin: 8px 0 0 26px;
  max-width: 60ch;
  line-height: 1.4;
}
.telemetry:empty { display: none; }
.robot-cta {
  margin-left: auto;  /* right-aligns even when meta is empty */
}

/* Whole row is a generous click target — cursor hints it, but the visual
   hover lives on the label-btn only (the row whitespace is forgiveness, not
   affordance; the band extending under buttons that aren't toggles would
   false-advertise). */
.robot .row { cursor: pointer; }

/* Label is a button for keyboard/a11y (aria-expanded) but reads as text.
   Negative margin + internal padding lets the hover pill sit snug against
   the label without shifting layout when the pill appears. */
.robot-identity { min-width: 0; flex: 1; overflow: hidden; }
.label-btn {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 4px 6px;
  margin: -4px -6px;
  background: transparent;
  border: none;
  color: inherit;
  font: inherit;
  font-weight: 500;
  text-align: left;
  border-radius: 6px;
  cursor: pointer;
  /* Constrain so a long robot name + ESP32 pill can't overflow into the
     Disconnect button. Inner .robot-name handles the actual ellipsis. */
  max-width: 100%;
  min-width: 0;
}
/* Truncates if the name + badge would overflow. Chevron and badge keep
   their natural size (flex-shrink: 0 on .type-badge); only the name gives. */
.robot-name {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* No hover background: when something in the card re-renders (OTA progress
   ticks), innerHTML rebuild destroys/recreates this button and the transient
   loss of :hover registers as a visible flicker. Clickability is still carried
   by cursor:pointer on the row + focus-visible ring + the disclosure chevron. */
.label-btn:focus-visible {
  outline: none;
  /* Softer focus ring than outline — closer to Apple's native feel. */
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 35%, transparent);
}

/* "BetterRobot-" prefix is the same for every robot so only the hex suffix
   distinguishes. Dim the prefix and put the suffix in monospace so the eye
   snaps to the ID characters. */
.name-prefix { color: var(--ink-muted); font-weight: 400; }
.name-suffix { font-family: "SF Mono", ui-monospace, Menlo, monospace; font-weight: 500; }

/* Leading-edge disclosure triangle — macOS Finder / Mail thread pattern for
   in-place expansion. Lives inside the label-btn so it reads as part of the
   identity, not an action. Rotates from › (right, collapsed) to ⌄ (down,
   expanded). Purely visual; the whole label-btn is the click target. */
.disclosure-chevron {
  width: 12px;
  height: 12px;
  color: var(--ink-muted);
  opacity: 0.85;  /* 0.6 was barely visible on dark theme */
  flex-shrink: 0;
  transform: rotate(-90deg);
  transition: transform 0.2s ease-out;
}
.robot.expanded .disclosure-chevron { transform: rotate(0); }
.robot-actions { display: flex; gap: 6px; align-items: center; }

/* Active-ops chips — at-a-glance "what's happening" without expanding
   each cap row. Sits next to the meta line in the secondary row. Each
   chip is small and unobtrusive; the LACK of chips is the steady state. */
.robot-ops {
  display: flex;
  flex-wrap: wrap;
  gap: 4px;
  margin-left: 8px;
}
.robot-ops .op-chip {
  font-size: 11px;
  padding: 1px 7px;
  border-radius: 999px;
  background: color-mix(in srgb, var(--accent) 18%, transparent);
  color: var(--accent);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
@media (prefers-color-scheme: dark) {
  .robot-ops .op-chip { color: #5aa9d6; }
}

@media (prefers-reduced-motion: reduce) {
  .disclosure-chevron { transition: none; }
}

/* Narrow viewports stack into a single column — iOS HIG list pattern. The
   400px minmax grid at small widths squeezes cards uncomfortably. */
@media (max-width: 720px) {
  #robot-list { grid-template-columns: 1fr; }
}

/* Collapsible body — rendered only when expanded so capability DOM (camera
   streams especially) isn't mounted-but-hidden, burning bandwidth. */
.robot-body {
  margin-top: 12px;
  /* Light indent so capability rows read as "under" the robot identity
     (operator's intuition; strict iOS HIG would keep same-indent and
     rely on typography hierarchy alone, but a small visual offset
     materially helps scannability when ~5-7 caps stack up). 12px is
     small enough to avoid feeling tree-view. */
  padding-left: 12px;
}
.robot-body > :first-child { margin-top: 0; }

/* Firmware commit SHA lives in the robot-menu header — diagnostic detail
   out of the card face, visible when you open the menu to act on the robot. */
#robot-menu-header {
  font-family: "SF Mono", ui-monospace, Menlo, monospace;
}

.robots-heading {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0 4px;
  margin: 24px 0 8px;
}
.heading-actions { display: flex; gap: 8px; margin-left: auto; }
/* Collapsible tray — generic details pattern. Log uses this; reusable for any accordion surface.
   Stands alone without .card: reads as bottom-strip chrome, not a data panel. */
details.tray {
  padding: 12px 0;
  margin-top: 24px;
  border-top: 1px solid var(--line);
}
details.tray > summary {
  cursor: pointer;
  list-style: none;
  user-select: none;
  display: flex;
  align-items: center;
  gap: 6px;
}
details.tray > summary::-webkit-details-marker { display: none; }
details.tray > summary::marker { content: ""; }
/* Trailing chevron — matches the iOS-Settings idiom the rest of the
   dashboard uses (settings disclosures, capability sections). Earlier
   leading-edge variant was the minority pattern; unified now. */
details.tray > summary .disclosure-chevron { margin-left: auto; }
details.tray[open] > summary .disclosure-chevron { transform: rotate(0); }
details.tray[open] > :not(summary) { margin-top: 12px; }
/* Unread-error pip: cleared by log.js on toggle-open, re-set by the next error
   logged while the tray is closed. Keeps the collapsed default from silently
   hiding failures. */
details.tray.has-alert > summary::after {
  content: "";
  display: inline-block;
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--danger);
  margin-left: 8px;
  vertical-align: middle;
}

.empty-state .empty-text { display: flex; flex-direction: column; gap: 6px; align-items: center; max-width: 380px; }
.empty-state .empty-title { font-size: 17px; font-weight: 600; color: var(--ink); letter-spacing: -0.01em; }
.empty-state .empty-sub { font-size: 14px; color: var(--ink-muted); margin: 0; line-height: 1.45; }
.empty-state .empty-actions { display: flex; gap: 8px; justify-content: center; }
.gamepad-dot { font-size: 12px; margin-left: 4px; vertical-align: middle; }

#robot-list {
  display: grid;
  /* Card now puts CTA on its own row, so the identity line only has to fit
     "name + type badge + ⋯" — much less width than the old inline-action
     layout demanded. 300 px min lets more cards-per-row on typical screens;
     individual .robot caps via max-width so a single-robot view doesn't
     stretch absurdly wide on a big monitor. */
  grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
  gap: 16px;
  align-items: start;  /* cards aren't forced to the tallest row-mate */
  margin-bottom: 16px;
}
#robot-list > .card { margin-bottom: 0; max-width: 440px; }

.robot-controls {
  margin-top: 12px;
  padding-top: 12px;
  border-top: 1px dashed var(--line);
}

/* Per-capability disclosure section. Header (chevron + label + state +
   primary action) is always visible; body collapses below. iOS Settings /
   macOS Finder pattern — action stays clickable when collapsed, depth is
   one tap away. Open state persists per-cap in localStorage. */
.cap-section {
  margin-top: 12px;
  padding-top: 12px;
  border-top: 1px dashed var(--line);
}
.cap-header {
  display: flex;
  align-items: center;
  gap: 8px;
}
.cap-toggle {
  display: flex;
  align-items: center;
  gap: 8px;
  flex: 1;
  min-width: 0;
  padding: 4px 0;
  margin: -4px 0;
  background: transparent;
  border: none;
  font: inherit;
  text-align: left;
  color: inherit;
  cursor: pointer;
  border-radius: 4px;
}
.cap-toggle:focus-visible {
  outline: none;
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 35%, transparent);
}
.cap-chevron {
  width: 12px;
  height: 12px;
  color: var(--ink-muted);
  opacity: 0.85;
  flex-shrink: 0;
  transform: rotate(-90deg);
  transition: transform 0.2s ease-out;
}
.cap-toggle[aria-expanded="true"] .cap-chevron { transform: rotate(0); }
/* Plain header when there's no body to expand — same layout as cap-toggle
   minus the click affordance. Aligns visually with toggleable headers. */
.cap-static {
  display: flex;
  align-items: center;
  gap: 8px;
  flex: 1;
  min-width: 0;
  padding-left: 20px;  /* matches chevron + gap on cap-toggle */
}
.cap-label { font-weight: 500; flex-shrink: 0; }
.cap-transport { width: 12px; height: 12px; color: var(--ink-muted); opacity: 0.7; flex-shrink: 0; }
.cap-state {
  color: var(--ink-muted);
  font-size: 13px;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.cap-body {
  margin-top: 10px;
  /* Indent body content so it aligns under the label, not the chevron —
     same idiom as iOS Settings disclosure rows. The 20px = chevron 12px
     + cap-toggle gap 8px. */
  padding-left: 20px;
}

/* Nested caps (Flash + Snapshot inside Camera) — render as sub-controls,
   not stacked cards. Drop the dashed top-border + reduced spacing so the
   visual hierarchy reads "these belong inside Camera" instead of "three
   peers in a row." Border-left would also work, but the lighter touch
   keeps the card from feeling like a tree-view. */
.cap-section .cap-section {
  margin-top: 8px;
  padding-top: 8px;
  border-top: 1px dotted var(--line);
  padding-left: 12px;
}
@media (prefers-reduced-motion: reduce) { .cap-chevron { transition: none; } }

/* Camera profile picker — sits at the bottom of the camera body when fw-info
   advertises a profile menu. Compact: label + select inline + meta hint. */
.cap-profile {
  margin-top: 10px;
  font-size: 13px;
}
.cap-profile label { display: inline-flex; align-items: center; gap: 6px; }
.cap-profile select {
  font-size: 13px;
  padding: 2px 6px;
  background: var(--surface);
  border: 1px solid var(--border-default);
  border-radius: 4px;
  color: var(--ink);
}
.cap-profile .meta { display: block; margin-top: 4px; }

/* Pip backend selector + API-key row in Settings. Same shape as other
   settings-item blocks; key input shows only when backend is "anthropic". */
.settings-select {
  width: 100%;
  margin-top: 6px;
  padding: 6px 8px;
  font-size: 14px;
  background: var(--surface);
  border: 1px solid var(--border-default);
  border-radius: 6px;
  color: var(--ink);
}
.pip-key-row { margin-top: 12px; }
.pip-key-row input[type="password"] {
  width: 100%;
  margin-top: 6px;
  padding: 6px 8px;
  font: inherit;
  font-family: "SF Mono", ui-monospace, Menlo, monospace;
  font-size: 12px;
  background: var(--surface);
  border: 1px solid var(--border-default);
  border-radius: 6px;
  color: var(--ink);
}
/* Service-worker update banner. Sits at the bottom-center of the viewport
   when a new dashboard version is waiting. Dismissable, never auto-applies. */
#sw-update-banner {
  position: fixed;
  bottom: 16px;
  left: 50%;
  transform: translateX(-50%);
  z-index: 200;
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 10px 14px;
  background: var(--surface);
  border: 1px solid var(--border-default);
  border-radius: 10px;
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.18);
  font-size: 13px;
  max-width: calc(100vw - 32px);
}
#sw-update-banner .icon {
  min-width: 24px;
  min-height: 24px;
  width: 24px;
  height: 24px;
  padding: 0;
  color: var(--ink-muted);
}
#sw-update-banner .icon:hover { color: var(--ink); }
.robot.highlight { background: #fff8e1; transition: background 1.5s; }

/* Top-level robot state (installing, rebooting, …). Live = pulsing accent
   dot; sticky (remembered across a disconnect) = static muted dot. */
.robot-state {
  display: flex;
  align-items: center;
  gap: 6px;
  margin-top: 6px;
  font-size: 13px;
  color: var(--ink-muted);
}
.robot-state::before {
  content: "";
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--accent);
  animation: robot-state-pulse 1.2s ease-in-out infinite;
  flex-shrink: 0;
}
.robot-state.sticky::before { animation: none; background: var(--ink-muted); }
@keyframes robot-state-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }

/* ESP32 serial monitor — xterm.js host (same primitive as .recovery-term).
   min-height: 0 so the flex column actually shrinks past the default
   `min-height: auto` content size. */
.esp-serial-console {
  background: #1e1e1e;
  flex: 1;
  min-height: 0;
  overflow: hidden;
}

/* User-script editor: see USER-CODE.md. Browser-resident, no Pi upload. */
.scripts-modal { width: min(820px, 92vw); }
/* Native <select> defaults to OS chrome that doesn't respect the dark theme.
   appearance:none strips it; the SVG chevron is inlined as background-image so
   we don't need to wire a separate icon mount. */
.scripts-template-select {
  appearance: none;
  -webkit-appearance: none;
  font: inherit;
  font-size: 13px;
  color: var(--ink);
  background: color-mix(in srgb, var(--ink) 4%, transparent);
  border: 1px solid var(--border);
  border-radius: 6px;
  padding: 6px 30px 6px 10px;
  cursor: pointer;
  background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path d='M1 1l4 4 4-4' stroke='%23999' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/></svg>");
  background-repeat: no-repeat;
  background-position: right 10px center;
}
.scripts-template-select:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
.scripts-editor {
  width: 100%;
  min-height: 280px;
  font-family: "SF Mono", ui-monospace, Menlo, monospace;
  font-size: 13px;
  line-height: 1.45;
  padding: 10px 12px;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: color-mix(in srgb, var(--ink) 4%, transparent);
  resize: vertical;
}
.scripts-output {
  font-family: "SF Mono", ui-monospace, Menlo, monospace;
  font-size: 12px;
  max-height: 200px;
  overflow-y: auto;
  padding: 8px 10px;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: color-mix(in srgb, var(--ink) 3%, transparent);
  white-space: pre-wrap;
  color: var(--ink-muted);
}
.scripts-output > div { padding: 1px 0; }
/* Hide the box itself when nothing has been logged yet — empty bordered
   strip below Run carries no information. Reappears the instant the first
   appendOutput child mounts. */
.scripts-output:empty { display: none; }

/* Heartbeat-only connect: pi-robot.service is dead, recovery plane reachable. */
.firmware-down-banner {
  margin-top: 10px;
  padding: 10px 12px;
  border: 1px solid var(--duke-persimmon);
  border-radius: 8px;
  background: color-mix(in srgb, var(--duke-persimmon) 8%, transparent);
}
.firmware-down-banner code {
  font-family: "SF Mono", ui-monospace, Menlo, monospace;
  font-size: 12px;
  background: color-mix(in srgb, var(--ink) 6%, transparent);
  padding: 1px 4px;
  border-radius: 3px;
}

/* Telemetry — ambient vitals line (uptime · mem · temp). Muted monospace. */
.telemetry {
  margin-top: 4px;
  font-family: "SF Mono", ui-monospace, Menlo, monospace;
  font-size: 11px;
  color: var(--ink-muted);
}

/* Pinout edit mode — pin inputs + preset controls under the board view. */
.pinout-edit-input { width: 70px; padding: 4px 8px; }
.pinout-edit-input.conflict { border-color: #ef4444; background: #fff5f5; }
/* ESP32 read-only / edit form rows — Pi path now lives in the SVG via
   inline terminal inputs, but ESP32 fw_info reports pins directly (no
   conf file) so its dialog still uses these legacy rows. */
.pinout-edit { margin-top: 16px; }
.pinout-edit-section { padding: 8px 0; border-top: 1px solid var(--line); }
.pinout-edit-row {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 4px 0;
  font-size: 14px;
}
.pinout-edit-label { color: var(--ink-muted); font-size: 13px; min-width: 72px; }
.pinout-warn {
  margin-top: 12px;
  padding: 8px 10px;
  border-radius: 6px;
  background: #ffe1d9;
  color: #7a2400;
  font-size: 13px;
}
/* Toolbar above the SVG — three caps (LED / Motors / Camera) live here as
   compact toggles. LED carries its GPIO input inline because it's a single
   pin direct to a single LED; the SVG below stays focused on the H-bridge,
   where the wiring is the interesting bit. */
.pinout-toolbar {
  display: flex;
  align-items: center;
  gap: 18px;
  flex-wrap: wrap;
  margin-bottom: 12px;
  padding-bottom: 10px;
  border-bottom: 1px solid var(--line);
}
.toolbar-toggle {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 14px;
  cursor: pointer;
}
.toolbar-toggle .pinout-edit-input { width: 50px; }
.pinout-helper { margin-top: 8px; font-size: 12px; }
/* Compact one-line warnings replacing the old stacked banners. Same colors
   as the legacy .pinout-warn / .soft so meaning carries; smaller padding
   and font so a typical config fits in one row below the diagram. */
.pinout-warn-line {
  margin-top: 8px;
  padding: 6px 10px;
  border-radius: 4px;
  background: #fff4e0;
  color: #7a4a00;
  font-size: 12px;
  line-height: 1.5;
}
.pinout-warn-line.has-hard { background: #ffe1d9; color: #7a2400; }
.pinout-warn-line .warn-hard { font-weight: 600; }

/* Motor calibration wizard — three-step flow living inside the pinout
   dialog body. Stepper across the top, choice buttons stack on small
   screens. Uses existing button classes for visual continuity. */
.cal-wizard { padding: 4px 2px; }
.cal-wizard h3 { font-size: 16px; }
.cal-wizard p { font-size: 14px; margin: 8px 0; }
.cal-progress {
  display: flex;
  gap: 8px;
  margin: 8px 0 14px;
}
.cal-step {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 26px; height: 26px;
  border-radius: 13px;
  background: var(--line);
  color: var(--ink-muted);
  font: 600 12px ui-monospace, "SF Mono", Menlo, monospace;
}
.cal-step.active { background: var(--accent, #2563eb); color: #fff; }
.cal-choices {
  display: flex;
  flex-direction: column;
  gap: 6px;
  margin: 8px 0;
}
.cal-choices button { text-align: left; }
.cal-summary { margin: 8px 0; padding-left: 20px; font-size: 14px; }
.cal-warn {
  padding: 6px 10px;
  border-radius: 4px;
  background: #fff4e0;
  color: #7a4a00;
  font-size: 13px;
}

.log-dialog { width: min(820px, calc(100vw - 32px)); max-width: 820px; }
.log-dialog-body {
  margin: 0;
  padding: 12px;
  background: var(--bg);
  border-radius: 8px;
  border: 1px solid var(--line);
  font-family: "SF Mono", ui-monospace, Menlo, monospace;
  font-size: 12px;
  line-height: 1.45;
  white-space: pre-wrap;
  word-break: break-word;
  max-height: 60vh;
  overflow-y: auto;
}

/* Enroll prompt — appears on a connected robot whose authorized list
   doesn't contain this dashboard's fingerprint. Empty list = TOFU invite. */

/* OTA progress bar — thin inline indicator styled to match the dashboard
   rather than the browser's chunky native bar. */
.ota-progress {
  display: block;
  width: 100%;
  height: 4px;
  margin-top: 8px;
  border: none;
  border-radius: 2px;
  background: var(--border-subtle);
  overflow: hidden;
  appearance: none;
}
.ota-progress::-webkit-progress-bar { background: var(--border-subtle); }
.ota-progress::-webkit-progress-value { background: var(--accent); transition: width 0.2s ease; }
.ota-progress::-moz-progress-bar { background: var(--accent); }

.wifi-list {
  margin: 8px 0 0;
  padding: 0;
  list-style: none;
  border-top: 1px dashed var(--line);
}
.wifi-row {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 10px 0;
  border-bottom: 1px solid var(--border-subtle);
}
.wifi-row:last-child { border-bottom: none; }
.wifi-row.joined {
  background: color-mix(in srgb, var(--success) 8%, transparent);
  margin: 0 -8px;
  padding-left: 8px;
  padding-right: 8px;
  border-radius: 6px;
  border-bottom-color: transparent;
}

.wifi-text { flex: 1; min-width: 0; }
.wifi-ssid {
  font-weight: 500;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.wifi-meta {
  font-size: 12px;
  color: var(--ink-muted);
  font-family: "SF Mono", ui-monospace, Menlo, monospace;
}
.wifi-row.joined .wifi-meta { color: var(--success); }

/* Signal bars: 4 vertical bars, weak→strong. "off" rect stays in flow so
   the column width is constant row-to-row — list scans visually clean. */
.wifi-bars { width: 16px; height: 12px; flex-shrink: 0; color: var(--ink); }
.wifi-bars .wifi-bar.on  { fill: currentColor; }
.wifi-bars .wifi-bar.off { fill: currentColor; opacity: 0.18; }

.wifi-lock { width: 12px; height: 14px; fill: var(--ink-muted); flex-shrink: 0; }
.wifi-check { width: 14px; height: 14px; fill: var(--success); flex-shrink: 0; }
.wifi-status-tag { display: inline-flex; align-items: center; }

.wifi-row-status {
  font-size: 13px;
  color: var(--ink-muted);
  justify-content: flex-start;
}

.wifi-row-other { cursor: pointer; }
.wifi-row-other:hover { background: var(--surface); margin: 0 -8px; padding-left: 8px; padding-right: 8px; border-radius: 6px; }
.wifi-row-other:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: 6px; }
.wifi-row-other .wifi-ssid { color: var(--ink-muted); font-weight: 400; }
.wifi-row-other:hover .wifi-ssid { color: var(--ink); }
.wifi-row-other-icon { width: 14px; height: 14px; color: var(--ink-muted); flex-shrink: 0; }

/* Small spinner — Scan button + empty-list wait state. */
.wifi-spinner {
  display: inline-block;
  width: 12px; height: 12px;
  border: 2px solid currentColor;
  border-right-color: transparent;
  border-radius: 50%;
  animation: wifi-spin 0.8s linear infinite;
  vertical-align: middle;
}
@keyframes wifi-spin { to { transform: rotate(360deg); } }

.setup-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 12px;
}
@media (max-width: 560px) {
  .setup-grid { grid-template-columns: 1fr; }
}

/* Platform picker — two tiles, one per hardware platform. Mark reuses the
   type-badge treatment from the robot-card list so the dialog visually
   rhymes with the surface the user came from. */
.platform-tile {
  /* Grid template aligns CTAs across tiles without a phantom footnote
     div. The trailing `1.5em` row reserves the footnote slot at the
     grid level — Pi has no footnote content but the row still holds
     its height, so its CTA lines up with ESP32's. Tiles can omit the
     footnote element entirely; the slot stays. */
  display: grid;
  grid-template-rows: auto 1fr auto 1.5em;
  gap: 12px;
  padding: 20px;
  background: var(--surface);
  border: 1px solid var(--border-default);
  border-radius: var(--radius);
}
.platform-mark {
  /* justify-self (not align-self) constrains horizontal in grid context.
     Without this, the mark spans the full grid column and renders as a
     full-width banner instead of a small inline pill. The earlier
     `align-self: flex-start` was a flex idiom orphaned when this tile
     switched from flex to grid. */
  justify-self: start;
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.06em;
  padding: 3px 8px;
  border-radius: 4px;
  background: var(--border-subtle);
}
.platform-esp32 .platform-mark { color: var(--accent); }
.platform-pi    .platform-mark { color: var(--success); }
.platform-title { font-size: 17px; font-weight: 600; letter-spacing: -0.01em; }
.platform-sub { font-size: 13px; color: var(--ink-muted); line-height: 1.45; }
.platform-cta { display: flex; align-items: center; }
.platform-footnote { min-height: 20px; display: flex; align-items: center; }

.help-hint {
  background: transparent;
  color: var(--ink-muted);
  border: none;
  padding: 0;
  min-height: unset;
  font-size: 12px;
  text-decoration: underline;
  text-decoration-style: dotted;
  text-underline-offset: 3px;
  cursor: help;
}
.help-hint:hover { color: var(--ink); }
.help-popover {
  border: 1px solid var(--line);
  border-radius: 10px;
  box-shadow: 0 10px 30px rgba(0,0,0,0.12), 0 2px 8px rgba(0,0,0,0.04);
  padding: 10px 12px;
  max-width: 280px;
  background: var(--surface);
  color: var(--ink);
  font-size: 13px;
  line-height: 1.4;
}
.help-popover::backdrop { background: transparent; }

.motor-sliders { display: flex; gap: 16px; margin-top: 8px; }
.motor-sliders label {
  flex: 1;
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 13px;
  color: var(--ink-muted);
}
.motor-sliders input[type="range"] { flex: 1; accent-color: var(--accent); }

/* Level slider — single-axis brightness control (flash LED today). Sits
   in the cap-section action slot, where toggle/signed-pair would put a
   button. Width keeps it compact in the right column. */
.level-slider {
  width: 140px;
  accent-color: var(--accent);
  vertical-align: middle;
}

/* Joypad — 2D drag surface with a knob that follows the pointer. Drives
   throttle (Y) + turn (X); firmware-side differential mixing lives in JS
   (signed-pair.js). touch-action:none stops mobile browsers from stealing
   drags for pan/zoom. */
.joypad-wrap {
  display: flex;
  flex-direction: column;
  align-items: center;
  margin-top: 12px;
  user-select: none;
  -webkit-user-select: none;
  -webkit-touch-callout: none;
  -webkit-tap-highlight-color: transparent;
}

/* Segmented control — Apple HIG's recommendation for 2–4 mutually
   exclusive options. Recessed track inside the surrounding --surface;
   the active segment rises to --surface with a subtle ring. macOS-
   style pop, not a loud accent fill — tabs aren't the primary action. */
.segmented {
  display: inline-flex;
  border-radius: 8px;
  background: var(--bg-elev);
  padding: 2px;
}
.segmented .mode-btn {
  background: transparent;
  border: none;
  font: inherit;
  font-weight: 500;
  font-size: 13px;
  min-height: 28px;
  padding: 4px 14px;
  border-radius: 6px;
  color: var(--ink-muted);
  cursor: pointer;
  transition: background 0.15s, color 0.15s;
}
.segmented .mode-btn[aria-pressed="true"] {
  background: var(--surface);
  color: var(--ink);
  box-shadow: 0 1px 2px rgba(0,0,0,0.08), 0 0 0 1px var(--border-default);
}
.segmented .mode-btn:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}
/* Drive-mode toggle inherits .segmented; just needs the right-anchor
   so it sits in the section header without pushing the driving
   controls down. */
.phone-drive-mode { margin-left: auto; }

/* Tilt-drive: phone-as-steering-wheel. Visual indicator across the top
   shows real-time gamma (left-right roll); pedals stack vertically below
   so a thumb on either hand reaches them naturally with the phone in
   landscape. */
.tilt-drive {
  display: flex;
  flex-direction: column;
  gap: 12px;
  margin-top: 12px;
  user-select: none;
  -webkit-user-select: none;
  -webkit-touch-callout: none;
  -webkit-tap-highlight-color: transparent;
}
.tilt-indicator {
  display: flex;
  flex-direction: column;
  gap: 6px;
  align-items: center;
}
.tilt-bar {
  position: relative;
  width: 100%;
  height: 12px;
  background: var(--border-subtle);
  border-radius: 999px;
  overflow: hidden;
}
.tilt-fill {
  position: absolute;
  top: 0;
  height: 100%;
  background: var(--accent);
  transition: left 60ms linear, width 60ms linear;
}
/* Neutral zone — band at the center showing "tilt within this range =
   robot keeps going straight." JS sets the inline width to match the
   ratio of the dead-zone to the saturation range so it scales if the
   constants change. Sits BEHIND .tilt-fill so the active read is still
   visible when the user crosses out of the dead zone. */
.tilt-neutral {
  position: absolute;
  top: 0;
  left: 50%;
  transform: translateX(-50%);
  height: 100%;
  background: color-mix(in srgb, var(--success) 35%, transparent);
}
.tilt-readout {
  font-variant-numeric: tabular-nums;
  font-size: 12px;
}
.tilt-pedals {
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.tilt-pedal {
  font: inherit;
  font-size: 16px;
  font-weight: 500;
  padding: 18px 0;
  border-radius: 14px;
  border: 1px solid var(--border-default);
  background: var(--bg-elev);
  color: var(--ink);
  cursor: pointer;
  /* Big tap target, immediate visual response. The active state makes
     the held-down feel obvious to the operator. */
}

/* Landscape steering-wheel grip — pedals spread to bottom-left and
   bottom-right corners (controller-grip), indicator runs across the top.
   Same buttons, different geometry: each thumb has a dedicated pedal
   right where it naturally rests when the phone is held landscape. */
.tilt-drive.landscape .tilt-pedals {
  flex-direction: row;
  justify-content: space-between;
  gap: 12px;
}
.tilt-drive.landscape .tilt-pedal {
  flex: 1;
  min-height: 88px;       /* bigger thumb target in landscape */
  font-size: 18px;
}
.tilt-drive.landscape .tilt-pedal.reverse { order: 1; }
.tilt-drive.landscape .tilt-pedal.forward { order: 2; }
.tilt-orient-hint {
  font-size: 13px;
  color: var(--ink-muted);
  text-align: center;
  padding: 10px 16px;
  border-radius: 8px;
  background: var(--bg-elev);
  border: 1px dashed var(--border-default);
}
.tilt-pedal:active,
.tilt-pedal[aria-pressed="true"] {
  background: var(--accent);
  color: #fff;
  border-color: var(--accent);
}
.tilt-permission {
  font: inherit;
  padding: 10px 16px;
  border-radius: 8px;
  background: var(--accent);
  color: #fff;
  border: none;
  cursor: pointer;
}
.joypad {
  /* 140 px (was 180 px) — recovers ~40 px of vertical real estate when
     the Motors section is open. The shared joypad module reads the
     element's bounding rect for drag math, so changing the size doesn't
     touch driving fidelity. */
  width: 140px;
  height: 140px;
  border-radius: 50%;
  background: radial-gradient(circle at center, var(--border-subtle) 0%, transparent 70%);
  border: 1px solid var(--border-default);
  position: relative;
  touch-action: none;
  cursor: grab;
}
.joypad.dragging { cursor: grabbing; }
.joypad-knob {
  position: absolute;
  top: 50%; left: 50%;
  width: 48px; height: 48px;
  margin: -24px 0 0 -24px;
  border-radius: 50%;
  background: var(--accent);
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25);
  pointer-events: none;
  transition: transform 0.1s ease-out;
}
/* CSS :active is inconsistent when the pointer is captured; use a JS class. */
.joypad.dragging .joypad-knob { transition: none; }
.joypad-hint { margin-top: 10px; font-size: 12px; }

.install-log {
  margin-top: 8px;
  padding: 6px 10px;
  border-radius: 6px;
  background: var(--bg);
  font-family: "SF Mono", ui-monospace, Menlo, monospace;
  font-size: 11px;
  color: var(--ink-muted);
  word-break: break-all;
}

/* SVG Pi header — green PCB surround, black plastic header strip, gold
   pin dots colored by kind. Pin dots are clickable targets (later phases
   wire pulse tests + live state here); use pointer-events: all so hovering
   a pin shows the title tooltip. */
.pinout-svg-wrap {
  background: #0f3a1e;   /* Pi PCB green */
  padding: 12px;
  border-radius: 6px;
  margin-bottom: 12px;
  overflow: hidden;
}
.pinout-svg { width: 100%; height: auto; max-width: 460px; display: block; margin: 0 auto; }
.pinout-svg .pinout-strip { fill: #1a1a1a; }  /* black header plastic */
.pinout-svg .pin-dot {
  fill: #d4a96a;             /* default: raw gold (GPIO) */
  stroke: #6b5530;
  stroke-width: 1;
  transition: filter 0.12s ease;
}
.pinout-svg .pin-dot:hover { filter: brightness(1.25); }
/* Elements that participate in a motor wire chain (pin-dot, claim text,
   wire path, driver terminal) — tagged with matching data-wire values.
   Cursor signals "this is interactive"; .wire-active fires on hover of
   any element in the chain to light up the whole connection. */
.pinout-svg [data-wire] { cursor: pointer; }
.pinout-svg .pin-dot.wire-active {
  stroke: #93c5fd;
  stroke-width: 3.5;
  filter: drop-shadow(0 0 8px rgba(147, 197, 253, 0.9));
}
.pinout-svg .motor-wire.wire-active { opacity: 1; stroke-width: 3.2; }
.pinout-svg .driver-pin.wire-active {
  stroke: #93c5fd;
  stroke-width: 3;
  filter: drop-shadow(0 0 6px rgba(147, 197, 253, 0.9));
}
.pinout-svg .pin-claim.wire-active { fill: #93c5fd; font-weight: 700; }
.pinout-svg .pin-dot.kind-3v3   { fill: #f59e0b; }
.pinout-svg .pin-dot.kind-5v    { fill: #ef4444; }
.pinout-svg .pin-dot.kind-gnd   { fill: #525252; }
.pinout-svg .pin-dot.kind-i2c-id { fill: #a855f7; }
.pinout-svg .pin-dot.claimed {
  stroke: #60a5fa;
  stroke-width: 2.5;
  filter: drop-shadow(0 0 3px rgba(96, 165, 250, 0.6));
}
/* Focus highlight — when the user is editing a pin input, the circle for
   that GPIO on the board glows white. Stacks over .claimed; appears after
   .claimed in this file so it wins the stroke declaration. Separate signal
   from amber AI-reply tint (different context, different channel). */
.pinout-svg .pin-dot.focused {
  stroke: #ffffff;
  stroke-width: 4;
  filter: drop-shadow(0 0 10px rgba(255, 255, 255, 0.95));
}
/* Driver board visualization (renderBoardWithDriver). Same PCB green as the
   Pi header so the two boards read as part of one diagram. Terminals gold
   for IN pins (match Pi GPIOs), purple for EN pins (match the existing
   i2c-id purple used to signal "special-role pin"). */
.pinout-svg .driver-pcb {
  fill: #0f3a1e;
  stroke: #1a1a1a;
  stroke-width: 1;
}
.pinout-svg .driver-title {
  font: 500 10px ui-monospace, "SF Mono", Menlo, monospace;
  fill: #9ca3af;
  letter-spacing: 0.04em;
}
.pinout-svg .driver-pin {
  stroke: #6b5530;
  stroke-width: 1;
}
.pinout-svg .driver-pin.input  { fill: #d4a96a; }
.pinout-svg .driver-pin.enable { fill: #a855f7; stroke: #5b21b6; }
.pinout-svg .driver-label {
  font: 600 11px ui-monospace, "SF Mono", Menlo, monospace;
  fill: #e5e5e5;
}
.pinout-svg .driver-sublabel {
  font: 500 9px ui-monospace, "SF Mono", Menlo, monospace;
  fill: #999;
}
/* Inline GPIO inputs sitting below each driver terminal in edit mode.
   Embedded via <foreignObject>. Dark, semi-transparent so they read as
   silkscreen labels you can type into — keeps the PCB metaphor intact. */
.pinout-svg .terminal-input {
  width: 100%;
  height: 100%;
  box-sizing: border-box;
  background: rgba(0, 0, 0, 0.45);
  border: 1px solid rgba(255, 255, 255, 0.18);
  border-radius: 3px;
  color: #f3f4f6;
  font: 600 12px ui-monospace, "SF Mono", Menlo, monospace;
  text-align: center;
  padding: 0;
  outline: none;
}
.pinout-svg .terminal-input::placeholder { color: rgba(255, 255, 255, 0.35); }
.pinout-svg .terminal-input:focus {
  border-color: #93c5fd;
  background: rgba(96, 165, 250, 0.18);
}
.pinout-svg .terminal-input.conflict {
  border-color: #ef4444;
  background: rgba(239, 68, 68, 0.18);
}
/* Wires from Pi GPIOs to driver terminals. IN wires are solid blue
   (direction control); EN wires are dashed purple (optional speed control).
   Low opacity so they stay visually subordinate to the pin dots. */
.pinout-svg .motor-wire {
  fill: none;
  stroke-width: 1.8;
  opacity: 0.7;
}
.pinout-svg .motor-wire.wire-input  { stroke: #60a5fa; }
.pinout-svg .motor-wire.wire-enable { stroke: #a855f7; stroke-dasharray: 5 3; }
/* Encoder breakout modules. Same PCB green as the L298N (and Pi header
   strip silver, by inheritance through .pin-dot) so all three boards
   read as one schematic. Pins use .pin-dot for the shared color/hover
   behavior; .enc-pin trims the stroke to signal "secondary board". */
.pinout-svg .enc-pcb {
  fill: #0f3a1e;
  stroke: #1a1a1a;
  stroke-width: 1;
}
.pinout-svg .enc-title {
  font: 500 10px ui-monospace, "SF Mono", Menlo, monospace;
  fill: #9ca3af;
  letter-spacing: 0.04em;
}
.pinout-svg .enc-pin-label {
  font: 500 9px ui-monospace, "SF Mono", Menlo, monospace;
  fill: #cbd5e1;
}
.pinout-svg .pin-dot.enc-pin { stroke-width: 0.8; }
/* Encoder wires. OUT is the editable signal — full blue, same as motor
   IN wires. VCC and GND are infrastructure (every encoder needs them,
   the user doesn't pick a destination) — drawn very faintly so they
   communicate "wire this too" without competing with the actionable
   OUT line. The faint VCC/GND wires both converge on the same Pi 3V3
   and Pi GND pins, visually teaching the shared-supply pattern. */
.pinout-svg .enc-wire {
  fill: none;
  stroke-width: 1.8;
}
.pinout-svg .enc-wire.wire-out { stroke: #60a5fa; opacity: 0.7; }
.pinout-svg .enc-wire.wire-vcc { stroke: #f59e0b; opacity: 0.22; stroke-width: 1.2; }
.pinout-svg .enc-wire.wire-gnd { stroke: #94a3b8; opacity: 0.22; stroke-width: 1.2; }
.pinout-svg .enc-wire.wire-out.wire-active { opacity: 1; stroke-width: 3.2; }
/* Supply-side note: reminder of non-wireable connections the user must
   make themselves (GND between Pi and driver, external motor power).
   Muted so it reads as annotation, not a configurable element. */
.pinout-svg .driver-supply {
  font: 400 10px ui-sans-serif, -apple-system, sans-serif;
  fill: #9ca3af;
  font-style: italic;
}
.pinout-svg .pin-label {
  font: 600 11px ui-monospace, "SF Mono", Menlo, monospace;
  fill: #e5e5e5;
  dominant-baseline: middle;
}
.pinout-svg .pin-num {
  font: 500 9px ui-monospace, "SF Mono", Menlo, monospace;
  fill: #9ca3af;
  dominant-baseline: middle;
}
.pinout-svg .pin-claim {
  font: 500 10px ui-sans-serif, -apple-system, sans-serif;
  fill: #60a5fa;
  dominant-baseline: middle;
}
/* ESP32-CAM reference view. Board is physically black (not Pi green) so the
   wrap switches background; status colors (free/sd-shared/reserved) override
   the default kind-gpio gold for pins that are not power/ground, so the
   availability story reads at a glance without a legend on the SVG itself. */
.pinout-svg-wrap.esp32 { background: #0a0a0a; }
.pinout-svg.esp32 { max-width: 520px; }
.pinout-svg.esp32 .esp-pcb {
  fill: #1a1a1a;
  stroke: #2a2a2a;
  stroke-width: 1;
}
.pinout-svg.esp32 .esp-chip-label {
  font: 500 11px ui-monospace, "SF Mono", Menlo, monospace;
  fill: #6b7280;
  letter-spacing: 0.04em;
}
.pinout-svg.esp32 .pin-dot.esp-free     { fill: #22c55e; stroke: #14532d; }
.pinout-svg.esp32 .pin-dot.esp-sd-shared { fill: #eab308; stroke: #713f12; }
.pinout-svg.esp32 .pin-dot.esp-reserved  { fill: #6b7280; stroke: #1f2937; }
.pinout-svg.esp32 .pin-dot.esp-forbidden { fill: #b91c1c; stroke: #450a0a; }

/* Console (Pi USB-C + ESP32 USB serial unified) — sized for ~110-col
   terminal at 13px (iTerm2-shape), flex-column so the active section's
   term container has a measurable size for FitAddon. The mode picker
   swaps which section is visible (.console-section); both share the
   dialog shell and lifecycle. resize: both lets power users grow the
   window past defaults; recovery.js's ResizeObserver re-fits xterm. */
#console-modal {
  display: flex;
  flex-direction: column;
  width: min(960px, calc(100vw - 32px));
  height: min(720px, calc(100vh - 64px));
  min-width: 480px;
  min-height: 360px;
  resize: both;
  overflow: hidden;
}
.console-mode-row {
  display: flex;
  align-items: center;
  margin: 0 0 12px;
}
.console-section {
  display: flex;
  flex-direction: column;
  flex: 1;
  min-height: 0;
}
.console-section[hidden] { display: none; }
.console-statusbar {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 8px 0 0;
  margin-top: 8px;
  border-top: 1px solid var(--border-subtle);
  font-size: 12px;
  color: var(--ink-muted);
}
.console-statusbar .meta { font-size: 12px; }
.console-statusbar-sep { opacity: 0.5; }
/* Toolbar row: Connect + status grouped at the left, destructive Flash
   firmware isolated on the right. Apple HIG separates destructive
   actions from benign ones so the operator's eye lands on the safe
   action by default. */
.recovery-actions { margin: 0 0 12px; align-items: center; }
.recovery-actions #esp-serial-flash { margin-left: auto; }

/* Install dialog (esp-flash-modal). One <dialog> hosts the full arc:
   connecting → board pick → write progress → done. The pick and
   progress sections show/hide via [hidden]; status text + action row
   stay visible across states so the operator sees continuous feedback. */
.esp-flash-status { margin: 0 0 12px; }
.esp-flash-status.success { color: var(--success); font-weight: 500; }
.esp-flash-status.error   { color: var(--danger);  font-weight: 500; }
/* Fade-in when a section becomes visible — matches macOS sheet content
   transitions. No fade-out (HIG: outgoing content can be instant). */
@keyframes esp-flash-fade-in {
  from { opacity: 0; transform: translateY(2px); }
  to   { opacity: 1; transform: none; }
}
.esp-flash-pick:not([hidden]),
.esp-flash-progress:not([hidden]) {
  animation: esp-flash-fade-in 150ms ease-out;
}
.esp-flash-boards { display: flex; flex-direction: column; gap: 8px; margin: 0 0 12px; }
.esp-flash-board-option {
  display: grid;
  grid-template-columns: auto 1fr;
  grid-template-rows: auto auto;
  column-gap: 10px;
  row-gap: 2px;
  padding: 10px 12px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--bg);
  cursor: pointer;
}
.esp-flash-board-option:has(input:checked) {
  border-color: var(--accent);
  background: var(--surface);
}
.esp-flash-board-option input[type="radio"] { grid-row: 1 / 3; align-self: center; }
.esp-flash-board-title { font-weight: 500; }
.esp-flash-board-sub { font-size: 12px; }
.esp-flash-webrtc {
  display: grid;
  grid-template-columns: auto 1fr;
  column-gap: 10px;
  row-gap: 2px;
  padding: 10px 12px;
  border: 1px dashed var(--border);
  border-radius: 8px;
  margin: 0 0 12px;
  cursor: pointer;
}
.esp-flash-webrtc input[type="checkbox"] { grid-row: 1 / 3; align-self: center; }
.esp-flash-webrtc-title { font-weight: 500; }
/* `display: grid` above beats the [hidden] attribute on specificity, so
   .hidden=true alone leaves the checkbox visible on no-camera boards.
   Explicit attribute-selector rule restores the expected behavior. */
.esp-flash-webrtc[hidden] { display: none; }
.esp-flash-actions { justify-content: flex-end; }
.esp-flash-empty { margin: 0 0 12px; }
.esp-flash-version { font-size: 11px; margin: 0 0 12px; opacity: 0.7; font-family: var(--mono, ui-monospace, monospace); }
.esp-flash-progress { margin: 0 0 12px; }
.esp-flash-progress-bar {
  height: 8px;
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: 4px;
  overflow: hidden;
}
.esp-flash-progress-fill {
  height: 100%;
  width: 0%;
  background: var(--accent);
  transition: width 120ms ease-out;
}
.esp-flash-progress-sub { margin: 6px 0 0; font-size: 12px; }
/* Subordinate to the status line above: tight gap, smaller text, slight
   indent. HIG-flavored — disclosure reads as "more about that status",
   not a peer of the picker/progress sections. */
.esp-flash-details { margin: -6px 0 12px 4px; }
.esp-flash-details summary {
  cursor: pointer;
  font-size: 11px;
  color: var(--ink-muted);
  user-select: none;
}
.esp-flash-details summary:hover { color: var(--ink); }
.esp-flash-details[open] summary { margin-bottom: 4px; }
.esp-flash-trace {
  margin: 8px 0 0;
  max-height: 200px;
  overflow-y: auto;
  font-family: var(--mono, ui-monospace, monospace);
  font-size: 11px;
  background: var(--bg);
  padding: 8px;
  border-radius: 4px;
  border: 1px solid var(--border);
  white-space: pre-wrap;
  word-break: break-all;
}
/* Inline text-link affordance for the post-empty-picker escape hatch.
   Quiet by default (yielding lens) — only earns ink when the user
   has hit the dead-end the filtered picker leaves. */
.link-btn {
  background: none;
  border: none;
  color: var(--ink-muted);
  font: inherit;
  font-size: 12px;
  padding: 0;
  min-height: auto;
  text-decoration: underline;
  cursor: pointer;
}
.link-btn:hover { color: var(--ink); }
.recovery-term {
  background: #1e1e1e;
  flex: 1;
  min-height: 0;  /* default `min-height: auto` on flex items prevents shrinking below content */
  overflow: hidden;
}
/* Shell dialog: terminal-shaped, deserves more horizontal room than the
   default prepare-dialog 560px. Override width and bound the term height
   so xterm's FitAddon doesn't loop measure → grow → re-measure. */
#shell-modal { width: min(960px, calc(100vw - 32px)); }
#shell-term { height: 60vh; min-height: 320px; }

.robot-camera {
  display: block;
  width: 100%;
  margin-top: 12px;
  border-radius: 8px;
  background: #000;
  aspect-ratio: 4 / 3;
  object-fit: contain;
}

/* Attached-camera frame — wraps the <video> and the ArUco SVG overlay so
   the overlay can absolute-position over the video without touching the
   broader card layout. The video's object-fit:contain letterboxes inside
   the 4:3 frame; preserveAspectRatio="xMidYMid meet" on the SVG matches
   that letterboxing so detected corners line up. */
.attached-camera-frame {
  position: relative;
  margin-top: 12px;
}
.attached-camera-frame .robot-camera { margin-top: 0; }

/* Flex column with gap is immune to margin-collapse + specificity edge cases —
   spacing stays right whether the .empty-sub is present or not. */
.empty-state { text-align: center; padding: 32px 24px; display: flex; flex-direction: column; align-items: center; gap: 20px; }
.empty-state button { padding: 10px 20px; }  /* more breathing room for the primary CTA */

.log {
  font-family: "SF Mono", ui-monospace, "JetBrains Mono", Menlo, monospace;
  font-size: 12px;
  color: var(--ink-muted);
  max-height: 240px;
  overflow-y: auto;
}
.log > div {
  display: grid;
  grid-template-columns: auto 16ch 1fr;
  gap: 12px;
  align-items: baseline;
}
.log > div.sys .log-msg { grid-column: 2 / -1; }  /* no name → message absorbs the slot */
/* Tertiary text — express as one step (40% of primary) instead of opacity on already-muted. */
.log .log-time { color: color-mix(in srgb, var(--ink) 40%, transparent); font-variant-numeric: tabular-nums; }
/* Flex + inner prefix/suffix so the column truncates the dim prefix first and
   keeps the identifying suffix visible (mirrors the card header treatment). */
.log .log-name {
  display: flex;
  min-width: 0;
  overflow: hidden;
  white-space: nowrap;
}
.log .log-name-prefix {
  color: var(--ink-muted);
  overflow: hidden;
  text-overflow: ellipsis;
  min-width: 0;
}
.log .log-name-suffix {
  color: var(--ink);
  font-weight: 500;
  flex-shrink: 0;
}
.log .log-name.dup { visibility: hidden; }
.log .log-msg { white-space: pre-wrap; word-break: break-word; }
.log .err .log-msg { color: var(--danger); }
.log .ok  .log-msg { color: var(--success); }

/* visible progress log (inside prepare dialog) */
.progress-log {
  font-family: "SF Mono", ui-monospace, Menlo, monospace;
  font-size: 12px;
  color: var(--ink);
  white-space: pre-wrap;
  max-height: 240px;
  overflow-y: auto;
  background: var(--bg);
  padding: 12px;
  border-radius: 8px;
  border: 1px solid var(--line);
}
.progress-log .err { color: var(--danger); }
.progress-log .ok  { color: var(--success); }

.unsupported {
  padding: 16px;
  border-radius: var(--radius);
  background: #fff4e0;
  color: #7a4a00;
  font-size: 13px;
  line-height: 1.5;
  margin-bottom: 16px;
}
/* Lighter tone than .unsupported — recoverable state, not terminal.
   Same shape, different palette so the user pre-attentively reads
   "fix this" vs. ".unsupported = give up here." */
.info-banner {
  padding: 16px;
  border-radius: var(--radius);
  background: color-mix(in srgb, var(--accent) 12%, transparent);
  color: var(--ink);
  font-size: 13px;
  line-height: 1.5;
  margin-bottom: 16px;
  border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
}

/* modals */
.label-modal, .prepare-dialog, .settings-modal, .pair-dialog {
  border: none;
  border-radius: var(--radius);
  padding: 24px;
  background: var(--surface);
  color: var(--ink);
  box-shadow: 0 20px 60px rgba(0,0,0,0.2);
}
.label-modal::backdrop, .prepare-dialog::backdrop, .settings-modal::backdrop, .pair-dialog::backdrop { background: rgba(0,0,0,0.3); }

/* fade + scale on open/close. allow-discrete + @starting-style handle the display
   transition so the native <dialog> toggle animates instead of popping. */
dialog {
  opacity: 0;
  transform: scale(0.96);
  transition:
    opacity 0.18s ease-out,
    transform 0.18s cubic-bezier(0.4, 0, 0.2, 1),
    display 0.18s allow-discrete,
    overlay 0.18s allow-discrete;
}
dialog[open] { opacity: 1; transform: scale(1); }
@starting-style {
  dialog[open] { opacity: 0; transform: scale(0.96); }
}
dialog::backdrop {
  opacity: 0;
  transition: opacity 0.18s ease-out, display 0.18s allow-discrete, overlay 0.18s allow-discrete;
}
dialog[open]::backdrop { opacity: 1; }
@starting-style {
  dialog[open]::backdrop { opacity: 0; }
}
.label-modal { width: min(400px, calc(100vw - 32px)); }
.prepare-dialog { width: min(560px, calc(100vw - 32px)); max-height: calc(100vh - 64px); }
.settings-modal { width: min(440px, calc(100vw - 32px)); }
.setup-dialog { width: min(560px, calc(100vw - 32px)); max-width: 560px; }

/* Pip — bubble / panel / notify / turns / reply / form come from pip (jsdelivr).
   This block only carries the site-specific layer: tool-trace rows, stop button,
   non-destructive turn collapse, and desktop-tight input sizing. */

/* Dashboard is desktop-dominant; override core's 16px (iOS zoom-prevention) to
   keep the input visually tight. */
.pip-input { font-size: 13px; padding: 6px 40px 6px 10px; background: var(--bg); }
.pip-input:focus { background: var(--surface); }
/* Send/stop button styling lives in pip-core 2.1.0+. Our tighter input
   (font-size 13px + padding 6+6) gives ~31px total height vs pip-core's
   ~36px default, so vertical gap with the 24px button is ~3.5px instead
   of ~5.5px. Match horizontally so the inset stays symmetric. */
.pip-form { --pip-btn-right: 4px; }
/* Tool-trace row — one per tool_use call, appended as Pip dispatches them.
   .pending = in-flight (italic + dim), .error = tool returned/threw. */
.pip-trace {
  list-style: none;
  margin: 6px 0;
  padding: 0;
  font-family: "SF Mono", ui-monospace, Menlo, monospace;
  font-size: 11px;
  color: var(--ink-muted);
  border-left: 2px solid color-mix(in srgb, var(--duke-persimmon) 40%, transparent);
  padding-left: 8px;
}
.pip-trace-line { padding: 1px 0; white-space: pre-wrap; word-break: break-word; }
.pip-trace-line.pending { font-style: italic; opacity: 0.7; }
.pip-trace-line.error   { color: var(--danger); }
.pip-trace-summary {
  background: none;
  border: 0;
  padding: 0;
  margin: 0;
  font: inherit;
  color: inherit;
  text-align: left;
  cursor: pointer;
  width: 100%;
}
.pip-trace-summary:hover { color: var(--ink); }
.pip-trace-summary::before {
  content: "▸ ";
  font-size: 9px;
  opacity: 0.5;
}
.pip-trace-summary[aria-expanded="true"]::before { content: "▾ "; }
.pip-trace-detail {
  margin: 4px 0 6px 12px;
  padding: 6px 8px;
  background: color-mix(in srgb, var(--ink) 4%, transparent);
  border-radius: 4px;
  font-size: 10.5px;
  white-space: pre-wrap;
  word-break: break-word;
  max-height: 240px;
  overflow: auto;
}

.settings-profile {
  display: flex;
  align-items: flex-start;
  gap: 12px;
  padding-bottom: 16px;
  margin-bottom: 4px;
  /* No border-bottom — the next .settings-item's border-top is the divider. */
}
.avatar-preview { margin-top: 18px; }  /* pin avatar to the input row, not the stack center */
.settings-signin { margin-top: 10px; }
.avatar-preview {
  width: 48px;
  height: 48px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  font-weight: 600;
  font-size: 18px;
  color: white;
  background: var(--ink-muted);
  flex-shrink: 0;
}
.settings-profile-body { flex: 1; min-width: 0; }
.settings-profile-body input {
  width: 100%;
  font: inherit;
  padding: 6px 10px;
  border: 1px solid var(--line);
  border-radius: 6px;
  background: var(--surface);
  color: var(--ink);
  margin-top: 4px;
}
.settings-profile-body .meta { margin-top: 4px; }

.settings-item {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 12px;
  align-items: start;
  padding: 10px 0;
  border-top: 1px solid var(--line);
}
.settings-item:first-of-type { border-top: none; }

/* Group header — iOS-style eyebrow label (all-caps, muted, letter-spaced)
   that introduces a related set of rows (Security, Experimental). Whitespace
   above does the grouping; no line, so the rhythm stays light. The adjacent
   settings-item drops its border-top so the header reads as the first
   element of the group, not a row with its own divider. */
.settings-group-header {
  margin: 20px 0 0;
  padding: 8px 0 4px;
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ink-muted);
}
.settings-group-header + .settings-item { border-top: none; }
.settings-item input[type="checkbox"] { margin: 2px 0 0 0; }
.settings-item label { cursor: pointer; }
.settings-title { font-weight: 500; margin-bottom: 2px; }

/* Stacked layout — avoids narrow-viewport wrapping when the buttons' auto
   column steals width from the fingerprint. word-break targets only the
   fingerprint so prose lines wrap normally. */
.settings-item.key-row { grid-template-columns: 1fr; gap: 6px; }
.settings-item.key-row .key-fingerprint { word-break: break-all; }
.settings-item.passwords-row { grid-template-columns: 1fr; gap: 6px; }
.settings-item.pip-backend-row { grid-template-columns: 1fr; gap: 6px; }

/* Disclosure-row pattern — Advanced section uses <details> so one-time
   setup tasks (key, passwords) collapse to a one-line summary by default.
   Summary shows label + current value at a glance; tap reveals the action
   set. Same iOS-Settings idiom. The grid template on the summary mirrors
   a normal settings-item row for visual rhythm. */
.settings-disclosure {
  display: block;
  padding: 0;
}
.settings-disclosure-summary {
  display: grid;
  grid-template-columns: auto 1fr auto;
  gap: 12px;
  align-items: center;
  padding: 10px 0;
  cursor: pointer;
  list-style: none;
}
.settings-disclosure-summary::-webkit-details-marker { display: none; }
.settings-disclosure-summary::marker { content: ""; }
.settings-disclosure-summary::after {
  content: "›";
  color: var(--ink-muted);
  font-size: 18px;
  transition: transform 0.2s ease;
}
.settings-disclosure[open] .settings-disclosure-summary::after { transform: rotate(90deg); }
.settings-disclosure-summary .key-fingerprint,
.settings-disclosure-summary .meta {
  text-align: right;
  word-break: break-all;
  color: var(--ink-muted);
  font-size: 12px;
}
.settings-disclosure-body {
  padding: 4px 0 12px;
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.pwd-entry {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 8px 0;
  border-top: 1px solid var(--border-subtle);
}
.pwd-entry:first-child { border-top: none; }
.pwd-info { flex: 1; min-width: 0; }
.pwd-host { font-weight: 500; }
.pwd-value { word-break: break-all; }
.pwd-actions { display: flex; gap: 6px; flex-shrink: 0; }
.key-actions { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 4px; }

.caps-list { display: flex; flex-direction: column; gap: 8px; }
.caps-list .cap-row {
  display: flex;
  align-items: center;
  gap: 10px;
  font-size: 14px;
}
/* width: auto beats the ambient .field input { width: 100% } which would
   otherwise stretch the checkbox across the whole row. */
.caps-list .cap-row input[type="checkbox"] { margin: 0; flex: none; width: auto; }
/* Override .field label's block / muted / margin rules inside a cap-row. */
.caps-list .cap-row label {
  display: inline;
  margin: 0;
  font-size: 14px;
  color: var(--ink);
  cursor: pointer;
  flex: 1 1 auto;  /* take remaining horizontal space so the pin input floats right */
}
.caps-list .cap-row .cap-suffix { color: var(--ink-muted); font-size: 13px; flex: none; }
.caps-list .cap-row input[type="number"] {
  width: 56px;
  padding: 2px 6px;
  font-size: 13px;
  flex: none;
}

.modal-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 16px;
  gap: 12px;
}
.qr-box {
  display: flex;
  justify-content: center;
  align-items: center;
  padding: 16px;
  background: white;
  border-radius: 8px;
  border: 1px solid var(--line);
}
.qr-box svg { width: 240px; height: 240px; }
.label-url {
  text-align: center;
  margin-top: 12px;
  padding: 6px 8px;
  border-radius: 6px;
  font-family: "SF Mono", ui-monospace, Menlo, monospace;
  font-size: 12px;
  color: var(--ink-muted);
  word-break: break-all;
  cursor: pointer;
  transition: background 0.15s, color 0.15s;
}
.label-url:hover { background: var(--surface); color: var(--ink); }
.label-url:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.label-url.copied { color: var(--success); }
.modal-title-stack { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.modal-subtitle { font-size: 12px; color: var(--ink-muted); font-family: "SF Mono", ui-monospace, Menlo, monospace; }
.modal-title-icon { width: 14px; height: 14px; color: var(--ink-muted); margin-right: 6px; vertical-align: -2px; }
.modal-footer { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; }

/* ── Pair-phone dialog ────────────────────────────────────────────────── */
.pair-dialog { width: min(420px, calc(100vw - 32px)); }
.pair-nearby-hint {
  margin: 0 0 4px;
  padding: 10px 12px;
  border-radius: 8px;
  background: color-mix(in srgb, var(--accent) 10%, transparent);
  border-left: 2px solid var(--accent);
  font-size: 13px;
  line-height: 1.4;
}
.pair-qr {
  display: flex;
  justify-content: center;
  margin: 16px auto;
  padding: 16px;
  background: #fff;
  border-radius: 12px;
  width: min(260px, 70vw);
  aspect-ratio: 1;
}
.pair-qr svg { width: 100%; height: 100%; }
.pair-meta { display: flex; flex-direction: column; gap: 6px; align-items: center; }
.pair-meta .mono {
  font-family: "SF Mono", ui-monospace, Menlo, monospace;
  font-size: 11px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  cursor: pointer;
  user-select: all;
  max-width: min(292px, calc(70vw + 32px));
  width: 100%;
  text-align: center;
}
.pair-meta .mono:hover { opacity: 0.75; }
.pair-meta .mono:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 3px; }
.pair-meta .hint { text-align: center; }
.pair-meta .hint.is-waiting::before {
  content: "";
  display: inline-block;
  width: 10px;
  height: 10px;
  margin-right: 8px;
  border: 1.5px solid var(--ink-muted);
  border-top-color: transparent;
  border-radius: 50%;
  animation: pair-spin 0.8s linear infinite;
  vertical-align: -1px;
}
@keyframes pair-spin { to { transform: rotate(360deg); } }

/* ── Helpers list (paired phones + this laptop's webcam) ──────────────── */
/* Helpers borrow the .robot card layout but read as supplementary, not
   peer to robots — they're operator-side accessories (extra eyes, extra
   hands), not controllable agents. Distinction is carried by a softer
   type-badge (no accent color) + tighter card padding + a gentler card
   surface so the eye finds robots first when both are present. */
/* Helpers are operator-side leftovers (laptop cam, paired phones not yet
   mounted on a robot) — the eye should land on the empty-state / robot
   cards first, then notice helpers as a quiet list. Card chrome would
   make them compete; rows separated by a divider read subordinate.

   The section header is iOS-Settings-quiet (small uppercase muted), not
   the bold "Your robots" register — that parallelism implied equal
   weight, but helpers are supporting cast. The "Pair phone" action lives
   below the list as an inline `+ Pair phone` so the action attaches to
   the section it adds to instead of competing with the section title. */
/* Helpers section reads as a contained card (border + tinted background)
   rather than orphan text floating on the page background. The dashboard's
   "everything is in a card" rhythm — robot cards, empty-state, settings
   modal — was breaking here: pre-style the section was a label + raw
   links sitting on the dark void, which Gestalt-proximity-wise read as
   detritus, not affordances. Same containment treatment matches the
   empty-state's framing so the eye reads "this is a section." */
.helpers-section { max-width: 440px; }
.helpers-section:has(#helpers-list:empty) { display: none; }
.helpers-heading {
  padding: 0 4px;
  margin: 24px 0 12px;
  display: flex;
  align-items: baseline;
  gap: 10px;
}
.helpers-heading .label { font-size: 13px; color: var(--ink-muted); font-weight: 500; }
.helpers-heading .meta-prose { font-size: 12px; color: var(--ink-muted); opacity: 0.75; }
#helpers-list { display: flex; flex-direction: column; gap: 16px; }
/* Topbar pair-phone button — small accent dot when nearby phones are
   broadcasting on wifi (presence-aware affordance). The hint moves to
   the title attr; the dot is the glance-level signal. */
.pair-phone-btn { position: relative; }
.pair-phone-btn.has-nearby::after {
  /* HIG online-presence convention (FaceTime / iMessage online badge):
     green dot says "ready," blue would read as "new / unread." */
  content: "";
  position: absolute;
  top: 4px;
  right: 4px;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: #34c759;
  box-shadow: 0 0 0 2px var(--bg);
}
.link-btn {
  background: transparent;
  border: none;
  padding: 4px 6px;
  color: var(--ink-muted);
  font: inherit;
  font-size: 13px;
  cursor: pointer;
  border-radius: 4px;
}
.link-btn:not(:disabled):hover { color: var(--ink); background: var(--border-subtle); }
#helpers-list > .card {
  margin-bottom: 0;
  max-width: none;
}
/* Helpers reuse the .robot card scaffold but NOT its identity signals.
   Robot cards carry a green left-stripe to mean "BLE-connected" — that
   semantic belongs to robots only. Neutralize the stripe on helpers so
   it can't be confused with robot-connected state. Active overhead is
   communicated via the role picker + the dot/pill status line, not at
   the card edge. */
.card.helper {
  padding: 16px 18px;
  border-left: 1px solid var(--border-default);
}
.helper .label-btn { font-size: 14px; }
.helper-preview {
  margin-top: 12px;
  position: relative;
  display: block;
  width: 100%;
  aspect-ratio: 16 / 9;
  border-radius: 8px;
  overflow: hidden;
  background: #000;
}
.helper-preview .helper-video {
  /* Live preview as a flush media tile — covers the container, no
     letterboxing. Aspect is set on the wrapper so a 4:3 phone or 4:3
     webcam crops cleanly to the 16:9 tile instead of bracketing black. */
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}
.helpers-empty { padding: 4px 0 0; }
/* Role picker — quiet inline control under the secondary row. Label uses
   .meta-prose (not .meta) so it reads as UI text, not data. */
.phone-mount {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  margin-top: 10px;
  font-size: 13px;
}
.phone-mount > .meta-prose { font-size: inherit; }
/* Strip native chrome and lay out our own chevron — native selects render
   the arrow crowded against the right edge across browsers, which the
   robot-card buttons + other modern UI in this dashboard don't. SVG is
   inline as a data URL so it ships without a separate asset. */
.phone-mount select {
  font: inherit;
  font-size: 13px;
  padding: 5px 30px 5px 10px;
  background-color: var(--bg);
  background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' fill='none' stroke='%238a8a92' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'><path d='M3 4.5l3 3 3-3'/></svg>");
  background-repeat: no-repeat;
  background-position: right 10px center;
  background-size: 12px;
  border: 1px solid var(--border-default);
  border-radius: 6px;
  color: var(--ink);
  cursor: pointer;
  appearance: none;
  -webkit-appearance: none;
  -moz-appearance: none;
}
.phone-mount select:hover { border-color: var(--ink-muted); }
.phone-mount select:focus-visible {
  outline: none;
  border-color: var(--accent);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 25%, transparent);
}

/* Permanent print-markers hint, visible whenever a helper is designated
   overhead. Sits between the role row and the preview tile so it reads
   as "to use this, you need these." Subtle text register; links inherit
   the muted color and underline on hover for affordance without shouting. */
.helper-markers-hint {
  margin-top: 8px;
  font-size: 12px;
  color: var(--ink-muted);
  opacity: 0.85;
}
.helper-markers-hint a {
  color: inherit;
  text-decoration: underline;
  text-underline-offset: 2px;
  text-decoration-color: color-mix(in srgb, currentColor 40%, transparent);
}
.helper-markers-hint a:hover { text-decoration-color: currentColor; }

/* ── Phone-side page (phone.html) ─────────────────────────────────────── */
body.phone {
  min-height: 100dvh;
  margin: 0;
  background: var(--bg);
  color: var(--ink);
  /* Global html/body rule above uses overflow:hidden for the desktop
     app-shell. phone.html's layout expects the body to scroll, so override. */
  overflow: auto;
  -webkit-overflow-scrolling: touch;
  /* Phone is a touch surface — drags that incidentally cross labels/
     headings shouldn't trigger text selection, and the iOS tap-highlight
     rectangle + long-press callout read as bugs when the user is trying
     to drive a robot. Suppress globally; re-enabled below on the few
     elements where text selection actually serves the user. */
  user-select: none;
  -webkit-user-select: none;
  -webkit-touch-callout: none;
  -webkit-tap-highlight-color: transparent;
}
/* Re-enable text selection only on inputs — phone is a remote control
   surface, not a reading view. The previous carve-out (Pip chat, scene
   caption) made the chat bubble selectable, which meant any finger drag
   that crossed it during driving accidentally selected text. Operator
   needs zero accidental selection while their thumb is steering; copying
   a Pip reply is rare enough to live without (a desktop user can copy
   from the dashboard log, which is the same content). */
body.phone input,
body.phone textarea {
  user-select: text;
  -webkit-user-select: text;
}
.phone-main {
  display: flex;
  flex-direction: column;
  min-height: 100dvh;
  max-width: 480px;
  margin: 0 auto;
  /* viewport-fit=cover: extend padding through the notch / home indicator
     on all four sides so the wordmark, status, and bottom button never
     render under the iOS status bar or home indicator. */
  padding:
    calc(20px + env(safe-area-inset-top))
    calc(20px + env(safe-area-inset-right))
    calc(20px + env(safe-area-inset-bottom))
    calc(20px + env(safe-area-inset-left));
  gap: 16px;
}
.phone-header {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
}
.phone-brand {
  font-size: 24px;
  letter-spacing: -0.01em;
}
.phone-brand .logo-accent { font-weight: 700; color: var(--brand-heading); }
.phone-brand .logo-base { font-weight: 400; color: color-mix(in srgb, var(--ink) 75%, transparent); }
.phone-brand-btn {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  background: transparent;
  border: none;
  padding: 4px 6px;
  margin: -4px -6px;
  border-radius: 6px;
  font: inherit;
  color: inherit;
  cursor: pointer;
}
.phone-brand-btn:hover { background: var(--line); }
.phone-brand-caret { width: 14px; height: 14px; color: var(--ink-muted); }
.phone-status {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  font-size: 13px;
  color: var(--ink-muted);
}
/* Discovery list shown when phone.html loads without a #pair= hash. Each
   button takes the user to the same room they would have reached via QR. */
.phone-nearby { display: flex; flex-direction: column; gap: 12px; margin-top: 12px; }
.phone-nearby-label {
  margin: 0 0 4px;
  font-size: 12px;
  color: var(--ink-muted);
}
#phone-nearby-list { display: flex; flex-direction: column; gap: 12px; }
.phone-nearby-btn {
  width: 100%;
  text-align: left;
  padding: 14px 16px;
  border: 1px solid var(--border-default);
  border-radius: 12px;
  background: var(--surface);
  color: var(--ink);
  font: inherit;
  font-size: 15px;
  cursor: pointer;
  min-height: 44px;
}
.phone-nearby-btn:hover { background: var(--surface-raised, var(--surface)); }
/* Trust-state affordances — filled = trusted (primary action), outlined =
   unknown (needs verification), alert = identity changed. The user reads
   the affordance shape before the text. */
.phone-nearby-status {
  margin: 6px 2px 0;
  font-size: 13px;
  color: var(--ink-muted);
  line-height: 1.4;
}
.phone-nearby-status.alert {
  color: #c0392b;
}
.phone-nearby-empty-hint {
  margin: 12px 2px;
  line-height: 1.4;
}
.phone-install {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
  margin-top: 14px;
  text-align: center;
}
.phone-install .hint { font-size: 12px; margin: 0; }

/* Presence — desktop subscribes to "phone-ready" ads on the same wifi
   and surfaces this small badge so the user can see discovery is live
   even before they open the pair dialog. */
.presence-badge {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 2px 8px;
  margin-right: 6px;
  border-radius: 999px;
  background: color-mix(in srgb, var(--accent) 14%, transparent);
  color: var(--accent);
  font-size: 11px;
  font-weight: 500;
}
/* Identity-changed alert variant — same shape, warning palette. The
   alert form is meaningful without colour alone (text reads "identity
   changed"), so colour-blind users still get the signal. */
.presence-badge.alert {
  background: color-mix(in srgb, #c0392b 16%, transparent);
  color: #c0392b;
}
.presence-badge.alert::before { background: #c0392b; }
.presence-badge::before {
  content: "";
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--accent);
  box-shadow: 0 0 0 0 currentColor;
  animation: presence-pulse 2s ease-out infinite;
}
@keyframes presence-pulse {
  0%   { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 50%, transparent); }
  70%  { box-shadow: 0 0 0 6px transparent; }
  100% { box-shadow: 0 0 0 0 transparent; }
}
/* Respect prefers-reduced-motion: the pulse is decorative — the badge
   appearing is the actual signal — so we suppress the animation rather
   than slow it down. */
@media (prefers-reduced-motion: reduce) {
  .presence-badge::before { animation: none; }
}
/* Dark-mode contrast: duke-royal-blue on the 14%-over-#1a1d20 composite
   lands at ~2.0:1, below WCAG AA for small text. Lift the text to a
   brighter blue that clears 4.5:1 while keeping the "blue badge" read. */
@media (prefers-color-scheme: dark) {
  .presence-badge {
    color: #5aa9d6;
  }
  .presence-badge::before {
    background: #5aa9d6;
  }
}

/* Pair-request modal — AirDrop-shape. Buttons mirror native iOS alerts
   so the affordance is familiar; the trust checkbox makes the
   "remember this device" choice explicit and reversible. */
.pair-request-dialog { max-width: 420px; }
.pair-request-body {
  display: flex;
  align-items: flex-start;
  gap: 14px;
  margin: 6px 0 14px;
}
.pair-request-icon {
  flex-shrink: 0;
  width: 32px;
  height: 32px;
  margin-top: 2px;
  color: var(--ink-muted);
}
.pair-request-text {
  margin: 0 0 6px;
  font-size: 15px;
  line-height: 1.4;
}
.pair-request-safety {
  margin: 0;
  font-size: 13px;
  color: var(--ink-muted);
  line-height: 1.4;
}
.pair-request-trust-row {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-bottom: 14px;
  font-size: 13px;
  color: var(--ink-muted);
  cursor: pointer;
}
.pair-request-trust-row input { width: 18px; height: 18px; }
/* Live remote camera (desktop → phone). One source at a time today. */
.phone-cam-section {
  display: flex;
  flex-direction: column;
  gap: 6px;
}
.phone-cam {
  width: 100%;
  border-radius: 12px;
  background: #000;
  display: block;
  aspect-ratio: 4 / 3;
  object-fit: cover;
}
.phone-cam-label { padding-left: 4px; }

/* Camera tile becomes tap-to-pick when multiple sources are available.
   The button wraps the <video> so the existing srcObject pipeline is
   unchanged; the overlay sits above and hides itself when there's only
   one source (no point inviting a tap that goes nowhere). */
.phone-cam-tap {
  position: relative;
  width: 100%;
  padding: 0;
  margin: 0;
  background: transparent;
  border: none;
  cursor: pointer;
  display: block;
}
.phone-cam-overlay {
  position: absolute;
  bottom: 8px;
  right: 8px;
  font-size: 11px;
  padding: 4px 10px;
  border-radius: 999px;
  background: rgba(0, 0, 0, 0.6);
  color: #fff;
  pointer-events: none;
}
/* Picker rows — surface-styled list of "{type} {label} {check?}". Sits
   below the camera tile (not absolutely positioned) so it doesn't trap
   focus or fight the bottom-sheet feel of the rest of the phone. */
.phone-cam-picker {
  display: flex;
  flex-direction: column;
  gap: 4px;
  padding: 6px;
  border-radius: 8px;
  background: var(--bg-elev);
  border: 1px solid var(--border-subtle);
}
.phone-cam-pick-row {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 8px 10px;
  border-radius: 6px;
  background: transparent;
  border: 1px solid transparent;
  cursor: pointer;
  font: inherit;
  color: inherit;
  text-align: left;
}
.phone-cam-pick-row.active { border-color: var(--accent); }
.phone-cam-pick-row:hover  { background: var(--border-subtle); }
.phone-cam-pick-check { margin-left: auto; color: var(--accent); }

/* Phone-camera-as-helper toggle (outgoing: phone → desktop). Sentence case
   per a11y.md; 44pt tap target; focus-visible preserved. Preview is the
   user's own camera feed so they can see what's being shared. */
.phone-share {
  display: flex;
  flex-direction: column;
  gap: 8px;
}
/* Shared action button for "+ Pair remote device" / "+ Share this
   device's camera" — used on both dashboard (helpers-add row) and
   phone (.phone-share section). The phone-share-btn name was
   surface-specific; the affordance isn't, so the class is generic now.
   44pt min height for thumb targets; sentence case per a11y; .on
   variant used by mobile.js to indicate "currently sharing." */
.share-action-btn {
  min-height: 44px;
  padding: 10px 16px;
  border-radius: 12px;
  border: 1px solid var(--border-default);
  background: var(--surface);
  color: var(--ink);
  font: inherit;
  font-size: 15px;
  cursor: pointer;
}
.share-action-btn.on {
  background: var(--accent);
  color: #fff;
  border-color: var(--accent);
}
.share-action-btn:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}
.phone-share-preview {
  width: 100%;
  border-radius: 12px;
  background: #000;
  display: block;
  aspect-ratio: 4 / 3;
  object-fit: cover;
}
/* Reconnect surface — visible only when not paired or after a drop. Hosts
   the QR scanner so the user can re-pair without going back to the desktop. */
.phone-reconnect {
  display: flex;
  flex-direction: column;
  gap: 12px;
  padding: 16px;
  border: 1px solid var(--border-default);
  border-radius: 12px;
  background: var(--surface);
}
.phone-reconnect-message { color: var(--ink-muted); font-size: 14px; line-height: 1.45; }
.phone-scanner {
  position: relative;
  border-radius: 12px;
  overflow: hidden;
  background: #000;
  aspect-ratio: 1 / 1;
}
.phone-scanner-video {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}
/* Visible target rectangle so the user knows where to point. Pure CSS —
   the QR detector reads the entire frame, the box is just a hint. */
.phone-scanner-frame {
  position: absolute;
  inset: 15%;
  border: 2px solid color-mix(in srgb, var(--accent) 80%, white);
  border-radius: 8px;
  pointer-events: none;
  box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.35);
}
#phone-scanner-cancel { position: absolute; right: 8px; top: 8px; }
.phone-scanner-fallback { padding: 4px; }
/* Panic button — panic-red always, because its purpose is to be found fast
   when the robot is doing the wrong thing. Status line auto-hides after ~3s. */
.phone-command { display: flex; flex-direction: column; gap: 6px; }
.phone-stop-btn {
  width: 100%;
  padding: 14px;
  border-radius: 12px;
  border: 1px solid var(--danger);
  background: var(--danger);
  color: var(--accent-ink);
  font-size: 16px;
  font-weight: 600;
}
.phone-stop-btn:disabled { opacity: 0.6; }
.phone-command-status {
  margin: 0;
  padding: 6px 10px;
  font-size: 13px;
  color: var(--ink-muted);
  text-align: center;
}
.phone-command-status.ok    { color: var(--ink); }
.phone-command-status.alert { color: var(--danger); }

/* Sticky panic-stop. Sits OUTSIDE the document scroll via position: sticky
   so it pins to the bottom of the viewport while the user scrolls camera /
   drive / Pip. iOS safe-area inset handles the home-indicator gap.
   Layout reorder note: this sits at the BOTTOM of the DOM so it's the
   last layout-flow element — the parent's padding-bottom keeps content
   from being hidden behind it when scrolled to the end. */
.phone-command-sticky {
  position: sticky;
  bottom: 0;
  z-index: 10;
  margin-top: 12px;
  /* Solid backdrop so content scrolling underneath doesn't show through.
     Same surface color as phone body to feel like part of the chrome. */
  background: var(--bg);
  padding: 8px 0 calc(8px + env(safe-area-inset-bottom));
  /* Subtle separator above so the operator can tell where scrollable
     content ends and the fixed control starts. */
  box-shadow: 0 -1px 0 var(--border-subtle);
}

/* Pip accordion. Collapsed = just a row with title + status + unread dot;
   expanded = the chat bubble + input. Auto-expanded by mobile.js when Pip
   pushes a message worth reading. The "details > summary" pattern is
   native + accessible — the disclosure caret is browser-supplied, so we
   don't have to wire keyboard / aria ourselves. */
/* ask_human modal on the phone. Preempts chat / drive so the user notices
   Pip is waiting on them. Image is the robot's current frame; options row
   for tappable answers, free-text form when Pip asks open-ended. Skip
   resolves the ask with null server-side so Pip doesn't block forever. */
.ask-dialog {
  border: none;
  border-radius: 14px;
  padding: 18px;
  max-width: min(420px, calc(100vw - 24px));
  background: var(--surface);
  color: var(--ink);
  box-shadow: 0 24px 48px rgba(0, 0, 0, 0.5);
}
.ask-dialog::backdrop { background: rgba(0, 0, 0, 0.6); }
.ask-image {
  display: block;
  width: 100%;
  max-height: 240px;
  object-fit: contain;
  background: #000;
  border-radius: 8px;
  margin-bottom: 12px;
}
.ask-question {
  font-size: 16px;
  font-weight: 500;
  margin: 0 0 14px;
  line-height: 1.35;
}
.ask-options {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin-bottom: 10px;
}
.ask-option { flex: 1 1 auto; min-width: 88px; }
.ask-free { display: flex; gap: 8px; margin-bottom: 10px; }
.ask-free input { flex: 1; padding: 8px 10px; border-radius: 8px; border: 1px solid var(--border-default); background: var(--bg); color: var(--ink); font-size: 14px; }
.ask-footer { display: flex; justify-content: flex-end; }

@media print {
  body > main, #robot-menu { display: none !important; }
  .label-modal { display: block; position: static; box-shadow: none; border: none; }
  .label-modal::backdrop { display: none; }
  .modal-header button, .modal-footer { display: none; }
}

/* Helpers preview tile + ArUco overlay. The <svg> overlay sits on top of
   the <video> via absolute positioning; its viewBox matches the video's
   frame dimensions so detected-marker corner coords paint without per-
   render rescaling. preserveAspectRatio default ("xMidYMid meet") tracks
   the letterboxing of `object-fit: contain`. */
.helper-preview { position: relative; display: block; }
.helper-preview .helper-video { display: block; width: 100%; border-radius: 4px; }
.helper-preview .aruco-overlay {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
  border-radius: 4px;
}
.aruco-overlay polygon {
  fill: color-mix(in srgb, var(--success) 22%, transparent);
  stroke: var(--success);
  stroke-width: 2;
  vector-effect: non-scaling-stroke;
}
.aruco-overlay line.heading {
  stroke: var(--success);
  stroke-width: 3;
  stroke-linecap: round;
  vector-effect: non-scaling-stroke;
}
.aruco-overlay text {
  fill: var(--success);
  font-size: 14px;
  font-weight: 600;
  text-anchor: middle;
  paint-order: stroke;
  stroke: rgba(0, 0, 0, 0.6);
  stroke-width: 3;
}
.aruco-status {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  margin-top: 10px;
  font-size: 12px;
  color: var(--ink-muted);
}
.aruco-status::before {
  content: "";
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--ink-muted);
  flex-shrink: 0;
}
.aruco-status.aruco-locked { color: var(--success); }
.aruco-status.aruco-locked::before {
  background: var(--success);
  box-shadow: 0 0 6px color-mix(in srgb, var(--success) 55%, transparent);
}
.aruco-status a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
