/* 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 {
  /* Sizing tokens: the hand-synced offset math below (flagged fragile by its
     own former comment) is tokenized here so --mirabel-panel-anchor has
     exactly one source of truth for both this wrapper's bottom offset and
     the panel's own `bottom:` positioning (.mirabel__panel and
     .mirabel__panel[data-expanded] below) — duplicating the literal in both
     places is the desync this refactor exists to kill. */
  --mirabel-anchor-bottom: 1.25rem;
  --mirabel-panel-anchor: 76px;
  --mirabel-nav-gap: 1rem;
  position: fixed; right: 1.25rem;
  bottom: calc(var(--mirabel-anchor-bottom) + 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;
}
/* Press rung (#4534): motion, not a tint or a deboss. `background: none` leaves
   nothing to tint — the only visible pixels are the portrait <img> — and any
   box-shadow here would REPLACE the drop shadow above, so a press would read as
   the FAB going flat. translateY composes with it instead. */
.mirabel__fab:active { transform: translateY(2px); }
/* 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;
}

/* 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 {
  /* Inset is a percentage, not px, so the halo reaches proportionally on BOTH
     triggers — the 64px FAB (~3.2px) and the 3rem nav pill (~2.4px). A px inset
     read as heavier on the smaller pill. Percentages resolve against the
     trigger's own box (both are square, so all four sides match). */
  content: ""; position: absolute; inset: -5%; 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;
}

/* The portrait itself does NOT animate — no breathing/zoom pulse. Only the halo
   moves (spin + morph + a gentle opacity throb), so Mirabel reads as calm with a
   living hearth behind her rather than a button that keeps growing. */
@media (prefers-reduced-motion: no-preference) {
  .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-hearth-spin { to { --mirabel-hearth-angle: 360deg; } }
  /* Scale peak stays small: it adds to how far the halo reaches past the
     portrait, on top of the -5% inset and the 3px blur. */
  @keyframes mirabel-hearth-pulse {
    0%, 100% { opacity: 0.48; transform: scale(1); }
    50%      { opacity: 0.84; transform: scale(1.02); }
  }
  @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);
}
/* Same treatment as the desktop FAB, and it matters more here: this is the
   cramped-layout twin, i.e. the ONE Mirabel trigger a phone user ever taps. */
.pill--mirabel:active { transform: translateY(2px); }

.mirabel__panel {
  position: absolute; right: 0; bottom: var(--mirabel-panel-anchor); 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. --mirabel-panel-anchor is the panel's bottom
   anchor inside the wrapper (the single token shared with .mirabel__panel's
   own `bottom:` above — no more hand-syncing two literals);
   --mirabel-anchor-bottom + the safe-area inset is 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); --mirabel-nav-gap 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) - var(--mirabel-nav-gap) - var(--mirabel-panel-anchor) - var(--mirabel-anchor-bottom) - env(safe-area-inset-bottom, 0px));
}

/* The workshop (spec 2026-08-01 §1): expanded splits into transcript + a
   server-rendered workspace pane. Scoped to non-cramped explicitly — the
   cramped sheet presents the same regions as tabs instead. */
body:not(:has(.navshell[data-cramped="true"])) .mirabel__panel[data-expanded] {
  width: min(1120px, calc(100vw - 3rem));
  display: grid;
  grid-template-rows: auto minmax(0, 1fr);
  grid-template-columns: minmax(320px, 30rem) minmax(0, 1fr);
  grid-template-areas: "head head" "chat workspace";
}
body:not(:has(.navshell[data-cramped="true"])) .mirabel__panel[data-expanded] .mirabel__head { grid-area: head; }
body:not(:has(.navshell[data-cramped="true"])) .mirabel__panel[data-expanded] .mirabel__chat {
  grid-area: chat; display: flex; flex-direction: column; min-height: 0;
  border-right: 1px solid var(--rule-faint);
}
body:not(:has(.navshell[data-cramped="true"])) .mirabel__panel[data-expanded] .mirabel__workspace {
  grid-area: workspace; display: block; min-height: 0; overflow-y: auto;
}

/* Compact and default: the chat wrapper is layout-transparent, the pane hidden.
   display:contents removes .mirabel__chat from the `.mirabel__panel > *` rule's
   reach (mirabel.css:148) — re-shield its children explicitly or the panel's
   ::before texture overlay paints on top of the chips and input rows. */
