/* Mirabel: global summon FAB + popover panel. Loaded on every page (tracked),
   but the markup renders only when a kitchen + API key are present, so these
   rules match nothing otherwise. CSP-safe: external sheet, no inline style.
   The art is a circular framed portrait — it fills the button; the wooden ring
   is the visible edge, so no background/border on the button itself.
   Colors are design tokens (base.css) so dark mode works by construction; the
   FAB and mobile panel clear the nav dock's safe-area + reserved height
   (navshell.css). z-index sits at --z-raised — above page content but BELOW the
   navshell (--z-nav), which is a stacking context: the brand menu lives inside
   it at --z-nav-overflow, so a Mirabel at or above --z-nav-overflow would trap
   the menu beneath the FAB/panel and steal its taps (#1942). The desktop FAB and
   panel never overlap the top nav strip, so sitting under it costs nothing. */
.mirabel {
  position: fixed; right: 1.25rem;
  bottom: calc(1.25rem + env(safe-area-inset-bottom, 0px));
  z-index: var(--z-raised);
}

.mirabel__fab {
  width: 64px; height: 64px; border-radius: 50%;
  background: none; border: none; padding: 0; cursor: pointer;
  box-shadow: 0 6px 20px rgba(0, 0, 0, 0.25);
  position: relative; display: block;
}
/* The square portrait clips itself to a circle (border-radius) rather than the
   button clipping it (overflow: hidden), so the button can host an unclipped
   glow layer behind it. z-index keeps the portrait above that glow. */
.mirabel__fab-img {
  width: 100%; height: 100%; display: block; border-radius: 50%;
  position: relative; z-index: 1; transform-origin: 50% 50%;
}

/* Hearth cue: one slow, organic CMY/RGB halo around the existing portrait. The
   pseudo-element stays on the current trigger buttons, so the production markup
   and two-trigger controller contract remain untouched. */
@property --mirabel-hearth-angle {
  syntax: "<angle>"; initial-value: 0deg; inherits: false;
}
.mirabel,
.pill--mirabel {
  --mirabel-hearth-1: #00e8ff;
  --mirabel-hearth-2: #0030ff;
  --mirabel-hearth-3: #ff00e8;
  --mirabel-hearth-4: #ff1800;
  --mirabel-hearth-5: #f0f000;
  --mirabel-hearth-6: #00f020;
}
.mirabel__fab::after,
.pill--mirabel::after {
  content: ""; position: absolute; inset: -4px; z-index: 0;
  border-radius: 58% 42% 38% 62% / 50% 55% 45% 50%;
  background: conic-gradient(from var(--mirabel-hearth-angle),
    var(--mirabel-hearth-1), var(--mirabel-hearth-2), var(--mirabel-hearth-3),
    var(--mirabel-hearth-4), var(--mirabel-hearth-5), var(--mirabel-hearth-6),
    var(--mirabel-hearth-1));
  filter: blur(3px) saturate(1.3); opacity: 0.55;
  mix-blend-mode: multiply;
  /* Decorative only: the halo extends past the button, so keep it out of
     hit-testing — clicks land on the trigger, not this ring. */
  pointer-events: none;
}

/* Unread badge (Task 19): a reply settled while the panel was closed.
   z-index 2 sits above both the glow layer (::after, z 0) and the portrait
   (.mirabel__fab-img, z 1) so the dot is never occluded. --ground border
   separates it from the portrait underneath on either color scheme. */
.mirabel__fab.is-unread::before,
.pill--mirabel.is-unread::before {
  content: ""; position: absolute; top: -2px; right: -2px; z-index: 2;
  width: 10px; height: 10px; border-radius: 50%;
  background: var(--red); border: 2px solid var(--ground);
  pointer-events: none;
}

@media (prefers-reduced-motion: no-preference) {
  .mirabel__fab-img { animation: mirabel-breathe 9s ease-in-out infinite; }
  .mirabel__fab::after,
  .pill--mirabel::after {
    animation:
      mirabel-hearth-spin 60s linear infinite,
      mirabel-hearth-morph 17s ease-in-out infinite,
      mirabel-hearth-pulse 11s ease-in-out infinite;
  }
  @keyframes mirabel-breathe {
    0%, 100% { transform: scale(1); }
    50%      { transform: scale(1.03); }
  }
  @keyframes mirabel-hearth-spin { to { --mirabel-hearth-angle: 360deg; } }
  @keyframes mirabel-hearth-pulse {
    0%, 100% { opacity: 0.48; transform: scale(1); }
    50%      { opacity: 0.84; transform: scale(1.045); }
  }
  @keyframes mirabel-hearth-morph {
    0%, 100% { border-radius: 58% 42% 38% 62% / 50% 55% 45% 50%; }
    20%      { border-radius: 44% 56% 62% 38% / 40% 64% 36% 60%; }
    40%      { border-radius: 68% 32% 48% 52% / 36% 66% 64% 34%; }
    60%      { border-radius: 38% 62% 72% 28% / 63% 32% 56% 44%; }
    80%      { border-radius: 72% 28% 40% 60% / 38% 56% 42% 68%; }
  }
}

