:root {
  --bg: #fbf7fc;
  --card: #ffffff;
  --ink: #2b2333;
  --muted: #7a6f85;
  --accent1: #f2a8cd;
  --accent2: #b9a7ef;
  --ok: #2e9e5b;
  --bad: #c94f4f;
  --warnbg: #fdf3d7;
  /* Chat brains (v1.23): uncensored keeps the app's purple, censored gets its own teal, so
     which box answered is readable at a glance and not just in the meta line. */
  --accent3: #58b6bd;
  --tint-u: #f5f1ff;
  --tint-c: #edf8f9;
}
@media (prefers-color-scheme: dark) {
  :root {
    --bg: #1c1722;
    --card: #272031;
    --ink: #f0e9f5;
    --muted: #a99cb8;
    --warnbg: #3a3220;
    --tint-u: #2a2338;
    --tint-c: #1d2e30;
  }
}
* { box-sizing: border-box; }
[hidden] { display: none !important; } /* author display rules must not defeat the hidden attr */
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--ink);
  font: 17px/1.45 -apple-system, "SF Pro Text", system-ui, sans-serif;
  -webkit-text-size-adjust: 100%; }
/* Sticky (v1.17.2): content scrolls UNDER the bar instead of pushing it away, which is
   what every chat app does and what makes the composer reachable without hunting. Opaque
   background is load-bearing — messages pass behind it. */
header { padding: max(env(safe-area-inset-top), 8px) 16px 0;
  position: sticky; top: 0; z-index: 10; background: var(--bg); }
.topbar { display: flex; align-items: center; justify-content: space-between; }
h1 { margin: 4px 0 8px; font-size: 1.5rem;
  background: linear-gradient(90deg, var(--accent1), var(--accent2));
  -webkit-background-clip: text; background-clip: text; color: transparent; }
.menubtn { border: 1px solid var(--accent2); background: var(--card); color: var(--ink);
  border-radius: 10px; font-size: 1.3rem; line-height: 1; padding: 8px 12px; }
.menu { display: flex; flex-direction: column; gap: 2px; background: var(--card);
  border: 1px solid var(--accent2); border-radius: 12px; padding: 6px;
  margin-bottom: 10px; }
.menuitem { text-align: left; padding: 12px; font-size: 1.05rem; border: none;
  background: transparent; color: var(--ink); border-radius: 8px; }
.menuitem.active { background: var(--bg); font-weight: 600; }
/* The Voice lab entry is an <a>, not a <button> — match the menu items around it. */
a.menuitem { display: block; text-decoration: none; }
/* The menu button lives in this row now that the <h1> is gone — one row, not two. */
.chips { display: flex; gap: 8px; margin-bottom: 8px; flex-wrap: wrap; align-items: center; }
.chips .menubtn { margin-left: auto; padding: 4px 10px; font-size: 1.1rem; }
.chip { font-size: 0.8rem; padding: 3px 10px; border-radius: 999px;
  background: var(--card); border: 1px solid var(--accent2); color: var(--ink); font-family: inherit; }