.mirabel__chat { display: contents; }
.mirabel__chat > * { position: relative; z-index: 1; }
.mirabel__workspace { display: none; }
.mirabel__tabs { display: none; border-bottom: 1px solid var(--rule-faint); }

/* Workshop button (bridge affordance, spec §1): hidden only when the pane is
   already visible on desktop expanded. It stays visible under cramped, where
   it switches the sheet to the Workshop tab instead (mirabel_workspace_controller's
   openWorkshop branches on cramped) — do not hide it there. */
body:not(:has(.navshell[data-cramped="true"])) .mirabel__panel[data-expanded] .mirabel__workshop { display: none; }

/* Workshop pane content (spec §§1, 3d): the empty state and the preferences
   tenant's chrome. Token-based, kept simple — richer pane content (drafts,
   diffs, deltas) arrives in later PRs. */
/* <turbo-frame> is unknown to the UA stylesheet, so it computes to `inline`
   and its block children would lay out inside an inline box (editor.css:41 is
   the in-repo precedent for giving one an explicit box). */
.mirabel__workspace > turbo-frame { display: block; }

/* Every <a> the pane can render (the preferences tenant's Settings-page link
   since PR 1, a draft's resolved cross-reference, an inline @[Title] mention)
   falls back to UA-default blue/underline without this: base.css's only
   global link rule is `main a`, and the panel renders outside <main>
   (application.html.erb — </main> closes before shared/_mirabel is yielded).
   #mirabel-workspace is the frame id shared by every tenant, so scoping here
   fixes every tenant at once rather than widening `main a` itself, which
   would restyle links across the whole app. */
#mirabel-workspace a {
  color: var(--text);
  text-decoration: underline;
  text-decoration-color: var(--rule-faint);
  text-decoration-thickness: 1px;
  text-underline-offset: 3px;
  transition: color var(--duration-normal) ease, text-decoration-color var(--duration-normal) ease;
}
/* Text dialect (base.css:780). The transition declared just above was authored
   for state changes that, until #4534, never fired — this is the first rung to
   use it. Same reason as .mirabel__suggestion-title: `main a` cannot reach here. */
#mirabel-workspace a:active {
  color: var(--red);
  text-decoration-color: var(--red);
}

/* The @[Title] inline-mention link (recipes_helper#linkify_recipe_references,
   step instructions/prep notes/footer) — recipe.css's a.recipe-link,
   duplicated here for the same reason as .ingredient-alt below: recipe.css
   loads only on the recipe show page, but the pane opens everywhere. */
#mirabel-workspace a.recipe-link {
  color: var(--red);
  text-decoration-style: dotted;
  text-decoration-color: var(--red);
}

/* _broken_reference's card shape (`.embedded-recipe.broken-reference`) — the
   draft's NORMAL state for a `> @[Title]` naming a sub-recipe that doesn't
   exist yet, not an edge case. draft_id_prefix forces every RESOLVED
   cross-reference to the link branch too, so the fuller embedded-card chrome
   (header, title, cross-off controller) never renders in the pane — only the
   base card shape + broken-reference tint are needed here, not the whole
   recipe.css block. */
#mirabel-workspace .embedded-recipe {
  border: 1px solid var(--rule);
  border-radius: 0.25rem;
  background-color: var(--ground);
  box-shadow: var(--shadow-sm);
  padding: 1.5rem;
  margin-top: 0.5rem;
}

#mirabel-workspace .broken-reference {
  background-color: var(--broken-reference-bg);
  color: var(--text-soft);
}

#mirabel-workspace .broken-reference p {
  margin: 0;
  font-style: italic;
}

/* Two different facts, one visual treatment: no draft at all, and a draft
   that exists but wouldn't build (draft.html.erb's rescue branch). Kept as
   separate classes so each state stays independently addressable (tests,
   future analytics) even though today they read identically. */
.mirabel__ws-empty, .mirabel__ws-parse-failed { padding: 32px 20px; text-align: center; color: var(--text-soft);
  font-size: 0.9rem; line-height: var(--leading-body); }
.mirabel__ws-content { padding: 16px 18px; }
.mirabel__ws-title { font-size: 1rem; font-weight: 600; margin: 0 0 12px; line-height: var(--leading-tight); }
.mirabel__ws-note { font-size: 0.8rem; color: var(--text-soft); margin: 10px 0 0; }