@media (prefers-color-scheme: dark) {
  .mirabel__fab::after,
  .pill--mirabel::after { mix-blend-mode: screen; }
}

/* Mirabel's second trigger: a circular pill in the top-right nav slot, revealed
 * only when cramped (CSS gate in the cramped-layout section; she's the
 * bottom-right FAB on desktop). Deliberately NOT .pill: the portrait + wooden
 * frame ARE the chrome (no frosted capsule), and .pill's backdrop-filter would
 * make this a containing block. Sized to the strip's 48px icon-pill norm; rem so
 * it tracks text-zoom. */
.pill--mirabel {
  position: relative; width: 3rem; height: 3rem; flex: none;
  background: none; border: none; padding: 0; cursor: pointer;
  border-radius: 50%;
  box-shadow: 0 4px 14px rgba(0, 0, 0, 0.3);
}

.mirabel__panel {
  position: absolute; right: 0; bottom: 76px; width: 400px;
  max-height: min(80dvh, 700px); display: flex; flex-direction: column;
  background: var(--ground); border: 1px solid var(--rule-faint);
  border-radius: var(--radius-lg); box-shadow: var(--shadow-popover-lg); overflow: hidden;
}
.mirabel__panel::before {
  content: "";
  position: absolute;
  inset: 0;
  background-image: url("/assets/paper-noise-4ecbafa7.svg");
  background-size: var(--noise-tile) var(--noise-tile);
  opacity: var(--paper-opacity);
  pointer-events: none;
}
.mirabel__panel > * { position: relative; z-index: 1; }
.mirabel__panel[hidden] { display: none; }

/* Expanded: the "workspace" size (Task 8). Clearance is JS-measured
   (--nav-top-height, published by nav_shell_controller) — no static
   nav-height token exists, and a hand-picked constant would drift across the
   720px --gingham-gap breakpoint and under text-zoom. The 4rem fallback
   covers a no-JS first paint. 76px = the panel's bottom anchor inside the
   wrapper; 1.25rem + the safe-area inset = the wrapper's own bottom offset
   (the .mirabel rule above — omitting the inset would overshoot the cap by
   that much on a notched device in desktop layout, e.g. landscape or a
   standalone PWA); 1rem is breathing room below the nav. */
.mirabel__panel[data-expanded] {
  width: min(760px, calc(100vw - 3rem));
  max-height: calc(100dvh - var(--nav-top-height, 4rem) - 1rem - 76px - 1.25rem - env(safe-area-inset-bottom, 0px));
}

.mirabel__head { display: flex; align-items: center; gap: 10px; padding: 12px 14px 11px;
  border-bottom: 1px solid var(--rule-faint); }
.mirabel__head-img { width: 34px; height: 34px; border-radius: 50%; flex: none;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.18); }
.mirabel__name { font-weight: 600; line-height: 1; }
/* Clear lives in the header (#2408), centered in the slack between the name and
   the × via flex:1 — not adjacent to the ×, so the #1942 "reads as one control"
   concern that kept it out of the header is moot. The flex:1 also pins × right
   (no margin-left:auto needed). */
.mirabel__clear { flex: 1; text-align: center; font-size: 0.74rem;
  background: none; border: none; color: var(--text-soft); cursor: pointer;
  text-decoration: underline; text-decoration-color: var(--rule); text-underline-offset: 2px;
  font-family: var(--font-body); }
.mirabel__clear:hover { color: var(--text); }
/* Sits between Clear and ⤢ (Task 10): re-shows the disclaimer on demand — same
   icon-button chrome as ⤢/×. Clear's flex:1 above still fills the slack
   between the name and this trio, so the group reads as one right-aligned
   cluster. */
.mirabel__info { width: 30px; height: 30px; flex: none;
  border-radius: 50%; display: inline-flex; align-items: center; justify-content: center;
  background: none; border: none; font-size: 1rem; color: var(--text-light); cursor: pointer;
  transition: background var(--duration-fast), color var(--duration-fast); }
