:root {
  color-scheme: dark;  /* dark-only surface — native form controls + scrollbars render dark */
  /* 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;

  /* 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;
  /* translucent-on-ink borders — adapt to any bg without a mode override */
  --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);  /* recessed track inside a --surface element */
  --accent: var(--duke-royal-blue);
  --accent-hover: var(--duke-navy);
  --accent-ink: #ffffff;
  --brand-heading: var(--duke-light-blue);  /* navy on near-black is unreadable; lift to light-blue */
  --danger: #d70015;
  --danger-text: #ff6158;  /* danger as text — #c0392b/#d70015 are ~2.8:1 on these surfaces; this clears 5:1 even on the alert-chip tint */
  --warn: #d4a44a;  /* alert-chip warm/weak severity — the amber shared by the old .warning-chip, .pinout-warn-line, .cal-warn */
  --success: #30a46c;
  --success-text: color-mix(in srgb, var(--success) 80%, white);  /* success as small chip text — --success dips to 4.1:1 on the chip bg */
  --link: color-mix(in srgb, var(--duke-light-blue) 70%, white);  /* accent as small text — >=5.2:1 on this file's chip tints; royal is 1.7:1 */
  --focus: var(--duke-light-blue);  /* focus indicators — 3.1-3.5:1 on bg/surface; royal is 2:1, below the 3:1 floor */
  --radius: 8px;  /* tighter — reads as panel, not floating card */
  --shadow: 0 1px 2px rgba(0,0,0,0.3), 0 8px 24px rgba(0,0,0,0.5);
  --topbar-height: 52px;

  /* 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);
}

* { 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: var(--topbar-height);
  flex-shrink: 0;
  background: var(--bg);
  border-bottom: 1px solid var(--border-default);
}
.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;
  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(--border-default); }
.topbar-title-caret {
  width: 12px;
  height: 12px;
  color: var(--ink-muted);
  /* No flex `gap` on the button — that would also land between .logo-accent
     and .logo-base, splitting "BetterRobotics" into two words. This margin
     is the caret's own spacing (was 2px flex-gap + 2px margin combined). */
  margin-left: 6px;
}
/* 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; }
/* Match the avatar circle's 32px box so all three topbar controls share one
   hit-target size and sit on the same vertical rhythm. */
.topbar-actions .icon { font-size: 18px; width: 32px; height: 32px; min-width: 32px; min-height: 32px; padding: 0; }

/* 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(--border-default);
  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(--border-default); }
button.secondary:not(:disabled):hover { background: var(--border-subtle); }
button.sm { min-height: 36px; padding: 6px 12px; font-size: 14px; }
/* HIG floor on the phone surface — scanner Cancel is the only exit from
   the scanner-active state, and ask-dialog Send/Skip are confirmation
   gestures that need accurate targeting. */
body.phone button.sm { min-height: 44px; padding: 10px 14px; }

/* Link that must sit in a button slot (cap-section action) and read as one —
   the Camera cap swaps its Start button for an "Open in new tab ↗" anchor
   when mixed content blocks the inline stream. */
a.secondary.sm {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  font: inherit;
  font-weight: 500;
  text-decoration: none;
  cursor: pointer;
  padding: 6px 12px;
  border-radius: 10px;
  min-height: 36px;
  font-size: 14px;
  background: transparent;
  color: var(--ink);
  border: 1px solid var(--border-default);
  transition: filter 0.2s;
}
a.secondary.sm:hover { background: var(--border-subtle); filter: brightness(1.08); }
a.secondary.sm:active { filter: brightness(0.94); }
body.phone a.secondary.sm { min-height: 44px; padding: 10px 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(--border-default); 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(--border-default); 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(--border-default);
  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(--focus);
}
.menu {
  position: fixed;
  margin: 0;
  background: var(--surface);
  border: 1px solid var(--border-default);
  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(--border-default); }
.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(--border-default);
}
.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; }
@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-text); }
.type-badge.type-esp32 { color: var(--link); }
/* 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); }

/* 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; }

/* Shared "needs attention" component — one grammar for every place the
   app needs to flag something (outlined pill, solid light-mode
   rectangle, and color-mix tinted rect used to be three incompatible
   treatments of the same semantic role). Dark-aware by construction:
   color-mix against the existing semantic tokens instead of hardcoded
   hex pairs. Base is block-level flex so long banner text (pinout/cal
   wizard notices) wraps normally; .pill switches to inline-flex,
   shrink-to-content sizing for status-row chips that sit side by side. */