/* The draft tenant (spec 2026-08-01 §3a): recipe.css's header/footer/section
   chrome doesn't apply (no header, no cross-off, a bench-scoped footer note
   instead of a document footer), so its rules are hand-picked here rather
   than inherited wholesale. */
.mirabel__ws-draft-description { font-size: 0.85rem; color: var(--text-soft); margin: 0 0 12px; }
.mirabel__ws-draft .recipe h3 { font-size: 0.9rem; margin: 14px 0 6px; }
.mirabel__ws-draft-footer { font-size: 0.8rem; color: var(--text-soft); margin-top: 14px; }
/* Two bench-scoped notes under a draft, one treatment: the conversation has
   moved on (superseded), and the draft's slug is already taken so Save is
   withheld (conflict). Separate classes so each stays independently
   addressable, same rule because they read the same. */
.mirabel__ws-superseded, .mirabel__ws-conflict { font-size: 0.8rem; color: var(--text-soft); margin-top: 14px; }

/* The draft tenant's action row. Chrome is .mirabel__action's token set rather
   than a new palette — same panel, same primitive — and the primary modifier
   borrows base.css's .btn-primary fill so "Save to kitchen" reads as the
   affirmative action here exactly as it does elsewhere. button_to wraps its
   control in a <form>, whose default margin would fight the row's gap. */
.mirabel__ws-actions { display: flex; gap: 8px; margin-top: 16px; }
.mirabel__ws-actions form { margin: 0; }
.mirabel__ws-action { font-size: 0.85rem; padding: 6px 12px; border-radius: var(--radius-sm);
  border: 1px solid var(--rule); background: var(--ground); color: var(--text);
  font-family: var(--font-body); cursor: pointer; }
.mirabel__ws-action--primary { background: var(--red-fill); border-color: var(--red-fill); color: white; }

/* Two rungs, not one, and the split is load-bearing: the modifier below wins
   over the primitive by SOURCE ORDER at equal (0,1,0), so a lone
   .mirabel__ws-action:active at (0,2,0) would out-specify it and repaint the
   three write actions (Save to kitchen / Apply changes / Add N items) neutral
   under the finger. The hover rung already respects this split. */
.mirabel__ws-action:active { background: var(--rule-faint); border-color: var(--text-light); }

@media (any-hover: hover) {
  .mirabel__ws-action--primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
}
.mirabel__ws-action--primary:active {
  background: var(--accent-hover); border-color: var(--accent-hover); box-shadow: var(--shadow-press);
}

/* The edit_compare tenant (spec 2026-08-01 §3b). The diff body is a document
   fragment with no .recipe wrapper of its own — _marked_body emits bare
   <section>/<h2>/<ul>/<footer>. The pane supplies the .recipe--history
   ambient class (components/diff_marks.css keys off it) and the scale the
   narrow column needs; the footer rule stands in for base.css's
   #recipe-history-keyed one, which the pane has no ancestor for. */
.mirabel__ws-compare .recipe--history h2 { font-size: 0.9rem; margin: 14px 0 6px; }
.mirabel__ws-compare .recipe--history p { font-size: 0.85rem; }
.mirabel__ws-compare .recipe--history footer {
  margin-top: 14px;
  font-size: 0.8rem;
  color: var(--text-soft);
}
.mirabel__ws-applied, .mirabel__ws-stale { font-size: 0.85rem; color: var(--text-soft); margin: 0 0 12px; }

/* The shopping_delta tenant (spec 2026-08-01 §3c). .mirabel__ws-note (the
   draft tenant's aside style, above) doubles as the optional group's label —
   same faint caption every other pane's asides already use, not a new rule.
   The 10px margin below reads close to edit_compare's own .mirabel__ws-applied/
   .mirabel__ws-stale (12px, above) but isn't folded into that rule: the two
   pairs share no concept, only a coincidental number, and each tenant's rules
   stay scoped to its own comment block here rather than reaching across. */
/* -added joins the group rather than minting a rule: the batch add's outcome
   line is the same faint summary voice as the pane it replaces, and a separate
   class keeps it independently addressable (the -superseded/-conflict
   precedent above). */
/* -covered (everything short is already carted, #4018) joins for the same
   reason -added did: it is the same faint summary voice, in the same slot, and
   only needs to be independently addressable. */