.mirabel__info:hover { background: var(--rule-faint); color: var(--text); }
/* Sits between Clear and ×; Clear's flex:1 above still fills the slack
   between the name and this trio, so the group reads as one right-aligned
   cluster and Clear stays centered within its own box either way. */
.mirabel__expand { width: 30px; height: 30px; flex: none;
  border-radius: 50%; display: inline-flex; align-items: center; justify-content: center;
  background: none; border: none; font-size: 1rem; color: var(--text-light); cursor: pointer;
  transition: background var(--duration-fast), color var(--duration-fast); }
.mirabel__expand:hover { background: var(--rule-faint); color: var(--text); }
.mirabel__expand[aria-pressed="true"] { color: var(--red); }
.mirabel__close { width: 30px; height: 30px; flex: none;
  border-radius: 50%; display: inline-flex; align-items: center; justify-content: center;
  background: none; border: none; font-size: 1.1rem; color: var(--text-light); cursor: pointer;
  transition: background var(--duration-fast), color var(--duration-fast); }
.mirabel__close:hover { background: var(--red-light); color: var(--red); }

/* The wrap exists only to give the jump pill (below) a position:relative
   anchor scoped to the transcript's own box, not the whole panel — the pill
   must stay a sibling of .mirabel__transcript, never a child of it, because
   resetHistory()/renderHistory() wipe the transcript's children wholesale
   (replaceChildren()) and would take the pill with them. flex:1/min-height:0
   moved here from .mirabel__transcript below; the transcript is the wrap's
   sole flex child so it fills the same box it always did. */
.mirabel__transcript-wrap { position: relative; display: flex; flex-direction: column; flex: 1; min-height: 0; }
.mirabel__transcript { padding: 14px; overflow-y: auto; display: flex; flex-direction: column;
  gap: 10px; background: var(--surface-alt); flex: 1; min-height: 0; }
/* The transcript now always opens with the disclaimer card (Task 10) — it's
   never truly :empty, so the old collapse-when-empty rule is retired. */

/* Smart autoscroll's "↓ jump to latest" pill (Task 5): floats over the
   transcript's own bottom edge (positioned against the wrap above, not the
   whole panel) so it reads as "more below", not as chrome belonging to the
   chips/input beneath it. Hidden by default; mirabel_controller toggles the
   attribute as autoFollow flips. */
.mirabel__jump {
  position: absolute; left: 50%; bottom: 10px; transform: translateX(-50%);
  width: 28px; height: 28px; border-radius: var(--radius-pill);
  display: flex; align-items: center; justify-content: center;
  background: var(--ground); border: 1px solid var(--rule); color: var(--text);
  box-shadow: var(--shadow-popover); cursor: pointer; font-size: 0.9rem; line-height: 1;
}
.mirabel__jump[hidden] { display: none; }
.mirabel__jump:hover { background: var(--surface-alt); }
.mirabel__msg { max-width: 84%; padding: 9px 12px; border-radius: var(--radius-lg); font-size: 0.9rem;
  line-height: var(--leading-body); overflow-wrap: anywhere; }
.mirabel__msg.is-bot { align-self: flex-start; background: var(--ground);
  border: 1px solid var(--rule-faint); border-bottom-left-radius: var(--radius-xs);
  position: relative; } /* anchors .mirabel__copy, positioned absolute below */
/* User text is rendered verbatim (textContent), so preserve its own line breaks;
   Mirabel's replies are Markdown blocks and lay themselves out. */
.mirabel__msg.is-user { align-self: flex-end; background: var(--smart-blue-bg);
  border-bottom-right-radius: var(--radius-xs); white-space: pre-wrap; }

/* Rich Markdown inside Mirabel's bubbles. Collapse the outer paragraph margins
   so the bubble padding alone frames the text. The copy button (Task 18) is
   the bubble's structural :last-child but not its visual content — it's
   absolutely positioned — so a bare :last-child would collapse the button's
   (irrelevant) margin while the actual last block keeps its trailing margin,
   reopening the whitespace this rule exists to remove. Target the last
   CONTENT child instead: either the true last child when it isn't the button,
   or the element sitting immediately before a button-as-last-child. No twin
   change for :first-child — the button is appended after the rendered
   fragment, so the first child is always content. */