.alert-chip {
  display: flex;
  align-items: center;
  gap: 6px;
  margin: 6px 0;
  padding: 6px 10px;
  border-radius: 6px;
  font-size: 12px;
  line-height: 1.5;
  border: 1px solid transparent;
}
.alert-chip strong { font-weight: 600; }
.alert-chip code {
  font-family: "SF Mono", ui-monospace, Menlo, monospace;
  font-size: 10px;
  padding: 0 3px;
  border-radius: 3px;
  background: color-mix(in srgb, var(--ink) 8%, transparent);
}
.alert-chip svg { flex-shrink: 0; }
.alert-chip.pill {
  display: inline-flex;
  margin: 0;
  padding: 2px 8px;
  border-radius: 999px;
  font-size: 11px;
  line-height: normal;
  font-variant-numeric: tabular-nums;
}
.alert-chip.warn {
  background: color-mix(in srgb, var(--warn) 12%, transparent);
  border-color: color-mix(in srgb, var(--warn) 30%, transparent);
  color: var(--warn);
}
.alert-chip.danger {
  background: color-mix(in srgb, var(--danger) 12%, transparent);
  border-color: color-mix(in srgb, var(--danger) 30%, transparent);
  color: var(--danger-text);
}
.alert-chip.info {
  background: color-mix(in srgb, var(--accent) 12%, transparent);
  border-color: color-mix(in srgb, var(--accent) 30%, transparent);
  color: var(--ink);
}
/* Success — not one of the three canonical severities, but pip's reflex
   "motion resumed" notice needs a distinct-from-warn "all clear" tone
   (was green, separate from the amber halt notice); reuses the existing
   --success tokens rather than forcing it into .info and losing the
   halt/resume color distinction. */
.alert-chip.success {
  background: color-mix(in srgb, var(--success) 12%, transparent);
  border-color: color-mix(in srgb, var(--success) 30%, transparent);
  color: var(--success-text);
}

/* 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;
  /* Ring, not outline — closer to Apple's native feel; solid because the
     35% tint sat far below the 3:1 focus-indicator floor. */
  box-shadow: 0 0 0 2px var(--focus);
}

/* "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); }
/* When the card isn't connected or connecting (disconnected or errored),
   expanding shows nothing useful. Hide the chevron via visibility:hidden so
   the header layout stays identical, and kill the affordance on the button.
   The real no-op gate lives in app.js's toggleExpand — pointer-events here
   is cosmetic (the row's click handler would otherwise fire when the
   pointer-events:none button is clicked over). */
.robot:not(.status-connected):not(.status-connecting) .disclosure-chevron {
  visibility: hidden;
}
.robot:not(.status-connected):not(.status-connecting) .label-btn {
  pointer-events: none;
  cursor: default;
}
.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(--link);  /* not --accent — royal on the tint fails AA */
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

@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(--border-default);
}
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; }

#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(--border-default);
}

/* 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(--border-default);
}
.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 2px var(--focus);
}
.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(--border-default);
  padding-left: 12px;
}
@media (prefers-reduced-motion: reduce) { .cap-chevron { transition: none; } }

/* 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: color-mix(in srgb, var(--warn) 20%, transparent); 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; } }
@media (prefers-reduced-motion: reduce) { .robot-state::before { animation: none; } }


/* IDE view (ide/ide.js): see USER-CODE.md. A full-viewport surface, not a
   modal — the toolbar stands in for the app header (same pattern + reasoning
   as the serial console's [open]-scoped full-bleed rule above). */
#ide-modal[open] {
  position: fixed;
  inset: 0;
  margin: 0;
  width: 100vw;
  height: 100dvh;
  max-width: none;
  max-height: none;
  min-width: 0;
  min-height: 0;
  padding: 0;
  border-radius: 0;
  box-shadow: none;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}
#ide-modal::backdrop { background: transparent; }