.mirabel__ws-delta-summary, .mirabel__ws-delta-complete,
.mirabel__ws-delta-added, .mirabel__ws-delta-covered { font-size: 0.85rem; color: var(--text-soft); margin: 0 0 10px; }
/* Required-missing and optional-missing render the same list chrome — one
   rule, not two near-identical copies. They used to differ only in a top
   margin (0 vs 4px) that margin-collapse against the preceding paragraph
   already absorbs either way, so unifying it changes nothing on screen. */
.mirabel__ws-delta-need, .mirabel__ws-delta-listed,
.mirabel__ws-delta-optional ul { margin: 4px 0 0; padding-left: 1.2em;
  font-size: 0.9rem; line-height: var(--leading-body); }
.mirabel__ws-delta-optional { margin-top: 14px; }

/* The read-only cooking-preferences summary (settings/_preferences_fields,
   editable: false): renders here for non-owner members — the settings page
   itself always renders editable: true, so this dl's only caller today is
   this pane. Kept simple: a term/definition list, not a form. */
.settings-preferences-summary { display: grid; grid-template-columns: auto 1fr;
  gap: 4px 12px; margin: 0; font-size: 0.9rem; }
.settings-preferences-summary dt { color: var(--text-soft); }
.settings-preferences-summary dd { margin: 0; color: var(--text); }
/* Same branch, nothing set: her "nothing saved yet" line stands in for the dl. */
.settings-preferences-empty { margin: 0; font-size: 0.9rem; color: var(--text-soft);
  line-height: var(--leading-body); }

.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); }
@media (any-hover: hover) {
  .mirabel__clear:hover { color: var(--text); }
}
/* ── Press rung (HIG state ladder 3, #4534) ──────────────────────────────────
 * mirabel.css shipped with ZERO :active rules, so the panel — the surface most
 * used from a phone — acknowledged no tap anywhere. Rung 2 is any-hover gated,
 * so a touch user's only rung was whatever the app did next.
 *
 * THE DECISION, since #4534 asked for one: the quartet (info / workshop / ⤢ / ×)
 * shares ONE press treatment — a surface tint one step past hover, in each
 * button's own hue family — and the panel's other controls follow the same
 * principle in their own dialect. Three rules govern every rung below:
 *
 *   1. Press moves a property the control's STATE INDICATOR does not own.
 *      The quartet's indicator is `color` (expand's aria-pressed red), the tabs'
 *      is `border-bottom-color`; pressing moves `background`, so no press rule
 *      has to out-specify an indicator and none can repaint one. Where that is
 *      impossible (.mirabel__option, whose .is-chosen owns `background`) the
 *      selected variant gets its own rung, as .btn-pill.active:active does.
 *      Note this is the OPPOSITE constraint to hover: press SHOULD reach a
 *      selected control (pressing it is still a tap), it just must not erase it.
 *   2. --shadow-press is NOT the house-wide press treatment, and it is wrong
 *      here. It is `inset 0 1px 2px rgba(0,0,0,0.06)` — on a 30px circle with
 *      `background: none` there is no surface to recess and it reads as nothing.
 *      base.css:809 (.btn-ghost:active) already made this call for the same
 *      archetype: borderless controls press by moving their surface, not by
 *      debossing. Bordered/filled controls elsewhere in the app do take it.
 *   3. box-shadow REPLACES, it never accumulates. .mirabel__fab, .pill--mirabel
 *      and .mirabel__jump each declare a resting shadow, so a naive
 *      `box-shadow: var(--shadow-press)` would DELETE their elevation and make
 *      the press read as the control flattening. Those press by motion instead,
 *      which composes with the shadow they already have.
 *
 * Rungs are deliberately NOT given transitions they lack: press acknowledgement
 * wants to be instantaneous. The four header buttons fade only because their
 * base rules already transition background for hover.
 */

/* Text control — press reads as a colour + rule shift, not a tint (base.css:780). */
.mirabel__clear:active { color: var(--text); text-decoration-color: var(--text-light); }

/* Sits between Clear and the icon-button quartet (re-shows the disclaimer on
   demand) — same icon-button chrome as workshop/⤢/×. Clear's flex:1 above
   still fills the slack between the name and this quartet, 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); }
@media (any-hover: hover) {
  .mirabel__info:hover { background: var(--rule-faint); color: var(--text); }
}
/* One step past hover, so the press also reads on a fine pointer — where the
   hover tint is already painted and an exact replay would show nothing. */