.mirabel__msg > :first-child { margin-top: 0; }
.mirabel__msg > :last-child:not(.mirabel__copy),
.mirabel__msg > :has(+ .mirabel__copy:last-child) { margin-bottom: 0; }
.mirabel__msg p { margin: 0 0 0.5em; }
.mirabel__msg ul, .mirabel__msg ol { margin: 0 0 0.5em; padding-left: 1.25em; }
.mirabel__msg li { margin: 0.15em 0; }
.mirabel__msg strong { font-weight: 700; }
.mirabel__msg em { font-style: italic; }
.mirabel__msg a { color: var(--red); text-decoration: underline; }
.mirabel__msg code { font-family: var(--font-mono); font-size: 0.85em;
  background: var(--surface-alt); padding: 0.1em 0.3em; border-radius: 4px; }
/* Copy button (Task 18): tucked into the bubble's own corner rather than a
   toolbar row, so it never competes with the reply's own layout. Hidden until
   hover/focus on a fine pointer (mouse) — a hovering trackpad/mouse can see
   it appear; a touch surface has no hover state to reveal it, so it stays at
   a faint constant opacity there instead of being permanently invisible. */
.mirabel__copy { position: absolute; right: 4px; bottom: 4px; width: 24px; height: 24px;
  border: none; background: none; color: var(--text-soft); font-size: 0.85rem;
  border-radius: var(--radius-sm); cursor: pointer; opacity: 0;
  transition: opacity var(--duration-fast), color var(--duration-fast); }
.mirabel__msg.is-bot:hover .mirabel__copy,
.mirabel__msg.is-bot:focus-within .mirabel__copy { opacity: 1; }
.mirabel__copy:hover { color: var(--text); }
@media (pointer: coarse) {
  .mirabel__copy { opacity: 0.45; }
}
.mirabel__copy-fallback { width: 100%; margin-top: 6px; font-size: 0.8rem;
  font-family: var(--font-mono); color: var(--text); background: var(--surface-alt);
  border: 1px solid var(--rule); border-radius: var(--radius-sm); padding: 4px 6px; resize: none; }
/* Headings/blockquote/hr/tables (Task 17): scaled down from document sizes —
   these are bubble content, not a document page — and tight-margined like the
   rest of the bubble's block elements above. */
.mirabel__msg h1 { font-size: 1.1em; margin: 0 0 0.4em; line-height: var(--leading-flat); }
.mirabel__msg h2 { font-size: 1.05em; margin: 0 0 0.4em; line-height: var(--leading-flat); }
.mirabel__msg h3 { font-size: 1em; margin: 0 0 0.35em; line-height: var(--leading-flat); }
.mirabel__msg h4 { font-size: 0.95em; margin: 0 0 0.35em; line-height: var(--leading-flat); }
.mirabel__msg blockquote { margin: 0 0 0.5em; padding: 0.1em 0 0.1em 0.7em;
  border-left: 3px solid var(--rule); color: var(--text-soft); }
.mirabel__msg hr { border: none; border-top: 1px solid var(--rule-faint); margin: 0.5em 0; }
.mirabel__table-wrap { overflow-x: auto; max-width: 100%; margin: 0 0 0.5em; }
.mirabel__msg table { border-collapse: collapse; font-size: 0.85rem; }
.mirabel__msg th, .mirabel__msg td { border: 1px solid var(--rule-faint); padding: 0.25em 0.5em; text-align: left; }
.mirabel__msg th { font-weight: 700; }
/* One reply unit: the answer bubble plus the cards that turn produced. The
   wrapper exists for EVERY reply (uniform DOM), but the left accent only
   appears when the turn actually carries tool output — it binds cards to the
   reply that produced them; a text-only answer stays a plain bubble. Same
   :has() gating precedent as body:has() below. */
.mirabel__turn { display: flex; flex-direction: column; align-items: flex-start; gap: 0; }
.mirabel__turn > * + * { margin-top: 6px; }
.mirabel__turn:has(.mirabel__draft, .mirabel__options, .mirabel__suggestions) {
  border-left: 3px solid var(--rule); padding-left: 8px; }
.mirabel__turn .mirabel__options, .mirabel__turn .mirabel__suggestions { align-self: stretch; }

.mirabel__thinking { align-self: flex-start; color: var(--text-light); font-size: 0.9rem; padding: 4px 8px; }
.mirabel__tool-status { align-self: flex-start; color: var(--text-light); font-size: 0.9rem; font-style: italic; padding: 4px 8px; }

/* Small in-transcript action button. The draft-open, stage-import, and (the
   ghost) stage-dismiss buttons all rode the same eight declarations; this is
   that shared primitive. Each button carries .mirabel__action in markup and
   adds only its own differentiator (draft-open's flex-start alignment;
   stage-dismiss's borderless/muted ghost treatment). stage-import is the bare
   primitive. The override rules sit after this one, so equal-specificity
   later-wins applies them. */