.ide-toolbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  height: var(--topbar-height);
  padding: 0 20px;
  border-bottom: 1px solid var(--border-subtle);
  flex: none;
}
.ide-toolbar-left, .ide-toolbar-right { display: flex; align-items: center; gap: 10px; min-width: 0; }
.ide-toolbar-left .label { flex: none; }
.ide-active-file {
  font-size: 12px;
  color: var(--ink-muted);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.ide-toolbar-right #ide-run { white-space: nowrap; flex: none; }
/* Inline keyboard hint inside the Run button — slightly demoted vs the label. */
.kbd-hint {
  margin-left: 6px;
  padding: 1px 5px;
  border-radius: 4px;
  background: color-mix(in srgb, var(--accent-ink) 18%, transparent);
  color: var(--accent-ink);
  font: inherit;
  font-size: 11px;
  opacity: 0.85;
}
/* 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. */
.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-default);
  border-radius: 6px;
  padding: 6px 30px 6px 10px;
  cursor: pointer;
  min-width: 0;
  max-width: 220px;
  text-overflow: ellipsis;
  overflow: hidden;
  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(--focus); outline-offset: 1px; }

/* Body: file explorer | editor+tabs+output. */
.ide-body { display: flex; flex: 1; min-height: 0; }
.ide-sidebar {
  width: 232px;
  flex: none;
  display: flex;
  flex-direction: column;
  border-right: 1px solid var(--border-subtle);
  background: color-mix(in srgb, var(--ink) 3%, transparent);
}
.ide-sidebar-head {
  flex: none;
  padding: 10px 14px 6px;
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--ink-muted);
}
.ide-tree { flex: 1; overflow-y: auto; padding-bottom: 8px; }
.ide-tree[hidden] { display: none; }
.ide-main { display: flex; flex-direction: column; flex: 1; min-width: 0; min-height: 0; }

/* Activity bar — the VS Code icon strip. Each icon switches the sidebar
   panel; the active one carries a left accent rule. */
.ide-activity {
  width: 48px;
  flex: none;
  display: flex;
  flex-direction: column;
  align-items: center;
  padding-top: 6px;
  gap: 2px;
  border-right: 1px solid var(--border-subtle);
  background: color-mix(in srgb, var(--ink) 5%, transparent);
}
.ide-act {
  width: 44px;
  height: 44px;
  display: flex;
  align-items: center;
  justify-content: center;
  border: none;
  background: none;
  cursor: pointer;
  color: var(--ink-muted);
  border-radius: 10px;
  transition: color 0.1s ease, background 0.1s ease;
}
.ide-act:hover { color: var(--ink); background: color-mix(in srgb, var(--ink) 8%, transparent); }
/* Active view: soft rounded fill + full-ink icon — same language as the
   active tab and selected tree row, not a hard VS Code-style rule. */
.ide-act.active { color: var(--ink); background: color-mix(in srgb, var(--ink) 16%, transparent); }
/* 24px, VS Code's activity-bar icon size. flex: none is load-bearing — the
   svg has only a viewBox (no intrinsic width), so inside the flex button it
   would otherwise shrink to min-content (~12px) while height stayed set, which
   is what made them look half-size before. */
.ide-act .icon-svg { width: 24px; height: 24px; flex: none; }

/* Flash view — swapped in for the file tree when the Flash activity is on. */
.ide-flash-view { flex: 1; overflow-y: auto; padding: 4px 0 8px; }
.ide-flash-view[hidden] { display: none; }
.ide-flash-robot {
  padding: 8px 12px 2px;
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--ink-muted);
}

/* Collapsible section twisty (▾ expanded, ▸ collapsed). */
.ide-tree-chevron {
  display: inline-flex;
  flex: none;
  color: var(--ink-muted);
  cursor: pointer;
  transition: transform 0.1s ease;
}
.ide-tree-chevron .icon-svg { width: 12px; height: 12px; }
.ide-tree-section.collapsed .ide-tree-chevron { transform: rotate(-90deg); }
.ide-tree-title { cursor: pointer; }

/* Code-file glyph, dimmed accent — same slot in tree rows and tabs. */
.ide-file-icon { display: inline-flex; flex: none; color: var(--accent-ink); opacity: 0.85; }
.ide-file-icon .icon-svg { width: 14px; height: 14px; }