.mirabel__info:active { background: var(--rule); color: var(--text); }

/* Workshop button (bridge affordance, spec 2026-08-01 §1): opens/switches to
   the workshop pane. Same icon-button chrome as its neighbors; visibility
   toggles live with the layout rules above. */
.mirabel__workshop { position: relative; 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); }
/* Rung 6 (#4563), and structurally the same three rules ⤢ carries below —
   read that block's comments for why each guard is there, because the
   specificity arithmetic is identical. The state itself only ever SHOWS under
   cramped: on desktop the pane's visibility is `display: none` on this button
   (layout rules above), so the true limb paints on an element nobody sees. It
   is written anyway rather than scoped to cramped, because the attribute is
   honest at both sizes and a rule that exists at only one is the drift this
   rung exists to prevent.

   The badge can never contend with the red: setBadge(false) runs on every path
   that makes the pane visible, and receiveRef only badges when it is not — so
   `aria-expanded="true"` and a lit badge are mutually exclusive by construction. */
@media (any-hover: hover) {
  .mirabel__workshop:not([aria-expanded="true"]):not(:active):hover { background: var(--rule-faint); color: var(--text); }
  .mirabel__workshop[aria-expanded="true"]:not(:active):hover { background: var(--rule-faint); }
}
.mirabel__workshop:active { background: var(--rule); color: var(--text); }
.mirabel__workshop[aria-expanded="true"] { color: var(--red); }

/* Unseen-artifact badge (spec §1): a small dot, doubled in markup — one on
   the header button, one on the Workshop tab — both toggled together by
   mirabel_workspace_controller.js. No local [hidden] rule is needed: base.css
   declares a global `[hidden] { display: none !important }`, which no
   single-class display can outrank. */
.mirabel__ws-badge { display: inline-block; width: 8px; height: 8px;
  border-radius: 50%; background: var(--red); margin-left: 4px; vertical-align: middle; }
.mirabel__workshop .mirabel__ws-badge { position: absolute; top: 2px; right: 2px; margin-left: 0; }

/* Part of the same icon-button quartet as info/workshop/×; Clear's flex:1
   above still fills the slack between the name and this quartet, 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); }
/* Excluding [aria-pressed="true"] because hover and the pressed state TIE at
   (0,2,0) — only source order keeps the red today, so any later reorder would
   let hover repaint the panel's one selection indicator (#3855's shape). The
   pressed button still takes the surface tint; the red is never touched. */
/* Both limbs also yield to :active. Unlike every other control on this surface
   — where hover and press tie at (0,2,0) and later source order settles it —
   these two carry the aria-pressed guard above, which lifts them to (0,3,0) and
   made them BEAT the (0,2,0) press rung below: on a fine pointer, pressing ⤢
   showed hover's tint and nothing else. `:not(:active)` makes the two states
   mutually exclusive, so the outcome no longer depends on either. Same fix
   shape as .tag-filter-pill's `:not(.active)` (base.css), one rung down. */
@media (any-hover: hover) {
  .mirabel__expand:not([aria-pressed="true"]):not(:active):hover { background: var(--rule-faint); color: var(--text); }
  .mirabel__expand[aria-pressed="true"]:not(:active):hover { background: var(--rule-faint); }
}
/* Unguarded on purpose, and it is safe: this moves `background` while the
   pressed-state indicator is `color`, so the two never contend and a pressed
   ⤢ still acknowledges a tap (the #4534 inversion of the hover rule above). */
.mirabel__expand:active { background: var(--rule); 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); }
@media (any-hover: hover) {
  .mirabel__close:hover { background: var(--red-light); color: var(--red); }
}
/* × keeps its own hue family rather than joining the neutral three — the
   quartet shares the treatment, not the tint. */
.mirabel__close:active { background: var(--red-soft-2); color: var(--red); }

/* Cramped-only two-tab header (spec §1 Mobile): Chat | Workshop. Visibility
   (display:none by default, display:flex under cramped) lives with the
   layout rules above; this is the tab buttons' own chrome. */