.mirabel__action { font-size: 0.85rem; padding: 4px 10px; border-radius: var(--radius-sm);
  border: 1px solid var(--rule); background: var(--ground); color: var(--text);
  font-family: var(--font-body); cursor: pointer; }

/* A recipe draft: a persistent card (unlike the transient tool status) with an
   "Open in editor" action. Chrome matches .mirabel__chip's token set so it sits
   in the transcript without a new palette. */
.mirabel__draft { align-self: flex-start; display: flex; flex-direction: column; gap: 4px;
  border: 1px solid var(--rule-faint); border-radius: var(--radius-lg); padding: 10px 12px; background: var(--ground); }
.mirabel__draft-tag { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-light); }
.mirabel__draft-open, .mirabel__draft-preview, .mirabel__retry { align-self: flex-start; }

/* An options block (#2690): Mirabel asked a bounded question and offered
   tappable choices (present_options). Deliberately NOT a speech bubble —
   full-width and bordered like the draft card, so tool-driven UI reads as
   interface, not words she typed. After a tap the block stays as an honest
   record: chosen label highlighted, the rest dimmed, all disabled. */
.mirabel__options { align-self: stretch; display: flex; flex-direction: column; gap: 6px;
  border: 1px solid var(--rule-faint); border-radius: var(--radius-lg);
  padding: 10px 12px; background: var(--ground); }
.mirabel__options-prompt { margin: 0; font-size: 0.9rem; line-height: var(--leading-body); }
.mirabel__option { text-align: left; font-size: 0.9rem; padding: 6px 10px;
  border-radius: var(--radius-sm); border: 1px solid var(--rule);
  background: var(--surface-alt); color: var(--text); font-family: var(--font-body); cursor: pointer; }
.mirabel__option:disabled { cursor: default; opacity: 0.55; }
.mirabel__option.is-chosen { opacity: 1; background: var(--smart-blue-bg); font-weight: 600; }

/* Guided-flow scaffolding (#2691): a flow card carries a scripted step's own
   prompt (await steps, walkthroughs) — muted like a stage preview, not a bot
   speech bubble. An event note is a one-line bracketed record of what a
   scripted action did (e.g. "[Imported a photo]"), centered and quiet. */
.mirabel__flow-card { align-self: stretch; border: 1px solid var(--rule-faint);
  border-radius: var(--radius-lg); padding: 10px 12px; background: var(--ground);
  font-size: 0.9rem; line-height: var(--leading-body); }
.mirabel__event { align-self: center; font-size: 0.78rem; color: var(--text-light); padding: 2px 0; }

/* Suggestion cards (#2571): a quiet list, one Add-to-Menu action per row —
   not a speech bubble, and not the bordered .mirabel__options block (there's
   no prompt to disable here, each card acts independently). Ephemeral like an
   un-tapped flow block: never persisted, only the Add outcome (an event
   note) is. */
.mirabel__suggestions { align-self: stretch; display: flex; flex-direction: column; gap: 8px; }
.mirabel__suggestion { border: 1px solid var(--rule-faint); border-radius: var(--radius-lg);
  padding: 8px 11px; background: var(--ground); }
.mirabel__suggestion-title { display: block; font-weight: 600; font-size: 0.9rem;
  color: var(--text); text-decoration: none; }
.mirabel__suggestion-why { color: var(--text-soft); font-size: 0.8rem; margin: 2px 0 6px; }
.mirabel__suggestion-add { font-size: 0.8rem; padding: 3px 10px;
  border-radius: var(--radius-sm); border: 1px solid var(--rule);
  background: var(--surface-alt); color: var(--text); font-family: var(--font-body); cursor: pointer; }
.mirabel__suggestion-add:disabled { cursor: default; opacity: 0.55; }

/* -- Dinner-picker wheel, summoned into the transcript (#2571) -- */

.mirabel__wheel {
  margin: 0.5rem 0;
  text-align: center;
}

.dinner-picker-spin-btn {
  min-width: 180px;
  display: block;
  margin: 0 auto 0.8rem;
}

/* -- Drum (3D cylinder slot window) -- */

.dinner-picker-drum {
  border-radius: 8px;
  height: 76px;
  overflow: hidden;
  clip-path: inset(0 round 8px);
  position: relative;
  margin: 0 auto 0.8rem;
  border: 1px solid var(--rule);
  background: var(--surface-alt);
  perspective: 800px;
  width: fit-content;
}