/* Status bar — VS Code's signature bottom strip. */
.ide-statusbar {
  flex: none;
  display: flex;
  align-items: center;
  justify-content: space-between;
  height: 22px;
  padding: 0 12px;
  font-size: 11px;
  color: var(--ink-muted);
  background: color-mix(in srgb, var(--accent-ink) 12%, var(--bg));
  border-top: 1px solid var(--border-subtle);
}
.ide-status-left { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ide-status-right { display: flex; gap: 14px; flex: none; }

/* Tabs strip. */
.ide-tabs {
  display: flex;
  align-items: stretch;
  gap: 1px;
  overflow-x: auto;
  border-bottom: 1px solid var(--border-subtle);
  flex: none;
  min-height: 34px;
}
.ide-tab {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 0 8px 0 12px;
  font-size: 12px;
  color: var(--ink-muted);
  cursor: pointer;
  border-right: 1px solid var(--border-subtle);
  white-space: nowrap;
  background: color-mix(in srgb, var(--ink) 4%, transparent);
}
/* Active tab: lifts to the editor's ground with a top accent rule, VS Code's
   open-tab treatment. Inactive tabs sit slightly recessed. */
.ide-tab.active { color: var(--ink); background: var(--bg); box-shadow: inset 0 2px 0 var(--accent-ink); }
.ide-tab:not(.active):hover { color: var(--ink); }
.ide-tab .ide-file-icon { opacity: 0.7; }
.ide-tab.active .ide-file-icon { opacity: 1; }
.ide-tab-label { overflow: hidden; text-overflow: ellipsis; max-width: 22ch; }
.ide-tab-dirty { color: var(--accent-ink); font-size: 9px; line-height: 1; flex: none; }
.ide-tab-close {
  border: none; background: none; cursor: pointer;
  color: var(--ink-muted); font-size: 15px; line-height: 1;
  padding: 2px 3px; border-radius: 4px;
}
.ide-tab-close:hover { background: color-mix(in srgb, var(--ink) 12%, transparent); color: var(--ink); }

/* Monaco host fills the remaining column. */
.ide-editor { flex: 1; min-height: 0; }

.ide-output {
  font-family: "SF Mono", ui-monospace, Menlo, monospace;
  font-size: 12px;
  max-height: 30vh;
  overflow-y: auto;
  padding: 8px 12px;
  border-top: 1px solid var(--border-subtle);
  background: color-mix(in srgb, var(--ink) 3%, transparent);
  white-space: pre-wrap;
  color: var(--ink-muted);
  flex: none;
}
.ide-output[hidden] { display: none; }
.ide-output > div { padding: 1px 0; }
.ide-out-error { color: var(--danger); }
.ide-out-note { color: var(--ink-muted); opacity: 0.75; }

/* File tree. */
.ide-tree-section {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 6px 10px 4px;
}
.ide-tree-title {
  flex: 1;
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--ink-muted);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.ide-tree-meta { font-size: 10px; color: var(--ink-muted); opacity: 0.7; }
.ide-tree-add {
  border: none; background: none; cursor: pointer;
  color: var(--ink-muted); font-size: 16px; line-height: 1;
  padding: 0 4px; border-radius: 4px; flex: none;
}
.ide-tree-add:hover { background: color-mix(in srgb, var(--ink) 12%, transparent); color: var(--ink); }
.ide-tree-list { list-style: none; margin: 0 0 8px; padding: 0; }
.ide-tree-loading, .ide-tree-empty {
  padding: 4px 12px; font-size: 12px; color: var(--ink-muted); opacity: 0.7;
}
.ide-tree-row {
  display: flex;
  align-items: center;
  gap: 4px;
  padding: 0 6px 0 14px;
}
.ide-tree-row:hover { background: color-mix(in srgb, var(--ink) 6%, transparent); }
/* Open file selected — VS Code highlights the active editor's tree entry. */
.ide-tree-row.active { background: color-mix(in srgb, var(--accent-ink) 16%, transparent); }
.ide-tree-file {
  flex: 1;
  display: flex;
  align-items: center;
  gap: 7px;
  text-align: left;
  border: none; background: none; cursor: pointer;
  color: var(--ink);
  font: inherit;
  font-size: 12.5px;
  padding: 5px 0;
  overflow: hidden;
  min-width: 0;
}
.ide-tree-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ide-tree-file .ide-file-icon { opacity: 0.65; }
.ide-tree-size { font-size: 10px; color: var(--ink-muted); opacity: 0.7; flex: none; }
.ide-tree-del {
  border: none; background: none; cursor: pointer;
  font-size: 11px; opacity: 0; flex: none;
  padding: 2px 3px; border-radius: 4px;
}
.ide-tree-row:hover .ide-tree-del { opacity: 0.6; }
.ide-tree-del:hover { opacity: 1; background: color-mix(in srgb, var(--danger) 18%, transparent); }

/* Firmware section — flash map + OTA, per robot. Visually distinct from the
   file list (those are editable; this is raw flash). */
.ide-fw {
  margin: 4px 10px 12px;
  padding: 8px 10px;
  border: 1px solid var(--border-subtle);
  border-radius: 6px;
  background: color-mix(in srgb, var(--ink) 2%, transparent);
}
.ide-fw-head {
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--ink-muted);
}
.ide-fw-meta { font-size: 12px; color: var(--ink); margin-top: 2px; }
.ide-fw-parts { list-style: none; margin: 6px 0; padding: 0; }
.ide-fw-part {
  display: flex;
  justify-content: space-between;
  gap: 8px;
  font-size: 11px;
  color: var(--ink-muted);
  padding: 1px 0;
}
.ide-fw-part.running { color: var(--ink); }
.ide-fw-part.running .ide-fw-part-label { color: var(--accent-ink); }
.ide-fw-part-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ide-fw-part-size { flex: none; opacity: 0.8; }
.ide-fw-status { font-size: 11px; color: var(--accent-ink); min-height: 0; margin: 2px 0; }
.ide-fw-status:empty { display: none; }
.ide-fw-actions { display: flex; gap: 6px; margin-top: 6px; }
.ide-fw-btn {
  flex: 1;
  font: inherit;
  font-size: 11px;
  padding: 4px 6px;
  border: 1px solid var(--border-default);
  border-radius: 5px;
  background: color-mix(in srgb, var(--ink) 4%, transparent);
  color: var(--ink);
  cursor: pointer;
}
.ide-fw-btn:hover { background: color-mix(in srgb, var(--ink) 10%, transparent); }