.mirabel__tab { flex: 1; padding: 10px 12px; font-size: 0.85rem; font-weight: 600;
  font-family: var(--font-body); text-align: center; background: none; border: none;
  border-bottom: 2px solid transparent; color: var(--text-soft); cursor: pointer; }
/* Cramped-only, so this control exists SOLELY for touch users and had neither a
   hover rung nor a press one. Moves `background` while the selected indicator
   owns `border-bottom-color`, so pressing the already-selected tab still
   acknowledges without touching the indicator. */
.mirabel__tab:active { background: var(--rule-faint); }
.mirabel__tab[aria-selected="true"] { color: var(--text); border-bottom-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; }
@media (any-hover: hover) {
  .mirabel__jump:hover { background: var(--surface-alt); }
}
/* The strongest case on the surface (#4534): the pill hides itself the moment
   it is tapped, so with no rung the tap reads as "nothing happened". The tint
   moves alone — adding var(--shadow-press) would REPLACE --shadow-popover above
   and drop the pill onto the transcript mid-press. */
.mirabel__jump:active { background: var(--rule); }
.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:focus-within .mirabel__copy { opacity: 1; }
/* Only the hover half is gated — :focus-within is the keyboard path and must
   never be pointer-conditional. */
@media (any-hover: hover) {
  .mirabel__msg.is-bot:hover .mirabel__copy { opacity: 1; }
  .mirabel__copy:hover { color: var(--text); }
}
@media (pointer: coarse) {
  .mirabel__copy { opacity: 0.45; }
}
/* Opacity is the reveal channel and colour the press one, so the two compose:
   on touch the button sits at 0.45 and darkens under the finger. */
.mirabel__copy:active { color: var(--text); }
.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; }

/* Dots beside the text (#4270 Task 5): .busy-dots inherits currentColor, so
   it renders in the same --text-light as the "Thinking…" label with no extra
   color rule. The label is real text (SR content + the reduced-motion
   fallback); the dots span is aria-hidden, decorative only. */
.mirabel__thinking {
  align-self: flex-start; color: var(--text-soft); font-size: 0.9rem; padding: 4px 8px;
  display: flex; align-items: center; gap: 0.4em;
}
.mirabel__tool-status { align-self: flex-start; color: var(--text-soft); font-size: 0.9rem; font-style: italic; padding: 4px 8px; }

/* Turn choreography (Motion Tier 3, #4270): CSS classes are safe on THIS
   surface only, because the transcript is exempt from broadcast morphs
   (turbo:before-morph-element preventDefault guard in the controller) — a
   class-driven animation elsewhere would just get reconciled away mid-flight.
   Exit vocabulary for transcript moments (removeWithFade). */
.fade-removing {
  opacity: 0;
  transform: scale(0.98);
  transition: opacity var(--duration-fast) ease, transform var(--duration-fast) ease;
}
/* Entrance for live-created transcript nodes (never applied on history replay). */
.transcript-enter { animation: bloop var(--duration-bloop) var(--ease-bloop); }

/* 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; }
/* Reaches the bare primitive (stage-import, draft-open, draft-compare, retry).
   The two modifiers that repaint this chrome — draft-workshop's red fill and
   stage-dismiss's ghost — carry their own rungs below, because they beat this
   rule on source order alone at (0,1,0) and this one scores (0,2,0). .retry is
   the control that most needs it: its stale-tap guard is a silent JS early
   return, so the press rung is the only feedback on the refused path. */
.mirabel__action:active { background: var(--rule-faint); border-color: var(--text-light); }

/* A recipe draft: a persistent card (unlike the transient tool status). Both
   kinds send you to the same place — the workshop pane — and differ only in
   how many ways in they offer. An untargeted draft carries "Open in workshop"
   as its primary (Task 5, Workshop PR 2) with "Open in editor" beside it as a
   plain secondary escape hatch; a targeted draft (a proposed edit) carries
   "Open in workshop" alone, and only on the newest such card
   (renderDraftCard's latestTargetedDraftEntry branch). This used to say the
   targeted card "offers only Preview changes" — that action retired, and the
   same stale belief is what left its replacement out of the align-self list
   below (#4562). 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-soft); }
/* Every launcher a draft card can carry, shrink-wrapped rather than stretched
   to the card. .mirabel__draft is a flex column at the default align-items:
   stretch, so a button missing from this list grows to the card's full width.
   draft-compare was that button (#4562): the list still named draft-preview,
   which retired with the "Preview changes" action and exists nowhere else.
   The two look identical whenever the button IS the card's widest child, which
   is why short fixture titles never showed it — measured on a 311px card, the
   siblings held 143px and draft-compare took 285px. */