.dinner-picker-drum::before {
  content: '';
  position: absolute;
  inset: 0;
  z-index: 2;
  pointer-events: none;
  background:
    linear-gradient(to bottom,
      rgba(45, 42, 38, 0.35) 0%,
      rgba(45, 42, 38, 0.08) 20%,
      transparent 38%,
      transparent 62%,
      rgba(45, 42, 38, 0.08) 80%,
      rgba(45, 42, 38, 0.35) 100%
    );
}

.dinner-picker-chevron {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  z-index: 3;
  pointer-events: none;
  color: var(--red);
  font-size: 0.7rem;
  opacity: 0.6;
  line-height: var(--leading-flat);
}

.dinner-picker-chevron-left {
  left: 4px;
}

.dinner-picker-chevron-right {
  right: 4px;
}

.dinner-picker-reel {
  position: absolute;
  inset: 0;
  transform-style: preserve-3d;
  will-change: transform;
}

.dinner-picker-reel-item {
  position: absolute;
  width: 100%;
  height: 26px;
  top: 50%;
  left: 0;
  margin-top: -13px;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 0.95rem;
  font-weight: 600;
  color: var(--red);
  white-space: nowrap;
  -webkit-backface-visibility: hidden;
  backface-visibility: hidden;
}

/* -- Result panel (slides in below drum) -- */

.dinner-picker-result-panel {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows var(--duration-normal) ease, opacity var(--duration-normal) ease;
  opacity: 0;
}

.dinner-picker-result-inner {
  overflow: hidden;
  text-align: center;
}

.dinner-picker-result-panel.visible {
  grid-template-rows: 1fr;
  opacity: 1;
}

.dinner-picker-result-link {
  display: block;
  text-align: center;
  font-weight: 600;
  font-size: 0.9rem;
  margin-bottom: 0.1rem;
}

.dinner-picker-result-description {
  color: var(--text-soft);
  font-size: 0.85rem;
  margin-top: 0;
  margin-bottom: 0.6rem;
  line-height: var(--leading-body);
}

/* The bookmarklet walkthrough's drag target (Task 6, #2691 failure branch):
   inert javascript: href, live only once dragged onto the bookmarks bar. */
.mirabel__bookmarklet {
  display: inline-block;
  margin-top: 8px;
  padding: 6px 12px;
  border: 1px dashed var(--rule);
  border-radius: var(--radius-sm);
  background: var(--surface-alt);
  color: var(--text);
  cursor: grab;
  text-decoration: none;
}

/* A fragment-staged import (#1223): a bookmarklet/share dropped a recipe in the
   URL fragment. This is the confirm card — bot-aligned like .mirabel__draft, with
   a muted text preview of the captured body and Import / Dismiss actions. Nothing
   is sent until Import; Dismiss removes the card. */
.mirabel__stage { display: flex; flex-direction: column; gap: 6px;
  border: 1px solid var(--rule-faint); border-radius: var(--radius-lg); padding: 10px 12px; background: var(--ground); }
.mirabel__stage-preview { font-size: 0.8rem; color: var(--text-light); margin: 0;
  display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; overflow: hidden; }
.mirabel__stage-actions { display: flex; gap: 6px; }
.mirabel__stage-dismiss { border: none; background: none; color: var(--text-soft); }

/* Photo attach (#1925): a camera control mounts the image-picker — now a
   Mirabel-only feature (the AI import dialog was retired in #1933). The picker
   wraps the input form: the thumb strip rides above the field, and the attach
   button lives INSIDE the input pill (#1942) alongside send, so the row reads as
   one field rather than a separate toolbar. .mirabel__photo-echo is the
   session-memory thumbnail row of what was just sent — user-aligned, never
   persisted (rendered from object URLs in JS). */
.mirabel__picker { display: flex; flex-direction: column; gap: 6px;
  padding: 8px 12px 6px; border-top: 1px solid var(--rule-faint); }

/* The image-picker's base chrome: an ordered thumbnail strip the controller
   builds at runtime. The .thumb-* control chrome below is created by
   image_picker_controller.js, not authored in markup. It lives here (not in
   editor.css) because the picker is a Mirabel-global feature — _mirabel is its
   only mount, and mirabel.css loads on every page whenever the AI key is set,
   which is exactly when the picker can render anywhere. Editor pages get these
   rules via mirabel.css too. Keep this block BEFORE the .mirabel__thumb-strip
   override below: both are 0,1,0 specificity, so the override must stay later
   to win. */