/* Monitor view — full-width device dashboard. When its activity is on, the
   sidebar + editor step aside and this takes the whole body width. */
.ide-sidebar[hidden], .ide-main[hidden] { display: none; }
.ide-monitor { flex: 1; overflow-y: auto; padding: 20px 24px; }
.ide-monitor[hidden] { display: none; }
.ide-mon-empty { color: var(--ink-muted); font-size: 13px; padding: 24px; }
.ide-mon-panel { max-width: 1100px; margin: 0 auto 24px; }

.ide-mon-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
.ide-mon-title { font-size: 15px; font-weight: 500; }
.ide-mon-meta { font-size: 12px; color: var(--ink-muted); font-family: "SF Mono", ui-monospace, Menlo, monospace; }
.ide-mon-head-right { display: flex; align-items: center; gap: 14px; font-size: 12px; color: var(--ink-muted); }
.ide-mon-online { display: flex; align-items: center; gap: 6px; }
.ide-mon-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--success); }
.ide-mon-refresh { font-family: "SF Mono", ui-monospace, Menlo, monospace; }

.ide-mon-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 12px; margin-bottom: 16px; }
.ide-mon-tile { background: color-mix(in srgb, var(--ink) 4%, transparent); border-radius: 10px; padding: 12px 14px; }
.ide-mon-tile-label { font-size: 12px; color: var(--ink-muted); margin-bottom: 4px; }
.ide-mon-tile-value { font-size: 22px; font-weight: 500; }
.ide-mon-tile-sub { font-size: 11px; color: var(--ink-muted); margin-top: 2px; }
.ide-mon-tile-sub.ok { color: var(--success); }
.ide-mon-tile-sub.warn { color: #d9a441; }
.ide-mon-tile-sub.danger { color: var(--danger); }

.ide-mon-chartwrap { margin-bottom: 20px; }
.ide-mon-legend { display: flex; gap: 16px; font-size: 12px; color: var(--ink-muted); margin-bottom: 8px; }
.ide-mon-legend span { display: flex; align-items: center; gap: 5px; }
.ide-mon-legend i { width: 10px; height: 10px; border-radius: 2px; display: inline-block; }
.ide-mon-chart { width: 100%; height: 180px; display: block; }

.ide-mon-bottom { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 12px; }
.ide-mon-card { background: color-mix(in srgb, var(--ink) 3%, transparent); border: 1px solid var(--border-subtle); border-radius: 12px; padding: 14px 16px; }
.ide-mon-card-title { font-size: 13px; font-weight: 500; color: var(--ink-muted); margin-bottom: 12px; }
.ide-mon-part { margin-bottom: 10px; font-size: 12px; }
.ide-mon-part-top { display: flex; justify-content: space-between; margin-bottom: 4px; }
.ide-mon-part-label { font-family: "SF Mono", ui-monospace, Menlo, monospace; }
.ide-mon-part-amt { color: var(--ink-muted); }
.ide-mon-bar { height: 6px; background: color-mix(in srgb, var(--ink) 8%, transparent); border-radius: 3px; overflow: hidden; }
.ide-mon-bar-fill { height: 100%; background: var(--accent); border-radius: 3px; }
.ide-mon-table { width: 100%; font-size: 12px; border-collapse: collapse; }
.ide-mon-table td { padding: 4px 0; }
.ide-mon-k { color: var(--ink-muted); }
.ide-mon-v { text-align: right; font-family: "SF Mono", ui-monospace, Menlo, monospace; }

/* 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: var(--danger); background: color-mix(in srgb, var(--danger) 15%, transparent); }
/* 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(--border-default); }
.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: color-mix(in srgb, var(--danger) 12%, transparent);
  color: var(--danger-text);
  font-size: 13px;
}
/* Informational variant — config is valid but a constraint elsewhere
   will silently disable the cap (e.g. C3 LEDC channels exhausted when
   RGB is assigned but motors are in PWM-on-direction). Amber tint
   distinguishes "you'll want to know this" from the red "fix this". */
.pinout-warn-info {
  background: color-mix(in srgb, var(--warn) 12%, transparent);
  color: var(--warn);
}
/* 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(--border-default);
}
.toolbar-toggle {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 14px;
  cursor: pointer;
}
.toolbar-toggle .pinout-edit-input { width: 50px; }
/* RGB toolbar slot — three inputs in a row with tighter spacing so the
   triple reads as one labeled group. */
.toolbar-toggle-rgb { gap: 4px; }
.toolbar-toggle-rgb > span { margin-right: 4px; }
.toolbar-toggle-rgb .pinout-edit-input { width: 36px; }
.pinout-helper { margin-top: 8px; font-size: 12px; }
/* Compact one-line warnings — .alert-chip (warn = soft-only, danger = at
   least one hard conflict) replacing the old stacked banners. warn-hard
   still bolds just the "Conflict:" span within a mixed hard+soft 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(--border-default);
  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; }

.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(--border-default);
  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;
}

/* 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(--border-default);
}
.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(--focus); 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); } }
@media (prefers-reduced-motion: reduce) { .wifi-spinner { animation: none; } }

.help-popover {
  border: 1px solid var(--border-default);
  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;
}

/* RGB color picker — sits in the same action slot as level-slider. The
   browser's native input[type=color] is a tiny color square; default
   chrome (border, padding) varies across platforms, so flatten it to a
   uniform swatch that visually matches the level slider's footprint. */
.rgb-picker {
  width: 44px;
  height: 28px;
  padding: 0;
  border: 1px solid var(--border, rgba(0, 0, 0, 0.12));
  border-radius: 6px;
  background: transparent;
  cursor: pointer;
  vertical-align: middle;
}
.rgb-picker::-webkit-color-swatch-wrapper { padding: 2px; }
.rgb-picker::-webkit-color-swatch { border: none; border-radius: 4px; }
.rgb-picker::-moz-color-swatch { border: none; border-radius: 4px; }

/* 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(--focus);
  outline-offset: 1px;
}
/* Phone surface: segmented buttons get hit mid-action (Joypad/Tilt while
   driving, Front/Back while pointing the phone). 44pt HIG minimum. */
body.phone .segmented .mode-btn { min-height: 44px; }
/* 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; }
@media (prefers-reduced-motion: reduce) { .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;
}
/* 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) — full-viewport takeover
   rather than a centered floating modal: a terminal session is a workspace,
   not a quick prompt. Its toolbar replaces the "BetterRobotics" topbar
   outright (inset: 0 paints over it — the dialog follows the topbar in DOM
   with no competing z-index), reclaiming the topbar's row for the terminal.
   flex-column so the active section's term container has a measurable size
   for FitAddon; recovery.js's ResizeObserver re-fits xterm on any viewport
   resize. */
/* [open]-scoped: an unscoped #console-modal rule would out-specificity the
   UA's `dialog:not([open]) { display: none }` (ID beats attribute+element),
   leaving a full-viewport `position: fixed` box laid out — invisible via
   the shared `dialog { opacity: 0 }` transition rule, but still `pointer-
   events: auto` and hit-testable, silently eating every click on the page
   while "closed". Scoping to [open] lets the UA rule govern the closed
   state normally. */
#console-modal[open] {
  position: fixed;
  inset: 0;
  margin: 0;
  width: 100vw;
  height: 100dvh;
  max-width: none;
  max-height: none;
  min-width: 0;
  min-height: 0;
  padding: 0;
  border-radius: 0;
  box-shadow: none;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}