.mirabel__draft-open, .mirabel__draft-compare, .mirabel__draft-workshop, .mirabel__retry { align-self: flex-start; }
/* The launcher reads as PRIMARY, same red-fill treatment as the workshop
   pane's own "Save to kitchen" (.mirabel__ws-action--primary above) — so the
   demoted "Open in editor" beside it reads as secondary by contrast, not just
   by word order. */
.mirabel__draft-workshop { background: var(--red-fill); border-color: var(--red-fill); color: white; }

@media (any-hover: hover) {
  .mirabel__draft-workshop:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
}
.mirabel__draft-workshop:active {
  background: var(--accent-hover); border-color: var(--accent-hover); box-shadow: var(--shadow-press);
}

/* 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 a model-driven or standalone
   block stays as an honest record: chosen label highlighted, the rest dimmed,
   all disabled. A SCRIPTED flow block (#3896) instead stays live — its taps
   cost no tokens and enter no model history, so changing your mind is free —
   and only retires to that same disabled record once a turn goes out, a new
   step supersedes it, or the user types instead. */
.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; }
/* The one control on the surface where rule 1 above cannot apply: .is-chosen
   owns `background`, the same property a press wants. So the selected variant
   gets its own rung — exactly base.css:913's .btn-pill.active:active — instead
   of a shared rule that would swap the chosen blue for a neutral tint. Both
   carry :not(:disabled): a retired options block is fully disabled, and a dead
   choice must never look pressable. */
.mirabel__option:not(:disabled):not(.is-chosen):active { background: var(--rule-faint); border-color: var(--text-light); }
.mirabel__option:disabled { cursor: default; opacity: 0.55; }
.mirabel__option.is-chosen { opacity: 1; background: var(--smart-blue-bg); font-weight: 600; }
.mirabel__option.is-chosen:not(:disabled):active { background: color-mix(in srgb, var(--smart-blue-bg) 82%, var(--smart-blue-text)); }
/* Still-tappable-chosen vs. the retired, disabled record: the border is the
   only cue that separates them, since :disabled already carries the dimming. */
.mirabel__option.is-chosen:not(:disabled) { border-color: var(--smart-blue-text); }

/* Confirm-chip details (write invariant): what a grocery/menu confirm block
   is about to authorize, rendered between the prompt and the buttons so the
   tap stays INFORMED. Recipe links reuse .mirabel__suggestion-title below —
   one visual vocabulary for "this recipe" links across the panel. */
.mirabel__confirm-items { margin: 0; padding: 0 0 0 1.2em; font-size: 0.85rem; line-height: var(--leading-body); }
.mirabel__confirm-recipes { display: flex; flex-direction: column; gap: 2px; }

/* 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-soft); padding: 2px 0; }

/* Roll-forward divider (#3896): the server starts a fresh conversation after an
   hour of quiet, which is invisible on the wire and invisible in a transcript
   that keeps rendering. This is the only place the panel admits the model's
   memory ended here — a ruled line, quieter than an event note, because it
   reports a boundary rather than something that happened.

   A rule ABOVE the caption, not flanking it: the panel is ~370px wide and this
   sentence wraps to two lines there, which collapses centre-flanked rules to
   zero width and leaves it reading as an ordinary event note. */
.mirabel__divider { align-self: stretch; border-top: 1px solid var(--rule);
  margin-top: 6px; padding-top: 6px; text-align: center;
  font-size: 0.78rem; line-height: var(--leading-body); color: var(--text-soft); }

/* 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; }
/* Text dialect (base.css:780). The panel renders after </main>, so base.css's
   `main a` rules never reach these links — they need their own rung. */
.mirabel__suggestion-title:active { color: var(--red); }
.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:not(:disabled):active { background: var(--rule-faint); border-color: var(--text-light); }
.mirabel__suggestion-add:disabled { cursor: default; opacity: 0.55; }

/* 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-soft); 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); }
/* Deliberately borderless, so it presses in the ghost dialect (a tint, no
   border move) rather than inheriting the primitive's bordered rung. */