.thumb-strip {
  display: flex;
  flex-wrap: wrap;
  gap: 0.75rem;
  margin: 0.75rem 0 0;
  padding: 0 1.5rem;
  list-style: none;
}

.thumb {
  position: relative;
  width: 64px;
  height: 64px;
}

.thumb img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  border: 1px solid var(--rule);
  border-radius: var(--radius-sm);
  display: block;
}

/* White-on-red badge clears 3:1 on both grounds. */
.thumb-badge {
  position: absolute;
  top: -6px;
  left: -6px;
  min-width: 1.1rem;
  height: 1.1rem;
  padding: 0 0.2rem;
  border-radius: var(--radius-pill);
  background: var(--red);
  color: white;
  font-size: 0.7rem;
  font-weight: 600;
  line-height: 1.1rem;
  text-align: center;
}

.thumb-up,
.thumb-down,
.thumb-remove {
  position: absolute;
  width: 1.5rem;
  height: 1.5rem;
  padding: 0;
  border: 1px solid var(--rule);
  border-radius: var(--radius-sm);
  background: var(--ground);
  color: var(--text);
  font-size: 0.85rem;
  line-height: var(--leading-flat);
  cursor: pointer;
}

.thumb-up { bottom: -8px; left: -8px; }
.thumb-down { bottom: -8px; left: 1rem; }
.thumb-remove { top: -8px; right: -8px; }

.thumb-up:disabled,
.thumb-down:disabled {
  opacity: 0.4;
  cursor: default;
}

.thumb--error img {
  border-color: var(--danger);
}

.thumb-error {
  position: absolute;
  top: calc(100% + 0.25rem);
  left: 0;
  width: 14rem;
  margin: 0;
  font-size: 0.72rem;
  color: var(--danger);
}

/* Override the base .thumb-strip padding/margin (above) for the panel, and let
   the strip span the full width so its thumbnails wrap above the field. */
.mirabel__thumb-strip { margin: 0; padding: 4px 2px; gap: 8px; }
.mirabel__thumb-strip[hidden] { display: none; }
/* A plain icon button inside the input pill — no border/background of its own, so
   the pill is the only visible chrome. */
.mirabel__attach { width: 30px; height: 30px; flex: none; border-radius: 50%;
  display: inline-flex; align-items: center; justify-content: center; padding: 0;
  background: none; color: var(--text-soft); border: none; cursor: pointer; }
.mirabel__attach:hover { color: var(--text); }
.mirabel__attach:disabled { opacity: 0.4; cursor: default; }

.mirabel__photo-echo { display: flex; flex-wrap: wrap; gap: 4px; padding: 4px; }
.mirabel__photo-thumb { width: 48px; height: 48px; object-fit: cover; border-radius: 6px;
  border: 1px solid var(--rule); display: block; }

.mirabel__chips { display: flex; flex-wrap: wrap; gap: 7px; padding: 12px 14px 8px; }
.mirabel__chips[hidden] { display: none; }
.mirabel__chip { font-size: 0.8rem; line-height: 1.2; padding: 6px 11px; border-radius: var(--radius-pill);
  border: 1px solid var(--rule); background: var(--ground); color: var(--text);
  font-family: var(--font-body); cursor: pointer; }

/* The input row is one pill: attach (camera) and send sit INSIDE the field
   (#1942), so it reads as a single control. The text input is borderless — the
   pill border/background is the only chrome, and focus moves to the pill via
   :focus-within. The textarea's font-size: max(16px, 1rem) holds a hard 16px
   floor — the threshold below which iOS Safari zooms the viewport on focus — so
   a sub-16px root (text-zoom or an overridden default) can't make tapping the
   field zoom the page (#1942, #2408). */
/* A rounded rect, not a pill: once the textarea grows past one line, pill caps
   become awkward half-circles on a tall box (#2408). align-items: flex-end keeps
   the attach/send controls beside the last line as the field grows. */
.mirabel__input { display: flex; align-items: flex-end; gap: 4px;
  border: 1px solid var(--rule); border-radius: var(--radius-lg); background: var(--ground);
  padding: 4px 5px 4px 6px; }
.mirabel__input:focus-within { outline: var(--focus-ring); outline-offset: -1px; border-color: var(--red); }
.mirabel__input textarea { flex: 1; min-width: 0; border: none; background: none;
  padding: 6px 2px; font-size: max(16px, 1rem); font-family: var(--font-body); color: var(--text);
  line-height: 1.4; resize: none; overflow-y: auto; max-height: 8.5rem; }