#console-modal::backdrop { background: transparent; }
/* Single toolbar row: title left, status/connect/flash/close right. No
   mode switcher — console.js auto-detects Pi vs ESP32 from the connected
   port, so there's nothing to pick before connecting. */
.console-toolbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  /* Same height + horizontal padding as .topbar — this row stands in for
     the app header while the console is open, so it must land in the same
     slot, not a near-miss of it. */
  height: var(--topbar-height);
  padding: 0 20px;
  border-bottom: 1px solid var(--border-subtle);
  flex: none;
}
.console-toolbar-left, .console-toolbar-right { display: flex; align-items: center; gap: 10px; }
/* Connect is the only always-visible action and takes the bare-button accent
   fill; the port-pick escape hatch and destructive Flash firmware moved
   behind ⋯ (Apple HIG: one primary action per surface, rare + risky detours
   in the overflow). The divider groups Connect away from ⋯/× so the accent
   button doesn't read as part of the icon cluster. */
.toolbar-divider {
  width: 1px;
  align-self: stretch;
  margin: 4px 2px;
  background: var(--border-default);
}

/* Status pill, grouped left with the title it describes rather than into the
   action cluster (the robot card's split: identity + state left, actions
   right). Beside Connect, proximity would claim it labels that button.
   The dot alone can't carry state — color is the one channel that fails
   silently for a colorblind operator and reads as nothing at all to a
   screen reader, so the word rides with it (console.js's STATUS_LABEL). */