button.chip { cursor: pointer; }
.chip.ok { border-color: var(--ok); }
.chip.warn { border-color: #d9a92e; background: var(--warnbg); }
nav { display: flex; gap: 8px; }
.navbtn { flex: 1; padding: 8px; border: none; border-radius: 10px 10px 0 0;
  background: transparent; color: var(--muted); font-size: 1rem; }
.navbtn.active { background: var(--card); color: var(--ink); font-weight: 600; }
main { background: var(--card); min-height: 70vh; border-radius: 0 0 14px 14px;
  padding: 18px 16px calc(env(safe-area-inset-bottom) + 24px); }
section { display: flex; flex-direction: column; gap: 14px; }
/* #key-setup (Settings) wraps several fields that collapse together once a key is saved —
   same flex/gap rhythm as section's own direct-child spacing, so wrapping it doesn't change
   how the group looks when it IS shown. */
#key-setup { display: flex; flex-direction: column; gap: 14px; }
.field span { display: block; font-size: 0.85rem; color: var(--muted); margin-bottom: 4px; }
select, input[type="password"], input[type="text"], textarea { width: 100%; padding: 12px;
  font-size: 1rem; border-radius: 10px; border: 1px solid var(--accent2);
  background: var(--bg); color: var(--ink); font-family: inherit; resize: vertical; }
.bigbtn { display: block; text-align: center; padding: 22px; font-size: 1.25rem;
  font-weight: 700; border: none; border-radius: 14px; color: #fff; cursor: pointer;
  background: linear-gradient(135deg, var(--accent1), var(--accent2));
  box-shadow: 0 3px 10px rgba(185, 167, 239, 0.4); }
.bigbtn.primary { background: linear-gradient(135deg, #7fc98f, #4da3c7); }
.bigbtn.checkin { padding: 14px; font-size: 1.05rem;
  background: linear-gradient(135deg, #4da3c7, var(--accent2)); }
.bigbtn[disabled], .btn[disabled] { opacity: 0.45; pointer-events: none; }
.row { display: flex; gap: 10px; }
.btn { flex: 1; padding: 13px; font-size: 1rem; border: 1px solid var(--accent2);
  border-radius: 12px; background: var(--bg); color: var(--ink); }
.btn.subtle { border-color: var(--muted); color: var(--muted); }
.btn.stop, .bigbtn.stop { border-color: var(--bad); color: #fff; font-weight: 700;
  background: #d76a6a; /* solid light red — the faint 15% tint washed out on-phone */ }
.note { font-size: 0.85rem; color: var(--muted); margin: 0; min-height: 1.2em; }
#preview { width: 100%; border-radius: 12px; }
.verdict { padding: 12px; border-radius: 10px; font-weight: 600; text-align: center; }
.verdict.ok { background: rgba(46, 158, 91, 0.15); color: var(--ok); }
.verdict.bad { background: rgba(201, 79, 79, 0.15); color: var(--bad); }
details { font-size: 0.8rem; color: var(--muted); }
pre { white-space: pre-wrap; word-break: break-all; background: var(--bg);
  padding: 10px; border-radius: 8px; }
.install-banner { padding: 12px 14px; border-radius: 12px; background: var(--bg);
  border: 1px solid var(--accent2); display: flex; flex-direction: column; gap: 10px;
  margin-bottom: 4px; }
.install-banner .note { min-height: 0; }
.meta { display: grid; grid-template-columns: auto 1fr; gap: 4px 14px; font-size: 0.9rem; }
.meta dt { color: var(--muted); }
.meta dd { margin: 0; font-family: ui-monospace, monospace; }
.modal { position: fixed; inset: 0; background: rgba(0,0,0,0.55); display: flex;
  align-items: center; justify-content: center; padding: 20px; z-index: 10; }
.modal-card { background: var(--card); border-radius: 16px; padding: 18px;
  width: min(92vw, 380px); display: flex; flex-direction: column; gap: 12px; }
.modal-card h2 { margin: 0; font-size: 1.1rem; }
#gps-radar { width: 100%; aspect-ratio: 1; }
#gps-radar .ring { fill: none; stroke: var(--muted); stroke-width: 1; opacity: 0.5; }
#gps-radar .cross { stroke: var(--muted); stroke-width: 1; opacity: 0.3; }
#gps-radar .acc { fill: var(--accent2); fill-opacity: 0.25; stroke: var(--accent2);
  stroke-width: 2; }
#gps-radar .center { fill: var(--accent1); stroke: #fff; stroke-width: 2; }
#gps-radar .lbl { fill: var(--muted); font-size: 11px; font-family: ui-monospace, monospace; }

/* --- Home request cards --- */
.hgroup { font-size: 0.85rem; font-weight: 700; color: var(--muted);
  text-transform: uppercase; letter-spacing: 1px; margin-top: 4px; }
.reqcard { border: 1px solid var(--accent2); border-radius: 12px; padding: 12px;
  display: flex; flex-direction: column; gap: 8px; background: var(--bg); }
.reqcard.bad { border-color: var(--bad); }
.reqtitle { font-weight: 600; }
/* Gear needed for a stim pose (v1.15) — deliberately louder than the due line: this is the
   one thing on the card he has to act on BEFORE tapping in, and the session will refuse to
   start without it. */
.note.gear { color: var(--bad); font-weight: 600; }
#home-requests { display: flex; flex-direction: column; gap: 12px; }

/* --- Pose session --- */
.posecam { width: auto; max-width: 100%; height: auto; max-height: 46vh; display: block;
  margin: 0 auto; border-radius: 12px; background: #000;
  min-height: 160px; /* placeholder height before the stream is up */
  transform: scaleX(-1); /* mirror: front camera preview reads naturally */ }
  /* No fixed aspect-ratio: iPhone portrait streams are 480x640 — the canvas bitmap
     drives the box shape, else the overlay math and the picture disagree. max-height
     caps the portrait image so camera + rows + button fit one phone screen. */
#view-session { gap: 7px; } /* half the default section gap — tight phone fit */
/* Practice: nothing but camera, the three rows, Stop (task/phase/status hidden; the
   error path removes the class so failures stay visible). */
#view-session.practice #session-task,
#view-session.practice #session-phase,
#view-session.practice #session-status { display: none; }
.countdown { font-family: ui-monospace, monospace; font-weight: 800; line-height: 1;
  font-size: clamp(64px, 24vw, 130px); text-align: center; letter-spacing: 2px; }
.phase { text-align: center; font-weight: 700; font-size: 1.05rem; color: var(--muted);
  text-transform: uppercase; letter-spacing: 1px; min-height: 1.3em; }
/* Rows sized to read from ACROSS THE ROOM, and deliberately ONE CENTRED label each
   (Submissive 2026-08-01). A live row carries the COMMANDED position and says whether he is
   holding it in COLOUR alone (green = match, red = not); practice carries the DETECTED one.
   Two things changed together and both matter:
   - The part-name column is gone. Every vocabulary string already names its part ("Head Up",
     "Hands on Crotch", "Legs Together"), so the column restated it on every row.
   - Commanded and detected are never both rendered. That pair is what wrapped on a phone,
     and a row that wraps is exactly the row you can't read from across the room. */
.poserow { display: flex; align-items: center; justify-content: center; gap: 8px;
  padding: 14px 16px; border: 1px solid var(--accent2); border-radius: 10px; }
/* Tight rows ONLY on the setup screen (Submissive 2026-08-01). Before Go he is standing at
   the phone framing the shot, so every pixel these rows don't take goes to .posecam (which
   flex-grows) and the picture is the only thing that matters. From Go onward he is across
   the room and the rows ARE the readout — they go back to full height. pose.js adds
   .setup in enter() and drops it in go(). Horizontal padding never changes: it keeps text
   off the border and costs no height either way. */
#view-session.setup .poserow { padding: 2px 16px; }
/* .grace shares the label's size and line-height so the row does NOT change height the
   moment a grace countdown appears mid-hold — it only changes colour. */
.poserow .target, .poserow .detected, .poserow .grace { font-size: 1.6rem; font-weight: 800;
  line-height: 1.2; text-align: center; }
.poserow .detected { color: var(--muted); }
.poserow .grace { color: var(--bad); font-family: ui-monospace, monospace; }
/* Exactly one of .target/.detected is ever filled — the other must not still contribute a
   flex gap and push the live label off centre. */
.poserow span:empty { display: none; }
/* Immersive camera screen: the header (title, GPS/key chips, menu) hides to give the
   session view the whole display; pose.js toggles body.immersive. The view fills the
   exact viewport height and the CAMERA flex-grows into whatever the rows + button
   don't use — no dead space, no scrolling. */
body.immersive header { display: none; }
body.immersive main { border-radius: 0; box-sizing: border-box;
  height: 100vh; height: 100dvh;
  padding: max(env(safe-area-inset-top), 8px) 12px max(env(safe-area-inset-bottom), 8px);
  display: flex; flex-direction: column; }
body.immersive #view-session { flex: 1 1 auto; min-height: 0; }
body.immersive .posecam { flex: 1 1 auto; min-height: 120px; width: 100%; max-width: none;
  max-height: none; object-fit: contain; /* fill the leftover space, keep true aspect */ }
#rotate-blocker { display: none; }
@media (orientation: landscape) {
  body.immersive #rotate-blocker { display: flex; position: fixed; inset: 0; z-index: 30;
    background: var(--bg); color: var(--ink); align-items: center; justify-content: center;
    text-align: center; font-size: 1.4rem; font-weight: 700; line-height: 1.6; }
}
/* The row's whole verdict, on a live hold, is this colour pair — .target is coloured too now
   that it is the only label a live row renders. */
.poserow.match { border-color: var(--ok); background: rgba(46, 158, 91, 0.08); }
.poserow.match .target, .poserow.match .detected { color: var(--ok); }
.poserow.bad { border-color: var(--bad); background: rgba(201, 79, 79, 0.12); }
.poserow.bad .target, .poserow.bad .detected { color: var(--bad); }
/* Go + Cancel on one line (2026-08-01). .bigbtn is display:block, so inside .row's flex it
   would size to CONTENT — the same trap #toys-mypet and #toys-utimi each had to fix. Go gets
   an explicit 2:1 share so the primary action stays dominant and Cancel reads as the escape
   hatch it is, not an equal choice. Padding is cut hard for camera height but deliberately
   NOT to the 2px the rows use: this is the one control tapped with the phone in hand, before
   it goes on the prop, so it stays a real ~44px touch target. */
#session-actions .bigbtn { flex: 2; padding: 10px; }
#session-actions .btn { flex: 1; padding: 10px; }
#session-summary { display: flex; flex-direction: column; gap: 10px; }
/* The results read as plain text, not as a boxed code block (Submissive 2026-08-01): the
   generic `pre` rule's 10px padding + tinted background were framing the outcome lines and
   pushing the summary off the bottom of the phone. Zeroing the padding alone would leave a
   tint clamped to the glyphs, so the background goes with it. */
#summary-lines { font-size: 1rem; word-break: normal; padding: 0; background: none; }

/* --- Toys (TENS/EMS remote, v1.10) --- */
.toys-banner { border: 1px solid var(--bad); background: rgba(201, 79, 79, 0.12);
  color: var(--bad); padding: 12px; border-radius: 10px; font-weight: 600; }
.lvlrow { display: flex; align-items: center; gap: 10px; }
.lvlrow input[type="range"] { flex: 1; }
.lvlnum { font-weight: 800; font-size: 1.3rem; min-width: 2ch; text-align: center; }
.modegrid { display: grid; grid-template-columns: repeat(6, 1fr); gap: 6px; }
.modebtn { padding: 12px 0; border: 1px solid var(--accent2); border-radius: 10px;
  background: var(--bg); color: var(--ink); font-weight: 600; font-size: 1rem; }
.modebtn.active { background: linear-gradient(135deg, var(--accent1), var(--accent2));
  color: #fff; border-color: transparent; }
/* Start past half scale: same "check the number first" orange as TensView.swift. */
.bigbtn.startwarn { background: linear-gradient(135deg, #e8a54b, #d97b3f); }
#toys-log { max-height: 200px; overflow-y: auto; }

/* v1.32 Link-executor panels. The Jeusn plug's pattern CHANNEL switches: they send nothing on
   their own, they decide what the next pattern tap means — so they read as settings, not as
   actions, and they are plain checkboxes rather than buttons for exactly that reason. */
.toys-switches { flex-wrap: wrap; align-items: center; }
.switch { display: inline-flex; align-items: center; gap: 6px; color: var(--ink);
  font-weight: 600; }
.switch input { width: 20px; height: 20px; accent-color: var(--accent2); }
/* A per-channel Stop sits INSIDE the level row, beside the slider it stops, so which channel
   it ends is never in question. Kept narrow so it cannot crowd the slider on a phone. */
.lvlrow .btn { flex: 0 0 auto; padding: 8px 12px; font-size: .9rem; }
/* Twenty presets and ten patterns/modes: five across reads better on a phone than the TENS
   grid's six, and keeps the machine's twenty to four even rows. */
#toys-je-patterns, #toys-jc-modes { grid-template-columns: repeat(5, 1fr); }
#toys-fm-presets { grid-template-columns: repeat(5, 1fr); }
/* A greyed speed slider means the estimate is unknown or a preset is driving — it must LOOK
   inert, not just refuse the drag. */
#toys-fm-speed:disabled { opacity: .45; }

/* --- MyPet collar surface (v1.13) --- Its momentary action buttons fill the row (Beep +
   Vibrate share one; Shock, the danger action, gets its whole row full-width). Scoped to
   #toys-mypet so it never changes the TENS transport's content-sized start/stop. */
#toys-mypet .row .bigbtn { flex: 1; }
/* Vertical gap before the Shock row so it doesn't butt against Beep/Vibrate — matches the
   10px horizontal gap between the upper two buttons. */
#toys-mypet .row + .row { margin-top: 10px; }

/* --- Device rail (v1.12 multi-device) --- */
.devrail { display: flex; gap: 8px; flex-wrap: wrap; align-items: center;
  margin-bottom: 12px; }
.devchip { font-size: 0.85rem; padding: 6px 12px; border-radius: 999px;
  border: 1px solid var(--accent2); background: var(--bg); color: var(--ink);
  cursor: pointer; display: inline-flex; align-items: center; gap: 7px; }
.devchip.active { background: linear-gradient(135deg, var(--accent1), var(--accent2));
  color: #fff; border-color: transparent; }
.devchip .dot { width: 8px; height: 8px; border-radius: 50%; background: #888; }
.devchip .dot.on { background: var(--ok); }
.devchip.add { border-style: dashed; font-weight: 700; }
.addpanel { border: 1px solid var(--accent2); border-radius: 12px; padding: 12px;
  margin-bottom: 12px; }
.addpanel .found { display: block; width: 100%; text-align: left; margin: 6px 0;
  padding: 10px 12px; border: 1px solid var(--accent2); border-radius: 10px;
  background: var(--bg); color: var(--ink); cursor: pointer; }
.addpanel .found small { color: var(--muted, #888); font-family: monospace; }

/* --- Utimi surface (2026-07-30) --- Two motor cards stacked, each with its own pattern grid,
   level slider and stop. `.modegrid` is reused for the 8 pattern buttons: they are PATTERNS,
   not a 1-8 intensity scale, so the grid (all equal, one highlighted) is the honest rendering —
   don't restyle it as a ramp. Scoped to #toys-utimi so nothing here leaks into the other two
   surfaces. First pass written on the Mac; refine on the Windows box. */
/* The card is a flex column for the same reason #key-setup is: the wrapper divs inside a
   surface are plain blocks, and .note's margin is 0, so header/grid/slider/stop otherwise
   butt together with no gap at all. Same rhythm as section's own direct-child spacing. */
#toys-utimi .utimi-motor { display: flex; flex-direction: column; gap: 10px;
  border: 1px solid var(--accent2); border-radius: 10px;
  padding: 10px; margin-bottom: 10px; }
/* .bigbtn is display:block, so inside .row's flex it sizes to CONTENT — which rendered
   ALL STOP (alone in its row) as a small button, the one control that must never be hard
   to hit. Same fix #toys-mypet needed for beep/vibrate/shock; scoped so the TENS
   transport's deliberately content-sized start/stop is untouched. */
#toys-utimi .row .bigbtn { flex: 1; }
/* The pump block is three consecutive .rows (deflate + stop-pump, the inflate presses,
   ALL STOP). Without this they stack with zero vertical gap — same rule, same reason as
   #toys-mypet's. */
#toys-utimi .row + .row { margin-top: 10px; }
/* Pattern grid: 4-up by default, 8-up only where there is room. At 8-up on a 375px phone
   each button computes to ~37x42px (343 content - 22 card - 28 gaps / 8), under the 44pt
   minimum touch target on BOTH axes, with only 4px between neighbours — and mis-tapping a
   pattern here changes what the motors are doing. 4-up gives ~76px and standard .modebtn
   sizing. Still all-equal, still not a ramp. */
#toys-utimi .utimi-pat { grid-template-columns: repeat(4, 1fr); gap: 6px; }
@media (min-width: 480px) {
  #toys-utimi .utimi-pat { grid-template-columns: repeat(8, 1fr); }
}

/* --- Chat (v1.17) ------------------------------------------------------------------
   #view-chat is a column: the log grows and scrolls internally, the composer stays
   pinned so the iOS keyboard never pushes it off screen. Sections are already
   flex/column/gap:14px (see the base `section` rule), so only the log needs to flex. */
/* --- Chat layout: fixed chrome, only the log scrolls (v1.17.2) ----------------------
   body.chatmode turns the page into a viewport-height flex column — header, log,
   composer — so the composer is pinned to the bottom and the PAGE never scrolls. That
   last part matters on iOS: without it, focusing the textarea scrolls the whole document
   and the composer disappears behind the keyboard.
   --vvh is set from visualViewport by chat.js. The keyboard does NOT shrink the layout
   viewport in an installed iOS PWA, so 100dvh alone leaves the composer underneath it;
   visualViewport.height is the only thing that reports the real usable box. */
body.chatmode { height: var(--vvh, 100dvh); overflow: hidden;
  display: flex; flex-direction: column; }
body.chatmode header { flex: 0 0 auto; }
body.chatmode main { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column;
  border-radius: 0; padding-bottom: 0; }
body.chatmode #view-chat { flex: 1 1 auto; min-height: 0; }
body.chatmode .chatwrap { flex: 1 1 auto; min-height: 0; }
body.chatmode .chatlog { min-height: 0; height: 100%; }
body.chatmode .chatcompose { flex: 0 0 auto;
  padding-bottom: max(env(safe-area-inset-bottom), 8px); }
body.chatmode #chat-status:empty { display: none; }   /* no dead row above the composer */

/* The title moved here from the header (v1.17.2); section is a flex column with gap,
   so its own margins would double the spacing. */
#view-home h1 { margin: 0; }

#view-chat { min-height: 0; }
/* The log needs a positioned parent for the jump button to anchor to. */
.chatwrap { position: relative; display: flex; flex: 1 1 auto; min-height: 0; }
.chatjump {
  position: absolute; right: 12px; bottom: 12px; z-index: 2;
  min-width: 40px; height: 40px; padding: 0 12px; border-radius: 20px;
  background: var(--card); color: var(--ink); border: 1px solid var(--accent2);
  box-shadow: 0 2px 8px rgba(0,0,0,.18); font: inherit; font-size: 15px;
}
.chatjump.hasnew { background: var(--accent1); color: #2b2333; border-color: var(--accent1);
  font-weight: 600; }
.chatlog {
  flex: 1 1 auto; min-height: 40vh; overflow-y: auto; -webkit-overflow-scrolling: touch;
  display: flex; flex-direction: column; gap: 8px;
  background: var(--card); border: 1px solid var(--accent2); border-radius: 12px;
  padding: 10px;
}
.chatmsg {
  max-width: 82%; padding: 8px 11px; border-radius: 14px; line-height: 1.4;
  white-space: pre-wrap; overflow-wrap: anywhere;
  display: flow-root;              /* contains the floated .chatmeta */
}
.chatmsg.user { align-self: flex-end; background: var(--accent1); color: #2b2333;
  border-bottom-right-radius: 4px; }
.chatmsg.agent { align-self: flex-start; background: var(--bg); border: 1px solid var(--accent2);
  border-bottom-left-radius: 4px; }
/* Two brains answer in this thread and they are not interchangeable — one is the
   self-hosted uncensored box, one is Nous. Colour is the fastest way to see which you are
   reading; the meta line names it, this makes it obvious at a glance while scrolling. */
.chatmsg.agent.brain-u { background: var(--tint-u); border-color: var(--accent2); }
.chatmsg.agent.brain-c { background: var(--tint-c); border-color: var(--accent3); }
/* Day divider + service lines (v1.23) — Telegram's two non-bubble rows. */
.chatday { align-self: center; font-size: 0.72rem; letter-spacing: 0.5px; font-weight: 600;
  color: var(--muted); padding: 2px 12px; border-radius: 10px; background: var(--bg);
  border: 1px solid var(--accent2); margin: 6px 0 2px; }
/* 🔧 [background] Started/Done: machinery, not conversation. Quiet, centred, no bubble —
   it stops competing with the thread without being hidden or lost from the archive. */
.chatsys { align-self: center; max-width: 90%; text-align: center; font-size: 0.75rem;
  color: var(--muted); opacity: 0.85; padding: 1px 4px; }
.chatsys::before { content: "▸ "; }
/* The meta line: brain + time, bottom-right inside the bubble (v1.23). Floated so a short
   message keeps it on the same line, which is what makes it read as a stamp and not a
   second line of content. .chatmsg is display:flow-root so the float stays contained. */
.chatmeta { float: right; margin: 5px 0 0 12px; font-size: 0.66rem; opacity: 0.6;
  white-space: nowrap; }
/* Photos in the log (v1.21). The bubble keeps its pre-wrap text; the grid sits under it.
   A photo with no caption gets a bubble that is just the frame — padding around a picture
   reads as a mistake, not a message. */
.chatpics { display: grid; gap: 4px; margin-top: 6px; }
.chatpics.multi { grid-template-columns: 1fr 1fr; }
.chatmsg.photoonly { padding: 4px; }
.chatmsg.photoonly .chatpics { margin-top: 0; }
.chatpic {
  display: block; width: 100%; max-height: 55vh; object-fit: cover;
  border-radius: 10px; background: var(--bg); min-height: 90px; cursor: pointer;
}
.chatpic-gone { padding: 10px; border-radius: 10px; border: 1px dashed var(--muted);
  color: var(--muted); font-size: 0.85rem; text-align: center; }
/* Document chips (v1.25) — the contract, the weekly report. A real button: it is a download,
   and on a phone it has to be a comfortable tap target, not a link in a sentence. */
.chatfiles { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
.chatfile { display: flex; align-items: center; gap: 8px; width: 100%; min-height: 44px;
  padding: 8px 10px; border-radius: 10px; text-align: left; font: inherit;
  font-size: 0.9rem; color: var(--ink); background: var(--card);
  border: 1px solid var(--accent2); }
.chatfile-ico { flex: 0 0 auto; font-size: 1.15rem; }
.chatfile-name { overflow-wrap: anywhere; }
.chatfile.busy { opacity: .6; }
/* Full-screen viewer. z-index above the sticky header (10) and the rotate blocker (30). */
.lightbox { position: fixed; inset: 0; z-index: 40; background: rgba(0,0,0,0.92);
  display: flex; align-items: center; justify-content: center; padding: 8px; }
.lightbox img { max-width: 100%; max-height: 100%; object-fit: contain; }
.lightbox img.loading { opacity: .55; }
body.lightboxed { overflow: hidden; }
/* Markdown in agent bubbles (v1.26, md.js). The container resets the bubble's pre-wrap —
   mdHtml() emits its own block structure, and literal newlines would double-space it. */
.chatmd { white-space: normal; }
.chatmd p { margin: 0 0 6px; }
.chatmd > :last-child, .chatmd li > :last-child { margin-bottom: 0; }
.chatmd h1, .chatmd h2, .chatmd h3, .chatmd h4, .chatmd h5, .chatmd h6 {
  margin: 8px 0 4px; line-height: 1.25; }
.chatmd h1 { font-size: 1.15rem; }
.chatmd h2 { font-size: 1.08rem; }
.chatmd h3 { font-size: 1rem; }
.chatmd h4, .chatmd h5, .chatmd h6 { font-size: 0.95rem; }
.chatmd ul, .chatmd ol { margin: 0 0 6px; padding-left: 20px; }
.chatmd li { margin: 2px 0; }
.chatmd blockquote { margin: 6px 0; padding: 2px 10px;
  border-left: 3px solid var(--accent2); color: var(--muted); }
.chatmd code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 0.85em; background: var(--card); border: 1px solid var(--accent2);
  border-radius: 4px; padding: 0 4px; }
.chatmd pre { margin: 6px 0; padding: 8px; background: var(--card);
  border: 1px solid var(--accent2); border-radius: 8px; overflow-x: auto;
  -webkit-overflow-scrolling: touch; }
.chatmd pre code { border: 0; background: none; padding: 0; }
.chatmd hr { border: 0; border-top: 1px solid var(--accent2); margin: 8px 0; }
.chatmd a { color: inherit; text-decoration: underline; }
/* Tables — the ask that started v1.26. A bubble is 82% of a phone screen, so the wrap
   scrolls sideways rather than crushing the columns into one-word-wide strips. */
.mdtablewrap { overflow-x: auto; margin: 6px 0; max-width: 100%;
  -webkit-overflow-scrolling: touch; }
.chatmd table { border-collapse: collapse; font-size: 0.88em; }
/* break-word, NOT the bubble's inherited `anywhere`: `anywhere` gives every column a
   one-CHARACTER min-content width, so the auto table layout crushed the first column to
   a letter per line (seen on-phone 2026-08-06). break-word keeps whole-word minimums —
   a genuinely too-wide table overflows into this wrapper's sideways scroll instead. */
.chatmd th, .chatmd td { border: 1px solid var(--accent2); padding: 4px 8px;
  text-align: left; vertical-align: top; overflow-wrap: break-word; }
.chatmd th { background: var(--card); font-weight: 600; }
.chatmd .ta-c { text-align: center; }
.chatmd .ta-r { text-align: right; }
/* Small-phone chat (v1.26.1): the frame goes full-bleed and the bubbles tighten — a 390px
   screen was spending ~50px of width on main padding + log chrome, and wide tables paid
   for all of it. Scoped to the full chat view (body.chatmode) and to phones; desktop and
   every other view keep the framed card look (Submissive-specified 2026-08-06). Bubble
   alignment (agent left / user right) is align-self and untouched by any of this. */
@media (max-width: 500px) {
  body.chatmode main { padding-left: 0; padding-right: 0; padding-top: 8px; }
  body.chatmode #view-chat { gap: 8px; }
  body.chatmode .chatlog { border-left: 0; border-right: 0; border-radius: 0;
    padding: 8px 6px; }
  body.chatmode .chatmsg { max-width: 92%; padding: 6px 9px; }
  body.chatmode .chatmsg.photoonly { padding: 4px; }
  /* The log is the only full-bleed child — the composer and the status/dictation rows
     keep a small gutter so buttons don't touch the screen edge. */
  body.chatmode .chatcompose { padding-left: 6px; padding-right: 6px; }
  body.chatmode #chat-status, body.chatmode #chat-dictation-row {
    margin-left: 6px; margin-right: 6px; }
  body.chatmode .chatmd th, body.chatmode .chatmd td { padding: 3px 6px; }
  body.chatmode .chatjump { right: 8px; }
}
/* Pending is the elapsed-counter bubble — it must read as "not her words yet". */
.chatmsg.pending { opacity: .6; font-style: italic; }
.chatmsg.failed { opacity: .8; font-style: italic; border-color: var(--bad); color: var(--bad); }
/* Undelivered (v1.28) — HIS message, recovered from the server's inbound spool because the
   agent never received it. It keeps the user bubble's shape and colour on purpose: this is
   still a thing he said, not an error box. The dashed outline is what marks it as not having
   arrived, and the meta line says why. Deliberately not `.failed` styling: that one is a
   hollow bubble standing in for a reply that never came, this one has real content to read. */
.chatmsg.user.unsent { outline: 1px dashed var(--bad); outline-offset: 2px; opacity: .85; }
.chatmeta.undelivered { color: var(--bad); opacity: .95; font-weight: 600;
  white-space: normal; }
.chatcompose { display: flex; gap: 8px; align-items: flex-end; }
.chatcompose textarea {
  flex: 1 1 auto; min-height: 44px; max-height: 30vh; resize: vertical;
  font: inherit; padding: 10px; border-radius: 12px; color: var(--ink);
  background: var(--card); border: 1px solid var(--accent2);
}
/* 16px keeps iOS from zooming the viewport when the field takes focus. */
.chatcompose textarea { font-size: 16px; }
.chatcompose .btn { flex: 0 0 auto; min-height: 44px; }
/* Mic + dictation strip (v1.17.3). The meter is the fastest way to tell "not hearing you"
   apart from "thinking" — without it a silent failure looks identical to a slow model. */
.micbtn { flex: 0 0 auto; min-height: 44px; padding: 0 12px; font-size: 1.15rem;
  background: var(--card); border: 1px solid var(--accent2); color: var(--ink); }
.micbtn.on { background: var(--bad); border-color: var(--bad); color: #fff; }
/* Speaker uses the accent, not the red — red means "recording you", and the two buttons
   sit side by side. */
#chat-speak.on { background: var(--accent2); border-color: var(--accent2); color: #2b2333; }
.micbtn.loading { opacity: .6; }
#chat-dictation-row { display: flex; flex-direction: column; gap: 4px; }
#chat-meter { height: 4px; background: var(--bg); border-radius: 2px; overflow: hidden;
  border: 1px solid var(--accent2); }
#chat-meterbar { height: 100%; width: 0%; background: var(--ok);
  transition: width .06s linear; }
#chat-dictation { font-style: italic; }

/* --- Cameras (v1.31) ----------------------------------------------------- */
.camlive { background: #000; border-radius: 8px; overflow: hidden; margin-bottom: 10px; }
.camlive img { display: block; width: 100%; height: auto; }
.cambtns { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; }
.cambtns .bigbtn { flex: 1 1 30%; }
.cammedia-strip { display: grid; grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
                  gap: 8px; margin: 10px 0; }
/* `color` is load-bearing (v1.31.2): a <button> takes the UA's `buttontext` black unless it
   is told otherwise, so the caption rendered black-on-dark and was unreadable in dark mode. */
.cammedia { position: relative; padding: 0; border: 0; background: none; cursor: pointer;
            text-align: left; color: var(--ink); font: inherit; }
.cammedia img { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; border-radius: 6px;
                background: #222; display: block; }
/* Clips have no thumbnail to show — see the comment in cams.js refreshMedia(). */
.cammedia-vid { display: grid; place-items: center; width: 100%; aspect-ratio: 16 / 9;
                border-radius: 6px; background: #222; color: #fff; font-size: 22px; }
.cammedia-cap { display: block; font-size: 11px; color: var(--muted); margin-top: 2px;
                line-height: 1.25; }
.cammedia-gone { display: block; aspect-ratio: 16 / 9; border-radius: 6px; background: #222;
                 font-size: 11px; opacity: 0.6; }
.camrow { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 8px 0; }
.camrow input[type="text"] { flex: 1 1 8em; }
.camrow input[type="number"] { width: 5em; }
#cam-lightbox-body video, #cam-lightbox-body img { max-width: 100%; max-height: 80vh; }

/* --- Link sign-in + Connect toys (v1.33) --- The Settings row sits apart from the persona/voice
   block above it; the sheet is a .modal, scrolled inside its card when the share list is long.
   Share rows reuse the add panel's .found look. */
.linkrow { display: flex; flex-direction: column; gap: 8px; margin-top: 18px; padding-top: 14px;
  border-top: 1px solid var(--accent2); }
#toys-linksheet { z-index: 20; }
#toys-linksheet .modal-card { max-height: 85vh; overflow-y: auto; }
.linklist .found { display: block; width: 100%; text-align: left; margin: 6px 0;
  padding: 10px 12px; border: 1px solid var(--accent2); border-radius: 10px;
  background: var(--bg); color: var(--ink); cursor: pointer; font-family: inherit;
  font-size: 0.95rem; }
.linklist .found small { display: block; color: var(--muted); margin-top: 3px; }
.linklist .found[disabled] { opacity: 0.45; pointer-events: none; }
#toys-linkagent { margin-bottom: 12px; }

/* --- Link viewer role, share limits, refusals, onboarding (v1.33.1) --- A refusal toast is
   FIXED to the bottom of the viewport: #toys-status sits under the longest panels and a refused
   command nobody sees is the bug it exists for. Above the sticky header (10), below the sheets. */
#toys-viewer, #toys-sharenote { margin-bottom: 12px; }
.toys-toast { position: fixed; left: 12px; right: 12px; z-index: 15; cursor: pointer;
  bottom: calc(env(safe-area-inset-bottom) + 12px); background: var(--card);
  box-shadow: 0 4px 18px rgba(0, 0, 0, 0.35); }
.toys-toast::before { content: ""; position: absolute; inset: 0; border-radius: inherit;
  background: rgba(201, 79, 79, 0.12); pointer-events: none; }
#link-onboard { z-index: 25; }
.onboard-steps { margin: 0; padding-left: 1.3em; display: flex; flex-direction: column; gap: 6px; }