.mirabel__input textarea:focus { outline: none; }
.mirabel__send { width: 32px; height: 32px; flex: none; border-radius: 50%; background: var(--positive);
  color: white; border: none; font-family: var(--font-body); cursor: pointer; }

/* The AI/privacy disclaimer lives in the transcript now, as a flow-card (Task
   10) — not a pinned footer. Same small/muted type the old footer used;
   links get no rule here, so they keep their plain inherited treatment. */
.mirabel__disclaimer-card { font-size: 0.66rem; line-height: 1.3; color: var(--text-light); text-align: center; }
.mirabel__disclaimer-card p { margin: 0; }

/* ── Cramped layout ──────────────────────────────────────────────────────────
 * When nav_shell_controller flips data-cramped="true", Mirabel's trigger moves
 * to the top-right nav slot (#2353): the .pill--mirabel there reveals and the
 * desktop FAB hides — exactly one trigger per layout. Search takes her old dock
 * spot beside the destinations pill (navshell.css). The panel re-anchors ABOVE
 * the dock and rises from the bottom — the same direction as desktop, so
 * there's one panel direction everywhere. Keyed off the same body:has() the
 * dock's body padding uses (navshell.css), not a width query: cramped is
 * measured, so a media query could disagree with the real layout under
 * text-zoom. */
body:has(.navshell[data-cramped="true"]) .mirabel__fab { display: none; }

/* The cramped sheet (below) is already full-screen — a dead expand toggle
   would be noise with nothing left to expand into. */
body:has(.navshell[data-cramped="true"]) .mirabel__expand { display: none; }

/* The top-strip trigger is the cramped counterpart to the desktop FAB: scoped
 * to .navshell (where it lives) so the desktop-hidden state is a single rule. */
.navshell:not([data-cramped="true"]) .pill--mirabel { display: none; }

body:has(.navshell[data-cramped="true"]) .mirabel__panel {
  /* Full-screen sheet overlaid on the VISUAL viewport (#2408). mirabel_controller
     sets --mirabel-vv-* from the VisualViewport API so the sheet tracks the
     keyboard's OFFSET as well as its size — pinning top/left, not just height,
     keeps iOS focus-scroll from riding the fixed sheet off-screen (the symptom:
     only the input dangling into view). The 0 / 100* fallbacks make it a
     plain full-screen sheet with no JS. */
  position: fixed;
  top: var(--mirabel-vv-top, 0px);
  left: var(--mirabel-vv-left, 0px);
  width: var(--mirabel-vv-width, 100vw);
  height: var(--mirabel-vv-height, 100dvh);
  max-width: none; max-height: none; margin: 0;
  border-radius: 0;
}

/* The open full-screen sheet must cover the navshell (--z-nav: 30). The bump
   has to land on the .mirabel WRAPPER, not the panel: .mirabel is
   position:fixed + z-index:--z-raised, so it's a stacking context, and a
   z-index on the nested panel only orders WITHIN it — it could never lift the
   panel above the sibling .navshell. (The search overlay escapes this by being
   a <dialog> in the top layer via showModal(); our panel is a <div> by design,
   so it can't borrow that trick.) Raising the wrapper lifts the whole subtree
   above the nav. Gated to the open state (panel not [hidden]) so the closed
   trigger stays at --z-raised and the #1942 brand-menu invariant is untouched;
   the desktop FAB is display:none when cramped, so lifting the wrapper has no
   other effect. */
body:has(.navshell[data-cramped="true"]) .mirabel:has(.mirabel__panel:not([hidden])) {
  z-index: var(--z-overlay);
}

/* The footer used to be the buffer above the home indicator; now that it's
   gone (Task 10), the picker row's own bottom padding needs to clear it. */
body:has(.navshell[data-cramped="true"]) .mirabel__picker {
  padding-bottom: max(6px, env(safe-area-inset-bottom));
}

/* Body scroll-lock while the sheet is open on touch (mirabel_controller toggles
   the class, coarse-pointer-gated). Mirrors html:has(dialog[open]) for the panel,
   which is a <div> and so doesn't get that rule for free.
   iOS ignores root overflow:hidden for touch scrolling — freeze the body at
   the captured offset instead; the JS restores the scroll position on close. */
html.mirabel-scroll-lock body {
  position: fixed;
  top: var(--mirabel-lock-top, 0px);
  left: 0; right: 0;
  overflow: hidden;
}
/* Body-level touch scrolling under the freeze must not chain into a page
   scroll when it hits either end — contain it inside the sheet. */
.mirabel__transcript,
.mirabel__panel { overscroll-behavior: contain; }