.mirabel__stage-dismiss:active { background: var(--rule-faint); color: var(--text); border-color: transparent; }
.mirabel__stage-source { font-size: 0.75rem; color: var(--text-soft); margin: 0;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* 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-fill badge — 0.7rem text needs the 4.5:1 small-text floor, not
 * the 3:1 large-text allowance the old comment here claimed (#4513). */
.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-fill);
  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; }

/* up/down carry the guard because they genuinely disable at the ends of the
   strip (image_picker_controller.js); remove never does, so it takes the rung
   unguarded rather than a cargo-culted :not(). */
.thumb-up:not(:disabled):active,
.thumb-down:not(:disabled):active,
.thumb-remove:active {
  background: var(--rule-faint);
  border-color: var(--text-light);
}

.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; }
@media (any-hover: hover) {
  .mirabel__attach:hover { color: var(--text); }
}
.mirabel__attach:active { color: var(--text); background: var(--rule-faint); }

.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; }
/* Had no hover rung either, so this is the chip's first pointer state of any
   kind — the #4514 census could not see it, having enumerated :hover selectors. */
.mirabel__chip:active { background: var(--rule-faint); border-color: var(--text-light); }

/* 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 panel's primary action, and it had no pointer state at all. A filled
   control presses by darkening its own fill, the way .btn-primary:active does. */
.mirabel__send:active { background: color-mix(in srgb, var(--positive) 82%, black); }

/* 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.

   Its two links DO need a rule, and the comment here used to claim otherwise
   ("they keep their plain inherited treatment"). There is no inherited
   treatment to keep: `main a` is the app's only global link rule and the panel
   renders after </main>, so both links shipped in UA blue — rgb(0,0,238) light,
   rgb(158,158,255) dark, measured — inside a warm-neutral panel (#4560). This
   is `main a`'s idiom — body colour, one step brighter than the muted sentence
   around it, plus an underline in that same colour.

   Two other treatments were measured and rejected against the card, because on
   0.66rem text the contrast floor is 4.5:1 and this card has little headroom:
     - the panel's own .mirabel__msg red (var(--red)): 5.38:1 light but only
       3.34:1 DARK — and the UA blue it replaced was 6.21:1 dark, so shipping
       red would have made these links harder to read than the bug did.
     - body colour with a --rule underline: the underline came out 1.22:1, so
       a link coloured like its own sentence had no perceptible cue at all.
   var(--text) clears both problems: 13.11:1 light and 10.38:1 dark, and the
   underline rides currentColor so the affordance is as legible as the word.

   The note itself uses --text-soft: 4.97:1 light and 5.15:1 dark against the
   card, clearing WCAG AA while keeping the disclosure visually subordinate to
   ordinary transcript copy (#4574). At 0.75rem (13.5px on the app's 18px
   root), it remains fine print without repeating the old 0.66rem readability
   problem. Offset is 2px, not main's 3px, because the type is small. */
.mirabel__disclaimer-note { font-size: 0.75rem; line-height: 1.4; color: var(--text-soft); text-align: center; }
.mirabel__disclaimer-note p { margin: 0; }
.mirabel__disclaimer-note a {
  color: var(--text);
  text-decoration: underline;
  text-decoration-thickness: 1px;
  text-underline-offset: 2px;
  transition: color var(--duration-normal) ease;
}


/* ── 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. display:flex/column (the workshop,
     spec §1) stacks the header, tab bar, and whichever region is visible
     top-to-bottom. */
  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;
  display: flex; flex-direction: column;
}

/* Cramped tabs (spec §1 Mobile): Chat | Workshop replace the desktop two-pane
   grid. data-region (mirabel_workspace_controller.js) picks which region
   shows; both regions stay in the DOM (morph-exempt by construction). */
body:has(.navshell[data-cramped="true"]) .mirabel__tabs { display: flex; }
body:has(.navshell[data-cramped="true"]) .mirabel__panel .mirabel__chat { display: flex; flex-direction: column; min-height: 0; flex: 1; }
body:has(.navshell[data-cramped="true"]) .mirabel__panel[data-region="workspace"] .mirabel__chat { display: none; }
body:has(.navshell[data-cramped="true"]) .mirabel__panel[data-region="workspace"] .mirabel__workspace {
  display: block; flex: 1; min-height: 0; overflow-y: auto;
}

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