.status-pill {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  min-width: 0;
  max-width: 28ch;
  padding: 3px 10px;
  border-radius: 999px;
  background: var(--border-subtle);
  font-size: 12px;
  color: var(--ink-muted);
}
.status-pill .dot { width: 6px; height: 6px; flex: none; }
.status-pill > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* Placeholder for the terminal well before a session exists. Sits in the
   term's own dark ground so connecting swaps content without the panel
   shifting color underneath. */
.console-empty {
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 8px;
  background: #000;  /* match .recovery-term / xterm's default theme */
  text-align: center;
  padding: 24px;
}
.console-empty[hidden] { display: none; }
.console-empty-icon { font-size: 22px; color: var(--ink-muted); opacity: 0.6; }
.console-empty-title { font-size: 13px; color: var(--ink-muted); }
.console-empty-sub { font-size: 12px; color: var(--ink-muted); opacity: 0.7; }

.console-section {
  display: flex;
  flex-direction: column;
  flex: 1;
  min-height: 0;
}
.console-section[hidden] { display: none; }

/* 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;
}
@media (prefers-reduced-motion: reduce) {
  .esp-flash-pick:not([hidden]),
  .esp-flash-progress:not([hidden]) {
    animation: none;
  }
}
.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-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;
}
.recovery-term {
  background: #000;  /* match xterm's own default theme background */
  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 mounted phone's <video> so it can be
   positioned without touching the broader card layout. */
.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(--border-default);
}
.progress-log .err { color: var(--danger); }
.progress-log .ok  { color: var(--success); }

.unsupported {
  padding: 16px;
  border-radius: var(--radius);
  background: color-mix(in srgb, var(--warn) 12%, transparent);
  color: var(--warn);
  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)); }

/* 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; }

/* Reset the dashboard's global `button` rules for the buttons WE inject
   into Pip. The global `button { min-height: 44px; ... transform:
   scale(0.98) on :active; filter: brightness on hover }` is right for
   dashboard buttons and wrong for the tiny inline buttons we add inside
   Pip. Pip-core's own buttons (send, stop, slash-key) explicitly reset
   min-height in their own rules, so we only target ours. */
/* COCO class disclosure inside the Reflex card body. Default collapsed
   so the section stays compact; expand reveals all 80 classes as a
   wrapped comma-separated list. summary is keyboard-focusable. */
.watcher-coco {
  margin-top: 6px;
  font-size: 11px;
  color: var(--ink-muted);
}
.watcher-coco > summary {
  cursor: pointer;
  user-select: none;
  padding: 2px 0;
}
.watcher-coco > summary:hover { color: var(--ink); }
.watcher-coco-list {
  margin-top: 4px;
  padding: 6px 8px;
  border-radius: 6px;
  background: var(--surface);
  line-height: 1.5;
  font-family: "SF Mono", ui-monospace, Menlo, monospace;
  font-size: 10.5px;
  word-spacing: 1px;
}

/* Inline reflex-fire notice — rendered when watcher.js fires its
   onWatcherFire event during an active Pip turn. Dim, single-line,
   distinct from tool pills. Fire-once-and-disable means it lands at most
   once per arm cycle so the visual weight stays low. Now .alert-chip
   warn (halt, amber) / .alert-chip success (clear, green) — this
   component was the origin of the shared color-mix tint pattern. */
/* Mic-input notice — same shape as reflex-notice but cyan-tinged to
   distinguish "input feedback" (operator-facing diagnostic) from
   "perception event" (sensor-side notice). Surfaces no-speech, mic
   permission, and other voice-input states the operator otherwise
   wouldn't see. */
.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 */
.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(--border-default);
  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(--border-default);
}
.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; }

.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(--border-default);
}
.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(--focus); 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(--focus); 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); } }
@media (prefers-reduced-motion: reduce) { .pair-meta .hint.is-waiting::before { animation: none; } }

/* 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);
}

/* ── 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;
  background: transparent;
  border: none;
  /* Tap target is at least 44pt vertical (HIG floor). Negative margin
     compensates so the header's visual baseline doesn't move — the
     hit-rect just grows around the unchanged text. */
  padding: 10px 8px;
  margin: -10px -8px;
  min-height: 44px;
  border-radius: 6px;
  font: inherit;
  color: inherit;
  cursor: pointer;
}
.phone-brand-btn:hover { background: var(--border-default); }
.phone-brand-caret { width: 14px; height: 14px; color: var(--ink-muted); margin-left: 6px; }
.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: var(--danger-text);
}
.phone-nearby-empty-hint {
  margin: 12px 2px;
  line-height: 1.4;
}
/* 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(--link);  /* not --accent — royal on the tint is ~2:1 */
  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: var(--danger-text);
}
.presence-badge.alert::before { background: #c0392b; }
.presence-badge::before {
  content: "";
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--link);
  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; }
}

/* 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;
}
/* Segmented control sits flush-left at the section's start; matches the
   drive-mode toggle idiom but doesn't share its right-anchor (there's no
   sibling label to push against here). */
.phone-share-mode { align-self: flex-start; }
/* 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(--focus);
  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);
}

/* Phone mounted on a robot — chrome is dead weight on the face. Stop
   button included: operator's Stop lives on desktop / another phone,
   and the audience is closer to the robot than to a tiny screen
   overlay. body.phone-mounted carries the shared "fullscreen, black,
   chrome hidden" rules; per-mode classes add only the :not(...)
   exclusion for the surface that survives. */
body.phone-mounted { background: #000; }
body.phone-mounted .phone-main {
  padding: 0;
  min-height: 100dvh;
}
body.phone-attached .phone-main > *:not(#phone-cam-section):not(#phone-ask-dialog) {
  display: none !important;
}
body.phone-attached #phone-cam-section {
  position: fixed;
  inset: 0;
  margin: 0;
  background: #000;
  z-index: 1;
}
body.phone-attached #phone-cam-section .phone-cam-tap {
  width: 100vw;
  height: 100dvh;
  margin: 0;
  padding: 0;
  border: none;
  border-radius: 0;
  background: #000;
}
body.phone-attached #phone-cam {
  width: 100vw;
  height: 100dvh;
  object-fit: cover;
  border-radius: 0;
}
body.phone-attached #phone-cam-label,
body.phone-attached #phone-cam-picker,
body.phone-attached #phone-cam-overlay { display: none; }

/* Waiting overlay — visible in operator-cam mode while no incoming
   stream is present. Sits over the empty video element so the phone
   screen never reads as a black void. Hidden the instant a track
   lands; resurrected if every track ends. */
.phone-cam-waiting { display: none; }
body.phone-attached #phone-cam-waiting:not([hidden]) {
  display: flex;
  position: fixed;
  inset: 0;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  z-index: 2;
  padding: 0 24px;
  text-align: center;
  pointer-events: none;
}
.phone-cam-waiting-label {
  color: rgba(216, 226, 232, 0.95);
  font: 600 22px/1.25 system-ui, -apple-system, sans-serif;
  letter-spacing: -0.01em;
  text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
}
.phone-cam-waiting-label span { color: rgba(255, 255, 255, 1); }
.phone-cam-waiting-sub {
  margin-top: 8px;
  color: rgba(216, 226, 232, 0.6);
  font: 400 14px/1.35 system-ui, -apple-system, sans-serif;
}

/* 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; }
}
