/* ============================================================
   MURMUR — STYLES
   Lifted from the two sketches, reconciled to the Decision Log
   conventions (spec §5). Token layers:
     · Systemic  — canvas, sentiment scale, AI signature. Mechanical
                   chrome pulls these.
     · Brand     — driven by the body class (spec §6). User-visible
                   brand accents pull --brand-accent, nothing else.
   No italics anywhere. svh + safe-area for multiple surfaces.
   ============================================================ */

/* ============================================================
   TYPE — Sofia Pro, self-hosted (assets/fonts). The family ships
   Light / Regular / Bold / Black; no Medium or Semibold. Each face
   claims a weight RANGE so the stylesheet's semantic weights resolve
   without synthesis:
     300       → Light    (voice line — display air)
     400–500   → Regular  (body + quiet UI: clock, pills, actions)
     600–700   → Bold     (labels, usernames, metric values)
     800–900   → Black    (unused today; registered for hero moments)
   Italic faces exist in assets/fonts but stay unregistered — the
   header convention above says no italics anywhere.
   ============================================================ */
@font-face {
  font-family: 'Sofia Pro';
  src: url('assets/fonts/sofia-pro-light.otf') format('opentype');
  font-weight: 200 300;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'Sofia Pro';
  src: url('assets/fonts/sofia-pro-regular.otf') format('opentype');
  font-weight: 400 500;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'Sofia Pro';
  src: url('assets/fonts/sofia-pro-bold.otf') format('opentype');
  font-weight: 600 700;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'Sofia Pro';
  src: url('assets/fonts/sofia-pro-black.otf') format('opentype');
  font-weight: 800 900;
  font-style: normal;
  font-display: swap;
}

:root {
  /* ---- §10.1 / addendum 51: THE SCALE TOKENS ---- */
  /* Every font-size below derives from one of three factors, so the whole
     interface steps up together and the measure (characters per line) holds
     constant by construction — the drawer's width scales on the SAME factor
     as its type. Deliberately width-branched: the one carve-out from §9's
     capability-not-width rule, written down in §10.1.
     --ui-scale        chrome, drawer, cards, buttons — the interface
     --headline-scale  the composition's anchor; steps steeper (addendum 48's
                       phone-sized headline reads stranded inside rings that
                       grew, so the anchor grows faster than body type)
     --content-scale   set by the ENGINE (max(1, fitScale)) on #app — tooltip
                       type and bubble labels, pinned to the formation. The
                       :root value is only the pre-boot fallback. */
  --ui-scale: 1;
  --headline-scale: 1;
  --content-scale: 1;

  /* ---- Systemic: dark canvas floor ---- */
  --galaxy: #0D1117;
  --ring: #64748B;                        /* contrast floor on the dark canvas */
  --card-bg: #172033;
  --card-border: #475569;
  --card-highlight: rgba(100, 116, 139, 0.5);
  /* ---- Peek tooltip geometry: ONE declaration for the coupled set
     (spec §4.6, addendum 14 keeps this consolidation). The pin length,
     the nub's height and base, and the tooltip's border-radius must agree;
     engine.js reads these once at init and never re-declares them. A
     geometry constant living in two files is a bug with a delay on it —
     this is where it lives. The pin and the nub are TWO numbers and are
     allowed to differ (§4.6): the nub spans PART of the pin and stops
     short of the bubble on purpose. That air is clearance, not a gap to
     close (addendum 14 withdrew the "make it touch" change) — the tail is
     a finger pointing at a storm, not a stick poking it. ---- */
  --peek-gap: 10px;        /* bubble edge -> box edge; the fixed pin */
  --peek-nub-h: 5px;       /* nub spans half the pin, apex stops ~5px clear of the bubble */
  --peek-nub-base: 10px;   /* full triangle base width */
  --peek-radius: 8px;      /* tooltip corner; the tail root must clear this + half the nub base */
  --card-divider: #2D3B50;
  --text-primary: #F9FAFB;
  --text-body: #D1D5DB;
  --text-muted: #94A3B8;

  /* ---- Systemic: sentiment scale (bubbles + temp bar share this) ----
     Warming reads true orange (never yellow, never coral); unhealthy is
     a bright pure red. Amber stays the human-role caution color only. */
  /* THE SENTIMENT SCALE — one declaration (§11, addendum 20). Cool / Calm /
     Warming / Overheating. engine.js reads these four at init and builds
     SENT_BAND_COLORS and the temp-dot lerp from them, so the bubble, the
     temperature bar, and the Negative metric can never diverge — the same
     single-source pattern PEEK uses. rgb() (not hex) so the engine can parse
     them and sentGlow can add alpha; identical colors, so this is a pure
     refactor with zero visual change. */
  --s-blue:   rgb(59, 130, 246);   /* Cool */
  --s-orange: rgb(249, 115, 22);   /* Warming — the sentiment "amber" (§11) */
  --s-red:    rgb(255, 59, 48);    /* Overheating */
  /* Calm green left the palette with the word (addendum 52): three bands, and
     green was sentiment-only, so nothing on the scale reads it now. */
  --s-amber: #F59E0B;              /* ROLE color (human / caution) — NOT the sentiment scale */

  /* ---- Systemic: AI content signature (identical on every surface) ----
     The whole AI signature is the 05 violet now (user: match 05's purple).
     --ai-purple is the ONE source; the indigo-tier names are kept as aliases
     so every rule that already reads --ai-indigo / --ai-indigo-bright re-hues
     for free (particles, badges, borders, draft text, confidence fill). The
     old two-tier indigo (#6366F1 base / #818CF8 bright) collapses to a single
     violet, exactly as 05 runs it. The action-glow and edit sheet read the
     same token — one AI color, resting and acting alike. */
  --ai-purple: #A78BFA;                       /* THE AI color (matches 05) */
  --ai-purple-dim: rgba(167, 139, 250, 0.15);
  --ai-indigo: var(--ai-purple);              /* alias: solid accents / particles */
  --ai-indigo-bright: var(--ai-purple);       /* alias: text on the dark floor */
  --ai-bg: rgba(167, 139, 250, 0.12);
  --ai-text: rgba(167, 139, 250, 0.4);

  /* ---- Role aliases (spec §5): amber = human/caution, indigo = AI/handled.
     Everything the AI touches carries the indigo signature. Green is gone
     from the product entirely (addendum 52): it was sentiment-only, and the
     scale dropped it with the Calm band. ---- */
  --role-human: var(--s-amber);
  --role-ai: var(--ai-indigo);

  --radius: 16px;
}

/* Brand token layer (spec §6). The body class is the single brand source:
   accent + wordmark string both come from it, nothing is hardcoded in
   markup or engine templates. */
body.brand-placeholder {
  --brand-accent: #64748B;                /* neutral slate placeholder */
  --brand-name: "Your Brand";
}
body.brand-loreal {
  --brand-accent: #C5A572;                /* L'Oréal gold, user-visible accents only */
  --brand-name: "L'Oréal";
}

* { margin: 0; padding: 0; box-sizing: border-box; }

html, body {
  width: 100%;
  height: 100svh;                          /* smallest-viewport sizing */
  overflow: clip;                          /* clip, not hidden: forbids scroll
                                              anchoring from shifting the page
                                              during live window resizes */
  background: var(--galaxy);
  font-family: 'Sofia Pro', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
  user-select: none;
  touch-action: pan-y;                     /* let the browser own vertical scroll */
}

/* Layer 2 — Stage: rings + bubbles position against this, never the raw
   viewport. Capped at iPad Pro portrait, centered. Transparent so the
   Atmosphere shows through edge to edge. */
#app {
  position: relative;
  width: 100%;
  max-width: 430px;
  height: 100svh;
  margin: 0 auto;
  overflow: clip;                          /* clip, not hidden: hidden still accepts
                                              programmatic scroll (scrollIntoView walks
                                              ALL scrollable ancestors), which shifted
                                              the stage and dragged the offscreen edit
                                              sheet into view. clip forbids it. */
  background: transparent;
  z-index: 1;
}

/* ============================================================
   GALAXY LAYER
   ============================================================ */
#galaxy-layer {
  position: absolute;
  inset: 0;
  /* Opacity rides the drawer's own timing/easing (addendum 29) so the Stage
     fade and the drawer rise read as one gesture. Also used for the boot
     font-swap fade. */
  transition: transform 700ms cubic-bezier(0.16, 1, 0.3, 1), opacity 500ms cubic-bezier(0.16, 1, 0.3, 1);
  will-change: transform;
  z-index: 1;
}

/* Drawer open: the whole Stage (clusters, rings, tooltips) fades to zero and
   stops taking taps, so a tap above the drawer closes it instead of landing
   on a ghost bubble (addendum 29). Layer 1's starfield is untouched. */
#galaxy-layer.drawer-hidden { opacity: 0; pointer-events: none; }

/* (Removed addendum 32: the dead `#galaxy-layer.orbiting` CSS orbit —
   transform-origin 50% 42%, the fourth copy of the deleted 0.42 — plus its
   `.orbiting .bubble` counter-rotation and both keyframes. The `orbiting`
   class is never applied by any JS; the calm orbit is JS-driven around
   CALM.center in placeCalm. Proven dead before deletion.) */

/* Receipt foreground: the mid-layer dims to a ghost for legibility, never
   hides. Stars are Layer 1, so they keep their full brightness. */
#galaxy-layer.backdrop { opacity: 0.2; }

/* Two dashed rings (§7.1, addendum 22 — the outer ring is deleted). Size and
   left/top are set by the engine (fractions of the invisible frame, centered
   at CALM.center, §10.1); CSS keeps only the look and the centering transform.
   The width breakpoints are gone: the frame is a function of the formation, not
   of the window. */
.ring {
  position: absolute;
  border-radius: 50%;
  border: 1px dashed var(--ring);
  opacity: 0.4;
  transform: translate(-50%, -50%);
  pointer-events: none;
}
.ring-2 { opacity: 0.45; }   /* lifted — the action-glow gradient was washing the rings out */
.ring-1 { opacity: 0.6; }

/* ---- Bubbles ---- */
.bubble {
  position: absolute;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  /* Resting state is visible. The center-out entrance (spec §4) is a
     one-shot WAAPI animation from the engine — decorative motion on top,
     so a missed style recalc can never strand a bubble at scale(0). */
  transition: background-color 500ms ease-out, box-shadow 500ms ease-out,
              transform 300ms ease-out, opacity 400ms ease-out,
              left 500ms ease-out, top 500ms ease-out,
              width 500ms ease-out, height 500ms ease-out;
  will-change: transform, background-color, box-shadow;
}
.bubble.dimmed { opacity: 0.35; }
/* Queue cards outside the selected campaign hide, state intact. */
.comment-card.scoped-out { display: none; }

/* Stacking: the hot cluster (and its peek) always paints above neighbors —
   attention order, not DOM order. Cooling drops it back with the class. */
.bubble { z-index: 1; }
.bubble.hot { z-index: 3; }

.bubble-label {
  font-size: calc(9px * var(--content-scale));
  font-weight: 600;
  letter-spacing: 0.5px;
  text-transform: uppercase;
  color: var(--text-primary);
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
  pointer-events: none;
}
.bubble.hot .bubble-label { font-size: calc(11px * var(--content-scale)); }

/* Platform icon mark. The committed SVGs carry full brand colors, so they
   render through a mask with a white fill: monochrome on the bubble, the
   sentiment color stays the health read. Sized to the bubble, so it scales
   and stays centered wherever the bubble does. */
.bubble-icon {
  width: 46%;
  height: 46%;
  background: rgba(255, 255, 255, 0.95);
  -webkit-mask: var(--icon) center / contain no-repeat;
  mask: var(--icon) center / contain no-repeat;
  pointer-events: none;
}

.bubble.hot.pulsing { animation: hotPulse 2s ease-in-out infinite; }
@keyframes hotPulse {
  0%, 100% { transform: scale(1); filter: brightness(1); }
  50%      { transform: scale(1.06); filter: brightness(1.15); }
}

/* Hot cluster status ring (the "Unhealthy" halo in the PNG). */
.bubble.hot::after {
  content: '';
  position: absolute;
  inset: -10px;
  border-radius: 50%;
  border: 1px solid rgba(255, 59, 48, 0.5);
  animation: haloPulse 2s ease-in-out infinite;
  pointer-events: none;
}
@keyframes haloPulse {
  0%, 100% { transform: scale(1); opacity: 0.5; }
  50%      { transform: scale(1.12); opacity: 0.15; }
}

/* Peek tooltip — always visible until a drawer opens. */
.peek-tooltip {
  position: absolute;
  bottom: calc(100% + var(--peek-gap));
  left: 50%;
  transform: translateX(-50%);
  background: var(--card-bg);
  border: 1px solid var(--card-border);
  border-radius: var(--peek-radius);
  padding: 6px 10px;
  white-space: nowrap;
  opacity: 1;
  pointer-events: none;
  z-index: 5;
  transition: opacity 300ms, transform 500ms ease-out;
}
.peek-tooltip.hidden { opacity: 0; }
/* Cluster-scoped tooltip gate (spec §4.3, addenda 12 + 14). §4.3 draws a
   line between two moments that must not be collapsed:
   - RESTING STATE: a cluster already Cool when a view renders carries no
     label at all. `.peek-off` is display:none — no geometry, cannot paint —
     so the .hidden opacity gate (the drawer/calm reveal path) is
     structurally unable to bring it back; opacity never overrides display.
     A non-placed tooltip is therefore also a non-rendered one.
   - TRANSITION: a cluster that cools DURING a session (the user just
     cleared it, §5) is the payoff beat and must not pop. `.peek-fading`
     fades opacity to 0 on the settle's timing; only when the settle
     completes does the engine drop it to the structural `.peek-off`.
   The engine drives both from the ONE predicate `campaignView(c).sent >=
   WARM_T`, the same displayed/floored severity §4.5's headline uses, and
   gates placement off the same read. */
.peek-tooltip.peek-off { display: none; }
.peek-tooltip.peek-fading { opacity: 0; }
/* The tail is a computed consequence, never a fixed decoration: one
   triangle whose base is pinned at (--tail-x, --tail-y) on the tooltip
   edge facing its OWN bubble. --tail-a comes out axis-aligned (0/±90/180)
   as a CONSEQUENCE of cardinal-only pins (spec §4.6, addendum 13), not by
   fiat: under four cardinals the root is the facing edge's midpoint and
   the bubble sits on that edge's normal, so the true bearing to the bubble
   center is already flat. The engine still WRITES the true atan2 bearing
   and asserts it landed axis-aligned; it never snaps to a cardinal angle.
   Snapping is exactly how a tail ends up aiming at empty space the moment
   the geometry shifts, so the flat angle is derived and checked, never
   hardcoded. All three vars are derived AFTER the collision pass finalizes
   the box (placeCalm), so a shoved tooltip can never leave a stale tail
   behind. FUSED BY CONSTRUCTION (spec §4.6): the anchor values are the
   box's own geometry — 0%/100% pick the edge, calc(50% + d) slides along
   it — never raw px from an engine-assumed corner. Percentages resolve
   against the element as actually rendered, so no measurement drift
   (webfont swap, content re-render) can put the tail off its box; and
   because they resolve inside the 1px border, the base overlaps the
   border instead of seaming 1px outside it. Defaults reproduce the
   resting anchor: bottom-edge midpoint, pointing straight down. */
.peek-tooltip::after {
  content: '';
  position: absolute;
  left: var(--tail-x, 50%);
  top: var(--tail-y, 100%);
  border-style: solid;
  border-color: transparent;
  border-top-color: var(--card-border);
  border-top-width: var(--peek-nub-h);
  border-left-width: calc(var(--peek-nub-base) / 2);
  border-right-width: calc(var(--peek-nub-base) / 2);
  border-bottom-width: 0;
  transform: translateX(-50%) rotate(var(--tail-a, 0deg));
  transform-origin: 50% 0;
}
/* §51: tooltip type is BASE px on purpose — the whole tooltip box scales via
   the engine's transform (about its tail root), which carries the type with
   it. A calc() here would double-scale. Bubble LABELS do ride --content-scale:
   bubbles scale via width/height, which does not touch their label's font. */
.peek-line { font-size: 11px; color: var(--text-body); }
.peek-line strong { color: var(--text-primary); font-weight: 600; }
.peek-sub { font-size: 10px; color: var(--text-muted); margin-top: 2px; }
.peek-sentiment {
  display: inline-block;
  width: 6px; height: 6px;
  border-radius: 50%;
  margin-right: 4px;
  vertical-align: middle;
}

/* ============================================================
   LAYER 3 — CHROME (brand mark, clock, alert pill)
   Pinned to the viewport corners with safe-area padding, not to
   the Stage. Wordmark renders white/low-opacity, never full color.
   ============================================================ */
.brand-mark {
  position: fixed;
  top: calc(env(safe-area-inset-top, 0px) + 16px);
  left: calc(env(safe-area-inset-left, 0px) + 18px);
  z-index: 6;
  display: flex;
  align-items: center;
  gap: 8px;
  /* Home affordance: the wordmark restarts the app at its opening state. */
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  transition: opacity 150ms;
}
.brand-mark:hover { opacity: 0.75; }
.brand-mark-dot {
  width: 22px; height: 22px;
  border-radius: 6px;
  background: var(--brand-accent);          /* brand token */
  opacity: 0.9;
}
.brand-mark-label {
  font-size: calc(12px * var(--ui-scale));
  font-weight: 600;
  letter-spacing: 0.3px;
  color: var(--text-muted);
}
.brand-mark-label::before { content: var(--brand-name); }

/* L'Oréal mark: the real white asset (img/brand-logo-loreal-white.svg,
   committed lowercase — Vercel/Linux is case-sensitive), rendered at low
   opacity per the chrome convention. Monitoring context, not a logo drop.
   The dot and the text label yield to it; --brand-name still feeds the
   reply identity in the drafts. */
body.brand-loreal .brand-mark-dot { display: none; }
body.brand-loreal .brand-mark-label::before {
  content: '';
  display: block;
  width: 92px;
  height: 24px;                              /* 654 × 171.86 viewBox ratio */
  background: url('img/brand-logo-loreal-white.svg') left center / contain no-repeat;
  opacity: 0.82;
}

/* Campaigns scope, top-right (replaces the live clock). Dark pill,
   white label, quiet chevron — chrome, not a CTA. */
.campaigns {
  position: fixed;
  top: calc(env(safe-area-inset-top, 0px) + 14px);
  right: calc(env(safe-area-inset-right, 0px) + 16px);
  z-index: 30;
}
.campaigns-btn {
  display: flex;
  align-items: center;
  gap: 8px;
  background: #232C3F;
  border: none;
  border-radius: 18px;
  padding: 9px 14px 9px 16px;
  color: var(--text-primary);
  font-family: inherit;
  font-size: calc(13px * var(--ui-scale));
  font-weight: 500;
  letter-spacing: 0.1px;
  white-space: nowrap;                   /* selected name stays on one line */
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
/* Drawn chevron, not a glyph: the old 9px ▾ didn't read as an
   affordance. Sized to be seen and comfortably part of the tap. */
.campaigns-chevron {
  font-size: 0;                          /* hides the legacy ▾ character */
  width: 10px;
  height: 10px;
  margin-top: -4px;
  border-right: 2px solid var(--text-body);
  border-bottom: 2px solid var(--text-body);
  border-radius: 1px;
  transform: rotate(45deg);
  flex: none;
}
.campaigns-menu {
  position: absolute;
  top: calc(100% + 8px);
  right: 0;
  min-width: 224px;                       /* holds the longest name + check on one line */
  background: var(--card-bg);
  border: 1px solid var(--card-border);
  border-radius: 14px;
  padding: 6px;
  box-shadow: 0 14px 36px rgba(0, 0, 0, 0.5);
}
.campaigns-menu[hidden] { display: none; }
.campaigns-item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  width: 100%;
  min-height: 44px;                      /* touch floor */
  padding: 10px 12px;
  border: none;
  border-radius: 9px;
  background: none;
  color: var(--text-body);
  font-family: inherit;
  font-size: calc(13px * var(--ui-scale));
  font-weight: 500;
  text-align: left;
  white-space: nowrap;                   /* names never wrap to a second line */
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.campaigns-item:hover { background: rgba(100, 116, 139, 0.14); }
.campaigns-item.active { color: var(--text-primary); }
/* Trailing marks share one right-aligned slot: alert dot, then check. */
.campaigns-item .item-marks {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 6px;
  min-width: 12px;                       /* holds the column when both hide */
}
.campaigns-item .check {
  font-size: calc(12px * var(--ui-scale));
  color: var(--role-ai);            /* was sentiment green; green left the palette (§52) */
  display: none;
}
.campaigns-item.active .check { display: inline; }
/* Live-alert marker: the campaign holding a hot cluster wears the map's
   alert red, breathing on the same register as the pulsing bubble; a
   warming campaign wears the same dot in caution amber. Shared by the
   menu rows AND the collapsed pill — the dot LEADS the name in both. */
.campaigns .alert-dot {
  --dot-glow: 255, 59, 48;
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--s-red);
  animation: alertDotPulse 2.2s ease-in-out infinite;
  flex: none;
}
.campaigns .alert-dot.warm {
  --dot-glow: 249, 115, 22;
  background: var(--s-orange);
}
.campaigns .alert-dot[hidden] { display: none; }
/* Row layout: dot + name lead together; a hidden row dot keeps its slot
   so campaign names align down the list. The pill's dot collapses when
   calm — no empty gap in the chrome. */
.campaigns-item .item-lead {
  display: flex;
  align-items: center;
  gap: 8px;
}
.campaigns-item .item-lead .alert-dot[hidden] {
  display: inline-block;
  visibility: hidden;
}
@keyframes alertDotPulse {
  0%, 100% { box-shadow: 0 0 4px rgba(var(--dot-glow), 0.5); }
  50%      { box-shadow: 0 0 9px rgba(var(--dot-glow), 0.95); }
}

.alert-pill {
  position: fixed;
  top: calc(env(safe-area-inset-top, 0px) + 58px);   /* clears the campaigns pill */
  right: calc(env(safe-area-inset-right, 0px) + 16px);
  z-index: 6;
  display: flex;
  align-items: center;
  gap: 7px;
  background: var(--card-bg);
  border: 1px solid var(--card-border);
  border-radius: 20px;
  padding: 6px 12px;
  color: var(--text-body);
  font-family: inherit;
  font-size: calc(12px * var(--ui-scale));
  font-weight: 500;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.alert-pill[hidden] { display: none; }

/* Offline banner (?data=offline via js/router.js): quiet cached-render
   notice, centered in the top chrome. Amber dot = human/caution role. */
.offline-banner {
  position: fixed;
  top: calc(env(safe-area-inset-top, 0px) + 16px);
  left: 50%;
  transform: translateX(-50%);
  z-index: 6;
  display: flex;
  align-items: center;
  gap: 7px;
  background: var(--card-bg);
  border: 1px solid var(--card-border);
  border-radius: 20px;
  padding: 6px 12px;
  font-size: calc(11px * var(--ui-scale));
  font-weight: 500;
  letter-spacing: 0.3px;
  color: var(--text-muted);
  pointer-events: none;
}
.offline-banner[hidden] { display: none; }
.offline-banner-dot {
  width: 6px; height: 6px;
  border-radius: 50%;
  background: var(--s-amber);
}
.alert-pill-dot {
  width: 6px; height: 6px;
  border-radius: 50%;
  background: var(--s-red);
}

/* ============================================================
   GALAXY HEADLINE — the map's plain-language read (Murmur onboarding
   convention). Sits at the top of the Stage, above the galaxy,
   hidden while the drawer or the payoff owns the story.
   ============================================================ */
/* Positioned via left 50% + translateX in BOTH slots (top and centered)
   so every property on the move interpolates — no right:auto jump. */
#headline {
  position: absolute;
  top: calc(env(safe-area-inset-top, 0px) + 64px);
  left: 50%;
  width: 100%;
  z-index: 2;
  text-align: center;
  padding: 0 28px;
  pointer-events: none;
  opacity: 0;
  transform: translate(-50%, -8px);
  transition: opacity 500ms ease,
              transform 500ms cubic-bezier(0.16, 1, 0.3, 1),
              top 500ms cubic-bezier(0.16, 1, 0.3, 1),
              width 500ms cubic-bezier(0.16, 1, 0.3, 1),
              padding 500ms cubic-bezier(0.16, 1, 0.3, 1);
}
/* §10.1 / addendum 45: the focused headline is the top of the RIGID STACK. It
   sits at its chrome-clearance top (CSS `top`) and the engine slides it DOWN by
   --stack-shift so the copy welds to the galaxy at the tether gap, the pair
   centered as a unit. The shift rides the same transform transition, so a wrap
   change glides the whole stack rather than snapping. */
#headline.on { opacity: 1; transform: translate(-50%, var(--stack-shift, 0px)); }

/* All-calm composition: the reassurance takes the earned center of the
   orbit — CALM.center, the SAME value the rings read — and the healthy
   clusters ring it; the engine solves the radius so nothing ever touches
   the block. `top` is set inline by placeCalm from CALM.center.y (§4.2 /
   addendum 30); the old `top: 42%` here was the magic 0.42 addendum 20
   deleted from the rings, still stranding the headline one layer over.
   The move is the closing settle, so it gets earned timing (spec §5),
   not a UI snap. --calm-shift carries the drawer compression.
   §4.2 (addendum 48): the width is a FIXED comfortable size that fills most of
   the inner ring at phone dimensions — it is NOT measured to the ring by the
   engine (the circumscribed-circle fit is gone). Short copy sits inside the
   inner ring; longer copy overlaps a decorative ring, which is allowed. */
#headline.centered {
  width: calc(142px * var(--headline-scale));
  padding: 0;
  transform: translate(-50%, calc(-50% + var(--calm-shift, 0px))) scale(var(--calm-scale, 1));
  transition: opacity 500ms ease,
              transform 1800ms cubic-bezier(0.16, 1, 0.3, 1),
              top 1800ms cubic-bezier(0.16, 1, 0.3, 1),
              width 1800ms cubic-bezier(0.16, 1, 0.3, 1),
              padding 1800ms cubic-bezier(0.16, 1, 0.3, 1);
}

/* Offscreen measuring twin for the calm solver — never painted. */
.calm-probe {
  position: absolute;
  left: -9999px; top: 0;
  width: 220px;
  padding: 0;
  visibility: hidden;
  transition: none !important;
}

.no-anim, .no-anim * { transition: none !important; }
.headline-line {
  font-size: calc(21px * var(--headline-scale));
  font-weight: 600;
  letter-spacing: 0.2px;
  line-height: 1.25;
  color: var(--text-primary);
}
.headline-sub {
  font-size: calc(13px * var(--ui-scale));
  line-height: 1.5;
  color: var(--text-muted);
  margin: 8px auto 0;
  max-width: calc(300px * var(--ui-scale));
}
/* §4.2 (addendum 31): the centred block wraps balanced — no word orphaned to
   a line of its own — and the offscreen measuring probe wraps identically, so
   the height the engine measures is the height that paints. One rule, both. */
#headline.centered .headline-line,
#headline.centered .headline-sub,
.calm-probe .headline-line,
.calm-probe .headline-sub { text-wrap: balance; }

/* ============================================================
   SUMMARY DRAWER
   ============================================================ */
#drawer {
  position: absolute;
  bottom: 0; left: 0; right: 0;
  /* One detent, opens FULL (addendum 28): covers most of the galaxy but
     never all of it — the top margin keeps the galaxy edge peeking so
     spatial context survives. The HALF height (62svh) is deleted. */
  height: calc(100svh - env(safe-area-inset-top, 0px) - 56px);
  background: var(--card-bg);
  border: 1px solid var(--card-border);
  border-bottom: none;
  border-radius: var(--radius) var(--radius) 0 0;
  z-index: 10;
  transform: translateY(100%);
  visibility: hidden;                    /* engine flips it on open/close */
  transition: transform 500ms cubic-bezier(0.16, 1, 0.3, 1);
  will-change: transform;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}
#drawer::before {
  content: '';
  position: absolute;
  top: 0; left: 0; right: 0;
  height: 1px;
  background: var(--card-highlight);         /* top-edge highlight */
  z-index: 1;
}
#drawer.open { transform: translateY(0); }

/* §5 meter-recalibration beat (addendum 24): from the instant the queue empties
   until closeDrawer() runs, the whole sheet is inert. pointer-events is an
   inherited property and nothing inside the drawer overrides it, so `none` here
   makes EVERY descendant compute `none` — the sent card, a Tier 2 row, the
   chooser, or any stale handler. No tap can commit anything during the beat;
   it's impossible, not avoided. The sheet still shows the recalibrated meter,
   it just can't be acted on. Lifted in closeDrawer()/openDrawer(). */
#drawer.settling { pointer-events: none; }

.drawer-handle-area {
  padding: 12px 0 8px;
  cursor: grab;
  touch-action: none;                    /* the drag is ours, not the scroller's */
  flex-shrink: 0;
  min-height: 40px;
  display: flex;
  align-items: center;
  justify-content: center;
}
.drawer-handle {
  width: 36px; height: 4px;
  background: var(--card-divider);
  border-radius: 2px;
}

.drawer-header {
  padding: 4px 20px 12px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-shrink: 0;
}
.drawer-topic { display: flex; align-items: center; gap: 8px; }
/* Channel identity is a logo-and-name lockup (§7.3, addendum 39): the mark on
   the left, the channel name on its right, one horizontal unit. .drawer-topic
   above centers them and holds the gap. NO status dot — it duplicated the
   bubble's colour and misled in All Campaigns where the aggregate is floored.
   The mark is the same img/ asset the bubble uses, masked white so it reads as
   identity, not severity. */
.topic-logo {
  width: 22px; height: 22px;
  background: var(--text-primary);
  -webkit-mask: var(--icon) center / contain no-repeat;
  mask: var(--icon) center / contain no-repeat;
  flex: none;
}
.topic-name { font-size: calc(16px * var(--ui-scale)); font-weight: 600; color: var(--text-primary); }
.drawer-status { display: flex; align-items: center; gap: 12px; flex-shrink: 0; }
/* Freshness line: live relative time, dark-canvas muted floor. Sentence
   case on purpose — status prose, not a label (reference card). */
.drawer-fresh {
  font-size: calc(11px * var(--ui-scale));
  font-weight: 400;
  color: var(--text-muted);
}
/* The alert drawer's trend chip (▲/▼ %). The severity-word variants
   (.calm/.warm/.cool) were deleted with the calm-header pill — §7.3 /
   addendum 44 — the bubble colour and the temperature meter carry the band. */
.drawer-trend {
  font-size: calc(12px * var(--ui-scale));
  font-weight: 600;
  color: var(--s-red);
  background: rgba(255, 59, 48, 0.12);
  padding: 3px 9px;
  border-radius: 10px;
  flex-shrink: 0;
}

/* Zero-exception read (calm drawer, nothing recent): gentle, not a void. */
.calm-empty {
  /* Centre the message between the metrics rule above and the log-header rule
     below. The band is lopsided by STRUCTURE, not by this element: only 4px of
     .queue padding-top sits above the message, but .log-header's 22px margin-top
     sits below it. Bias the vertical padding down by exactly that 18px difference
     (39 vs 21, same 60px total) and the text lands centred. Every term is fixed
     px — text height is the only thing --ui-scale moves, and it grows symmetrically
     about the centre — so this holds at 1.0, 1.15, and 1.3 alike, no magic offset. */
  padding: 39px 20px 21px;
  text-align: center;
  font-size: calc(13px * var(--ui-scale));
  color: var(--text-muted);
}

/* ---- Sentiment temperature bar ---- */
.temp-wrap { padding: 20px 20px 10px; flex-shrink: 0; }
.temp-bar {
  position: relative;
  height: 6px;
  border-radius: 3px;
  /* Segmented, not a gradient (criterion 7, addendum 52). paintTempTrack()
     writes the three solid zones with hard seams at WARM_T/HOT_T, single-
     sourced from the engine's thresholds so a 62%/88% literal never drifts
     here against them. Neutral fallback until JS paints. */
  background: var(--card-divider);
}
.temp-dot {
  position: absolute;
  top: 50%;
  width: 14px; height: 14px;
  border-radius: 50%;
  background: var(--s-red);
  border: 2px solid var(--card-bg);
  box-shadow: 0 0 8px rgba(0, 0, 0, 0.5);
  transform: translate(-50%, -50%);
  transition: left 500ms cubic-bezier(0.16, 1, 0.3, 1), background-color 500ms ease-out;
  left: 92%;
}
.temp-scale {
  /* Positioning context: paintTempTrack() places the three labels at their
     zone boundaries (0 / WARM_T / HOT_T), so a label turns up where its color
     begins on the track. Height holds the row now that spans are absolute. */
  position: relative;
  height: calc(11px * var(--ui-scale));
  margin-top: 6px;
  font-size: calc(9px * var(--ui-scale));
  letter-spacing: 0.4px;
  color: var(--text-muted);
}
.temp-scale span { white-space: nowrap; }

/* ---- Three metrics with micro-viz ----
   Reference card anatomy: uppercase muted label, then value + trailing
   qualifier on the baseline, then the micro-viz. Columns distribute as
   equal thirds, each left-aligned inside its third. */
.metrics-row {
  display: flex;
  justify-content: space-between;
  gap: 14px;
  padding: 10px 20px 20px;
  flex-shrink: 0;
}
/* Column flex so every micro-viz bottom-aligns: the sparkline is the
   tallest viz and sets the row height; track and tri drop to its floor. */
.metric { flex: 1; min-width: 0; text-align: left; display: flex; flex-direction: column; }
/* Value and unit align at the TOP, not the baseline: on a narrow surface
   (iPhone 17 Pro is the floor) the unit wraps to two lines, and baseline
   alignment dropped that second line below the whole metric. Top-aligned,
   a wrapped unit stacks beside the number within its height instead. The
   unit's padding-top seats a single line optically against the number. */
/* Title label on top, value below (enlarged for prominence). The unit
   sublabels were deleted — the title names the metric, the number carries
   it, and the micro-viz shows the shape. */
.metric-top { margin-top: 3px; }
.metric-value { font-size: calc(28px * var(--ui-scale)); font-weight: 700; line-height: 1.05; color: var(--text-primary); }
.metric-value-neg { color: var(--s-red); }       /* counts the tri-split's red slice */
/* Voice Confidence's value carries its own bar's colour (§11, addendum 55). The
   SAME token .fill-ai fills the bar with (--role-ai = AI indigo), so number and
   bar are one reading in one colour — not a new hex. Negative does this in JS
   because its colour is data-driven; Confidence's bar is a static token, so the
   value is too. Volume stays white: a plain count, no semantic scale. */
.metric-value-ai { color: var(--role-ai); }
.metric-label {
  font-size: calc(10px * var(--ui-scale));
  font-weight: 600;
  letter-spacing: 0.9px;
  line-height: 1.25;
  text-transform: uppercase;
  color: var(--text-muted);
  /* Reserve two lines so a label that wraps on a narrow surface ("NEGATIVE
     SENTIMENT") doesn't push its value below the others — every number
     starts on the same line regardless of label length. */
  min-height: 2.5em;
}
.metric-spark { display: block; width: 100%; max-width: 170px; height: 22px; margin-top: 7px; overflow: visible; }
.metric-track {
  position: relative;               /* anchors the Negative bar's threshold mark */
  width: 100%;
  max-width: 150px;
  height: 4px;
  border-radius: 2px;
  background: var(--card-divider);
  margin-top: auto;                 /* bottom-aligns with the sparkline */
  overflow: hidden;
}
.metric-fill { height: 100%; border-radius: 2px; width: 0; transition: width 600ms cubic-bezier(0.16, 1, 0.3, 1); }
.fill-ai  { background: var(--role-ai); }       /* AI confidence = indigo */
/* The Negative bar (criterion 16, addendum 19): a single LINEAR fill on
   .metric-track / .metric-fill, sibling to Confidence, filling to neg% so it
   agrees with its own label. Fill color and value color are the cluster's own
   severity color (sentColor(v.sent), §3.3) — a Cool 3% reads blue, not red.
   The track is a two-zone gradient (NEG_TRACK_BG), set inline by the engine:
   dark neutral up to the Overheating threshold, then a faint danger-red wash,
   so the permanently-empty top of the axis says "here is how much worse this
   gets" instead of reading as plain progress. The old tri-red / amber / green
   split is gone: two of its three segments charted data that never existed. */
/* The Overheating threshold TICK (addendum 19, Phase 2C): a mark ON the track,
   never a cap on the fill. Full track height, a neutral muted colour that is
   never the fill colour and never off-white, drawn OVER the fill (z-index) so a
   short fill reads as "line on the track it hasn't reached" and an Overheating
   fill is seen crossing it. Its left is set inline by the engine (NEG_OVERHEAT). */
.metric-mark {
  position: absolute;
  top: 0; bottom: 0;
  width: 2px;
  border-radius: 1px;
  background: var(--text-muted);    /* neutral: not the fill colour, not off-white */
  z-index: 1;
}
/* Divider under the stats row (reference card). */
.metrics-rule {
  height: 1px;
  background: var(--card-divider);
  margin: 0 20px;
  flex-shrink: 0;
}

/* ---- AI summary hero line ---- */
.ai-summary {
  margin: 5px 24px 30px;
  padding: 12px 14px;
  background: var(--ai-bg);
  border-radius: 10px;
  display: flex;
  gap: 9px;
  flex-shrink: 0;
}
.ai-glyph {
  color: var(--ai-indigo-bright);
  font-size: calc(18px * var(--ui-scale));
  /* Not a ratio. The glyph's line box is pinned to the BODY's first line box
     (13px x 1.5), so the star stays optically centred on line one no matter
     what size the star itself grows to. A ratio here would push the row
     taller every time the glyph got bigger. */
  line-height: calc(19.5px * var(--ui-scale));
  flex-shrink: 0;
  text-shadow: 0 0 8px rgba(167, 139, 250, 0.8);   /* the sparkle glows */
}
.ai-summary-text {
  font-size: calc(13px * var(--ui-scale));
  line-height: 1.5;
  color: var(--text-body);
}
/* The actionable count — "N need your eyes" — is emphasised wherever it appears,
   using the same <strong> idiom as .peek-line / .campaign-row-read: semibold and
   lifted to primary so the number the user must act on stands out of the muted
   sub and body-weight AI line. Weight-only, so no line-box change / no reflow. */
.headline-sub strong,
.ai-summary-text strong {
  font-weight: 600;
  color: var(--text-primary);
}

.drawer-rule {
  height: 1px;
  background: var(--card-divider);
  margin: 0 20px;
  flex-shrink: 0;
}

/* ---- Review queue ---- */
.queue-header {
  padding: 14px 20px 8px;
  font-size: calc(11px * var(--ui-scale));
  font-weight: 600;
  letter-spacing: 0.6px;
  text-transform: uppercase;
  color: var(--text-muted);
  flex-shrink: 0;
}
.queue-count { color: var(--text-muted); font-weight: 500; }
/* Tier-1 header count is dropped — the header is now the swipe instruction, and
   the count lives on the galaxy headline + AI summary. The element stays in the
   DOM (updateQueueCount still writes it, harmlessly); only the Tier-1 instance
   is hidden. The Tier-2 log's class-based count is untouched. */
#queue-count { display: none; }

/* Scrollable drawer body: one scroll surface for the whole summary read —
   header, temp bar, metrics, summary, queue. Only the handle stays pinned.
   overscroll-behavior keeps the scroll from leaking to the galaxy behind. */
.drawer-body {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  overscroll-behavior: contain;
}

.queue { padding: 4px 20px 8px; }

.comment-card {
  padding: 12px 0;
  border-bottom: 1px solid var(--card-divider);
  transition: opacity 400ms ease-out;
  /* Swipe host: pan-y so a vertical drag still scrolls the drawer while the
     JS claims the horizontal axis (§7.3 swipe). */
  position: relative;
  touch-action: pan-y;
}
.comment-card:last-child { border-bottom: none; }
/* §7.3 mouse safety net: the whole card is the drag surface on a fine pointer,
   so the whole card invites the grab (the .ai-draft rule already does this
   everywhere; this extends it to the card only where a mouse exists). */
@media (hover: hover) and (pointer: fine) {
  .comment-card { cursor: grab; }
  .comment-card:active { cursor: grabbing; }
}

/* §7.3 verdict beat — 05's .swipe-confirm, ported whole. The verification of
   an approve/dismiss carries NO color: an off-white label, centered, holding
   the flown card's footprint for CONFIRM_HOLD so the user reads what they did
   in the space where they did it. Direction drives only the drift-in side,
   echoing the gesture without re-stating it. Entry is a keyframe, not a
   class-toggled transition (05's backgrounded-tab lesson: an animation runs on
   its own clock; worst case it plays unseen rather than never playing). */
.swipe-confirm {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  padding: 20px 0;
  color: rgba(245, 245, 245, 0.72);
  font-size: calc(13px * var(--ui-scale));
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  border-bottom: 1px solid var(--card-divider);   /* holds the row rhythm the card kept */
  animation: swipe-confirm-in 0.18s cubic-bezier(0.33, 0, 0.2, 1) both;
}
.swipe-confirm.approved  { --confirm-from: 10px; }
.swipe-confirm.dismissed { --confirm-from: -10px; }
@keyframes swipe-confirm-in {
  from { opacity: 0; transform: translateX(var(--confirm-from, 0px)); }
  to   { opacity: 1; transform: translateX(0); }
}
/* Sealed for the beat: no second gesture can stack a verdict mid-hold. */
#queue.beat-locked { pointer-events: none; }
/* (Verdict tint deleted — user: NO color anywhere in the approve/dismiss
   verification. The gesture's feedback is the card's motion; the verdict is
   the off-white .swipe-confirm beat. The ::after overlay, --swipe-tint, and
   the green/red writes in renderCardSwipe are all gone together — owner code
   removes the whole family, not just the visible member.) */
/* A resolved card slides + fades and the queue re-renders without it (finality
   by removal). The button path nudged 28px; the swipe path flies it the full
   gesture direction via inline transform, which overrides this. */
.comment-card.leaving {
  opacity: 0;
  transform: translateX(28px);
  transition: opacity 200ms ease, transform 200ms ease;
  pointer-events: none;
}

/* Avatars are gone (single-letter color bubbles carried no information the
   @username doesn't); the content column takes the full card width. */
.comment-user { display: flex; align-items: flex-start; }
.comment-content { flex: 1; min-width: 0; }
.comment-meta { display: flex; align-items: center; gap: 6px; margin-bottom: 3px; }
.comment-username { font-size: calc(12px * var(--ui-scale)); font-weight: 600; color: var(--text-primary); }
.comment-time { font-size: calc(10px * var(--ui-scale)); color: var(--text-muted); }
.comment-text {
  font-size: calc(13px * var(--ui-scale));
  line-height: 1.5;
  color: var(--text-body);
  /* Addendum 51 belt-and-braces: ch IS the measure, so this is a guarantee,
     not an arithmetic relationship that drifts if someone edits the width
     and not the type (or vice versa). */
  max-width: 68ch;
}
.comment-social { display: flex; gap: 14px; margin-top: 6px; }
.social-stat { font-size: calc(10px * var(--ui-scale)); color: var(--text-muted); display: flex; align-items: center; gap: 5px; }
/* Icons are SVG, not font glyphs. ♥ and ↪ are both dual-presentation
   codepoints: iOS drew ↪ as a boxed emoji, then (pinned to text with FE0E)
   as a glyph half the heart's optical size. Font metrics we don't own are
   not a size we can spec, so the marks come out of the font entirely.
   1.4em keeps them tied to the label's type scale — one number, both icons. */
.social-ico { width: 1.4em; height: 1.4em; flex: none; fill: currentColor; }

/* AI draft reply — muted signature weight, clearly uncommitted
   (annotation 7 / "prefilled is not chosen"). No italics. */
.ai-draft {
  margin: 10px 0 0;                          /* the 42px indent aligned under the deleted avatar column */
  padding: 10px 12px;
  background: var(--ai-bg);
  border-radius: 10px;
  /* Fine-pointer affordance: the card is the swipe surface (approve/dismiss),
     so the draft invites the grab. Children with their own cursor (the Edit
     button's pointer) still win while over them; grabbing while held. */
  cursor: grab;
}
.ai-draft:active { cursor: grabbing; }
/* The draft's only header now: the AI badge, top-left. Brand identity is
   dropped — the user knows whose dashboard they're on (avatar + name removed). */
.ai-draft-header { display: flex; align-items: center; margin-bottom: 6px; }
/* AI badge: a pill, not a whisper — and a BADGE, not a rounded box. Every
   dimension below is em (i.e. a multiple of the pill's own type), so the
   9px→14.2px ui-scale step at 1440 carries the padding and the radius up with
   it. The old fixed 3px/8px/8px was the squeeze: type grew 1.58×, the box
   didn't, and the corners stayed a fraction of what they'd been at 1×.
   Radius 999px keeps the ends true semicircles at every scale. */
.ai-tag {
  display: inline-flex;
  align-items: center;
  gap: 0.45em;                       /* label-to-mark air, formerly a text space */
  font-size: calc(9px * var(--ui-scale));
  font-weight: 700;
  letter-spacing: 0.09em;            /* was 0.8px — em so tracking scales too */
  text-transform: uppercase;
  color: #C7CEFF;
  background: rgba(167, 139, 250, 0.22);
  border: 1px solid rgba(167, 139, 250, 0.55);
  border-radius: 999px;
  /* Optically symmetric, not numerically: the uppercase label carries a
     trailing letter-space on its last glyph, so the right side gets that 0.09em
     back to make the two ends of the pill read equal. */
  padding: 0.45em 0.76em 0.45em 0.85em;
  flex-shrink: 0;
}
/* Own line box so the label's tracking and baseline can't reach the mark — and
   optically centred, which is NOT what align-items:center gives an all-caps
   label. Sofia Pro Bold: ascent 750, descent 250, cap 689 per 1000 upm. At
   line-height 1 the half-leading is zero, so the box centres at baseline+250
   while the CAPS centre at 689/2 = 344.5 — the ink rides 0.0945em high, and no
   descender ever fills the space it leaves below. Flex centred the box exactly
   right; the box just isn't where the letters are. This pushes the ink back
   onto the pill's true centre line, which is where the star already sits. */
.ai-tag-label {
  line-height: 1;
  transform: translateY(0.095em);
}
/* The star is the AI mark — drawn, never a font glyph (see ICON_AI_STAR: the
   ✦ char fell to a different fallback per platform, so the same declaration
   rendered fat on macOS and thin on iOS). Sized in em off the pill, so one
   number holds the mark-to-cap ratio identical at every ui-scale. Same bright
   indigo + glow as the summary sparkle, so the mark reads as one thing in
   both places. */
.ai-tag-star {
  width: 0.85em;                     /* ≈ 1.15× the label's cap height */
  height: 0.85em;
  flex: none;
  fill: var(--ai-indigo-bright);
  filter: drop-shadow(0 0 3px rgba(167, 139, 250, 0.85));
}
.ai-draft-text {
  font-size: calc(12px * var(--ui-scale));
  max-width: 68ch;                           /* addendum 51: measure guarantee, see .comment-text */
  line-height: 1.5;
  color: var(--text-primary);                /* white: legible over the violet action wash (was indigo-on-violet) */
  outline: none;
}
.ai-draft-text.editing {
  color: var(--text-body);
  background: rgba(255, 255, 255, 0.03);
  border: 1px dashed var(--brand-accent);
  border-radius: 6px;
  padding: 8px;
  min-height: 46px;
  caret-color: var(--text-primary);
}

/* Inline Edit — the one per-card control, riding the bottom-right of the draft
   box (05 consistency). A muted pencil affordance, not a bordered button:
   Dismiss/Approve are the swipe, so Edit shouldn't read as one of three verbs. */
.ai-draft-tools {
  display: flex;
  justify-content: flex-end;
  margin-top: 8px;
}
/* Edit affordance — pencil AFTER the label (§11: pill buttons are label then
   icon; addendum 54). One glyph for the Edit action everywhere: the ✎ (U+270E)
   Unicode char in the inherited app font, the SAME source the master CTA's
   "Edit All Drafts ✎" uses — not a second, heavier Material Symbols pencil
   that could drift from it again. Icon sizing below mirrors the master's exactly
   (both buttons are 12px type); the icon inherits this control's own muted
   colour, precisely as the master's ✎ inherits its button colour. */
.ai-draft-edit {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: 2px 0;
  /* Tap target held at its pre-54 height: the glyph shrank from an 18px Material
     icon to the master CTA's 12px ✎, but the hit area must not (addendum 54 keeps
     the target). = the old (18px icon + 2px×2) box; content re-centres inside it. */
  min-height: calc(18px * var(--ui-scale) + 4px);
  font-family: inherit;
  font-weight: 600;
  color: rgba(255, 255, 255, 0.5);
  background: transparent;
  border: none;
  cursor: pointer;
  line-height: 1;
  -webkit-tap-highlight-color: transparent;
  transition: color 150ms;
}
.ai-draft-edit:hover { color: rgba(255, 255, 255, 0.85); }
.ai-draft-edit:active { color: rgba(255, 255, 255, 0.85); }
.ai-draft-edit .btn-icon {
  font-size: calc(12px * var(--ui-scale));   /* = master CTA's icon size (its 12px button type) */
  line-height: 1;
}
.ai-draft-edit-label {
  font-size: calc(12px * var(--ui-scale));
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
}
/* (.confirm is retired — Edit now opens the edit sheet; SEND lives on the
   sheet's Save button, not a second tap on this pencil. Rule kept inert.) */
.ai-draft-edit.confirm { color: var(--role-ai); }

/* ===== Edit sheet (ported from 05's reply-sheet) =====
   The deliberate correction surface. Opens OVER the drawer with a blurred
   backdrop so the draft is the only thing in focus — it replaces the inline
   dashed-outline editor. The sheet is scoped inside #app (position:absolute)
   so it aligns to the column, but the backdrop is fixed: the dim covers the
   whole viewport, gutters included — no ancestor of #app forms a containing
   block, so fixed here really is the viewport. The AI-action violet accents
   it, tying it to the same beat as the action glow. */
.edit-sheet-backdrop {
  position: fixed;
  inset: 0;
  z-index: 40;
  background: rgba(0, 0, 0, 0.5);
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.2s ease;
}
.edit-sheet-backdrop.open {
  opacity: 1;
  pointer-events: auto;
}

.edit-sheet {
  position: absolute;
  bottom: 0; left: 0; right: 0;
  z-index: 41;
  background: #141b2b;                    /* a touch above --card-bg, so it reads as lifted over the drawer */
  border-top: 1px solid var(--card-border);
  border-top-left-radius: 22px;
  border-top-right-radius: 22px;
  padding: 12px 24px 24px;
  padding-bottom: calc(24px + env(safe-area-inset-bottom));
  max-height: 72%;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  transform: translateY(100%);
  /* Same hygiene as #drawer: a closed sheet can never paint, whatever layout
     shifts do to its percentage transform. Engine flips it on open/close. */
  visibility: hidden;
  /* transform carries the slide + (desktop) horizontal centering; the drag
     writes the independent `translate` property so the two never fight. */
  transition: transform 0.28s cubic-bezier(0.4, 0, 0.2, 1),
              translate 0.28s cubic-bezier(0.4, 0, 0.2, 1);
  box-shadow: 0 -12px 40px rgba(0, 0, 0, 0.5);
  touch-action: pan-y;
}
.edit-sheet.open { transform: translateY(0); }

.edit-sheet-handle {
  width: 36px;
  height: 4px;
  border-radius: 2px;
  background: rgba(255, 255, 255, 0.18);
  margin: 0 auto 16px;
}

/* Original comment context — read-only, the thing the reply answers. Muted so
   the editable draft stays the focus, but present so the edit is never blind. */
.edit-sheet-context {
  background: rgba(255, 255, 255, 0.03);
  border: 1px solid var(--card-divider);
  border-radius: 12px;
  padding: 10px 12px;
  margin-bottom: 16px;
}
.edit-sheet-context-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 8px;
  margin-bottom: 5px;
}
.edit-sheet-context-user {
  font-size: calc(12px * var(--ui-scale));
  font-weight: 700;
  color: var(--text-body);
}
.edit-sheet-context-text {
  font-size: calc(12px * var(--ui-scale));
  line-height: 1.5;
  color: var(--text-muted);
}

.edit-sheet-label {
  font-size: calc(10px * var(--ui-scale));
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--ai-purple);               /* the AI-action beat's own color */
  margin-bottom: 8px;
}
/* The original-comment label is the matched-pair sibling (§11, addendum 56): it
   reuses .edit-sheet-label wholesale, so size/weight/case/letterspacing/offset
   match "Your Reply" by construction. .plain overrides the ONE thing that must
   NOT carry across — the AI signature — dropping the indigo for muted (and the
   markup omits the ✦). A human's words never wear the AI author mark. */
.edit-sheet-label.plain {
  color: var(--text-muted);
}

.edit-sheet-text {
  font-size: calc(13px * var(--ui-scale));
  line-height: 1.5;
  color: var(--text-primary);
  width: 100%;
  min-height: 132px;
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid rgba(255, 255, 255, 0.12);
  border-radius: 12px;
  padding: 12px 14px;
  font-family: inherit;
  resize: vertical;
  outline: none;
  box-sizing: border-box;
  transition: border-color 0.15s ease;
}
.edit-sheet-text:focus { border-color: var(--ai-purple); }

.edit-sheet-actions {
  display: flex;
  gap: 10px;
  margin-top: 14px;
}
.edit-sheet-btn {
  flex: 1;
  padding: 13px 16px;
  border-radius: 12px;
  border: none;
  font-family: inherit;
  font-size: calc(14px * var(--ui-scale));
  font-weight: 700;
  cursor: pointer;
  letter-spacing: 0.01em;
  transition: transform 0.1s ease, background 0.2s ease, opacity 0.2s ease;
}
.edit-sheet-btn:active { transform: scale(0.98); }
.edit-sheet-btn.close {
  background: rgba(255, 255, 255, 0.06);
  color: var(--text-primary);
}
.edit-sheet-btn.save {
  background: var(--ai-purple);
  color: #1A1030;                         /* dark ink on the light violet for contrast */
}
/* Save stays locked until the user taps into the draft to edit it (openEditSheet
   disables it; the textarea's focus/input listener unlocks). 05 parity. */
.edit-sheet-btn.save:disabled {
  opacity: 0.4;
  cursor: not-allowed;
  transform: none;
}

/* Per-comment actions — neutral, equal weight, no red-vs-green bias. */
.comment-actions { display: flex; gap: 8px; margin: 10px 0 0 42px; }
.comment-action {
  padding: 7px 12px;
  border-radius: 8px;
  border: 1px solid var(--card-border);
  background: transparent;
  color: var(--text-body);
  font-family: inherit;
  font-size: calc(11px * var(--ui-scale));
  font-weight: 500;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  transition: background 150ms, border-color 150ms;
}
.comment-action:active { background: rgba(255, 255, 255, 0.05); }
.comment-action .btn-icon { margin-left: 4px; }
/* Edit's second tap is SEND (§7.4): the confirm styling marks the armed state. */
.comment-action.confirm { border-color: var(--role-ai); color: var(--role-ai); }

/* Calm read: AI-handled item (read-only, already committed). */
.handled-note {
  font-size: calc(11px * var(--ui-scale));
  color: var(--text-muted);
  padding: 10px 0 2px;
}

/* ---- Drawer footer actions + Approve All confirm (§7.4 + addendum 35) ---- */
/* The Approve All confirm is the bar transforming in place, not a modal. The
   confirm copy lives INSIDE the bar (a flex column), so when it expands the
   bar grows and the flex scroll body above shrinks by exactly the same amount
   — the sum is constant and nothing is ever occluded. */
.drawer-actions {
  display: flex;
  flex-direction: column;
  padding: 12px 20px calc(env(safe-area-inset-bottom, 0px) + 14px);
  border-top: 1px solid var(--card-divider);
  background: var(--card-bg);             /* opaque bar — nothing ghosts through it */
  flex-shrink: 0;
  transition: opacity 200ms ease, transform 200ms ease, border-color 200ms ease;
}
.drawer-actions-row { display: flex; gap: 8px; }
/* The confirm copy: collapsed to zero height in the default state, animating
   open (bar grows upward) when the bar enters .confirming. overflow:hidden +
   an animated max-height/padding is what makes the grow read as motion. */
.confirm-copy {
  max-height: 0;
  opacity: 0;
  overflow: hidden;
  transition: max-height 240ms cubic-bezier(0.16, 1, 0.3, 1),
              opacity 160ms ease, padding-bottom 240ms cubic-bezier(0.16, 1, 0.3, 1);
}
.drawer-actions.confirming .confirm-copy {
  max-height: 96px;
  opacity: 1;
  padding-bottom: 12px;                   /* the gap to the button row, revealed with the grow */
}
.confirm-copy-title { font-size: calc(14px * var(--ui-scale)); font-weight: 700; color: var(--text-primary); }
.confirm-copy-sub { font-size: calc(11px * var(--ui-scale)); color: var(--text-muted); margin-top: 4px; line-height: 1.45; }
/* §11 carve-out (addendum 35): on a CONTROL, red is irreversibility, not
   Overheating — grammar separates them. The signal rides the SEND button, not
   the container: its border goes danger red (--s-red = #FF3B30), and its fill
   is that same red at the SAME 12%-over-card alpha the primary AI fill uses
   (#282D4B is violet 12%; #332333 is red 12%), precomposited opaque so nothing
   ghosts through the drawer. Cancel stays neutral on the left. */
.drawer-actions.confirming .drawer-action.primary {
  border-color: var(--s-red);
  background: #332333;                    /* --s-red at 12% over --card-bg (matches the indigo alpha) */
  color: var(--text-primary);
}
/* Structural hide — display beats the UA [hidden] rule, so say it here. */
.drawer-actions[hidden] { display: none; }
/* The commitment beat: the chosen path replaces the chooser. Decorative —
   the structural hide lands on the engine's fixed timeout, not on this. */
.drawer-actions.leaving { opacity: 0; transform: translateY(10px); pointer-events: none; }
/* CTA fills are OPAQUE — the same perceived colors as the old rgba
   overlays, precomposited over --card-bg (#172033), so nothing behind
   the drawer can ever ghost through the buttons. */
.drawer-action {
  flex: 1;
  padding: 12px 0;
  border-radius: 10px;
  border: 1px solid var(--card-border);
  background: var(--card-bg);
  color: var(--text-body);
  font-family: inherit;
  font-size: calc(12px * var(--ui-scale));
  font-weight: 500;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  transition: background 150ms, opacity 150ms;
}
.drawer-action:active { background: #232B3D; }   /* = white 5% over card */
/* Desktop keeps the mark at label size — a fine pointer is up close to a big
   surface and the glyph reads fine. Touch gets it enlarged; see the coarse
   block near the bottom of the file. */
.drawer-action .btn-icon { margin-left: 4px; }
/* Primary = the send action. Violet (AI role), the one intentional weighting. */
.drawer-action.primary { border-color: var(--role-ai); background: #282D4B; color: var(--text-primary); }  /* = violet (AI role) 12% over card */
.drawer-action[disabled] { opacity: 0.4; pointer-events: none; }

/* ============================================================
   LAYER 1 — ATMOSPHERE (starfield + gradient glow)
   Fixed, full viewport, behind everything. The glow mirrors the
   Murmur onboarding nebula: near-neutral cool brightness patches, not
   color (05-murmur-onboarding galaxy.js is the reference render).
   ============================================================ */
#sky-layer {
  position: fixed;
  inset: 0;
  z-index: 0;
  overflow: hidden;
  background:
    radial-gradient(circle at 22% 28%, rgba(120, 132, 165, 0.08), transparent 62%),
    radial-gradient(circle at 78% 58%, rgba(90, 100, 130, 0.06), transparent 55%),
    radial-gradient(circle at 50% 85%, rgba(70, 78, 105, 0.05), transparent 50%),
    var(--galaxy);
}

/* AI GLOW — Layer 1b, ATMOSPHERE. A bottom-anchored violet bloom that sits
   ABOVE the starfield and BEHIND everything else, and continues UNDERNEATH the
   drawer. FIXED and FULL-BLEED (100vw x 100svh) as a sibling of #app:
   atmosphere is shared and edge to edge on every surface and NEVER letterboxes
   inside the Stage cap (§1, §7.1, criterion 22). It was previously a child of
   #app, which made it inherit the 1024px cap — that was the letterbox bug.

   UNCONDITIONAL. No gate on severity, campaign, state, or work: it is present
   and identical on every campaign and in every state, Clear Skies and the
   payoff included. Calm is signalled by the CLUSTERS cooling to blue (Layer 2),
   which is where a signal belongs; the atmosphere just stays beautiful under
   it. There is no JS counterpart — no class to toggle, nothing to keep in sync.

   Pulse is OPACITY ONLY (Decision Log "Full-bleed background effects"):
   scaling a bottom-anchored gradient slides its bands off their seat. */
#action-glow {
  position: fixed;
  inset: 0;
  width: 100vw;
  height: 100svh;
  z-index: 0;                              /* above #sky-layer (z0, earlier in DOM), below the Stage (z1) */
  pointer-events: none;
  opacity: 1;
  animation: ai-action-breathe 3600ms ease-in-out infinite;
  background: radial-gradient(
    ellipse 148% 94% at 50% 100%,
    color-mix(in srgb, var(--ai-purple) 50%, transparent) 0%,
    color-mix(in srgb, var(--ai-purple) 36%, transparent) 28%,
    color-mix(in srgb, var(--ai-purple) 21%, transparent) 52%,
    color-mix(in srgb, var(--ai-purple) 9%, transparent) 72%,
    transparent 88%
  );
}
@keyframes ai-action-breathe {
  0%, 100% { opacity: 1; }
  50%      { opacity: 0.62; }
}
@media (prefers-reduced-motion: reduce) {
  #action-glow { animation: none; opacity: 1; }
}
.star {
  position: absolute;
  border-radius: 50%;
  opacity: 0;
  will-change: opacity;
}
.star.on { animation: starReveal 800ms ease-out forwards, twinkle var(--tw, 3s) ease-in-out infinite 800ms; }
@keyframes starReveal { from { opacity: 0; } to { opacity: var(--so, 0.6); } }
@keyframes twinkle { 0%, 100% { opacity: var(--so, 0.6); } 50% { opacity: calc(var(--so, 0.6) * 0.35); } }

.ai-particle {
  position: absolute;
  border-radius: 50%;
  background: var(--ai-indigo);
  opacity: 0;
  will-change: opacity, transform;
}
.ai-particle.on { animation: pReveal 1200ms ease-out forwards, pDrift var(--dd, 6s) ease-in-out infinite 1200ms; }
@keyframes pReveal { from { opacity: 0; transform: scale(0.5); } to { opacity: 0.6; transform: scale(1); } }
@keyframes pDrift {
  0%   { transform: translate(0, 0); }
  25%  { transform: translate(var(--a, 4px), var(--b, -6px)); }
  50%  { transform: translate(var(--c, -3px), var(--d, -2px)); }
  75%  { transform: translate(var(--e, 5px), var(--f, 4px)); }
  100% { transform: translate(0, 0); }
}

/* ---- Voice ---- */
/* Voice line — the payoff's plain-language read. Sits at the ring center
   — CALM.center, the SAME value the rings read (§4.2, addendum 32); `top`
   is set inline by placeCalm from CALM.center.y, like the headline. The old
   `top: 42%` here was the third copy of addendum 20's deleted 0.42. The
   healthy clusters circle the words: the calm IS the message's frame. The
   block caps narrow enough to stay inside every cluster's orbit radius. */
#voice {
  position: absolute;
  left: 50%;
  width: 200px;
  transform: translate(-50%, calc(-50% - 8px));
  text-align: center;
  z-index: 4;
  opacity: 0;
  transition: opacity 800ms ease-out, transform 800ms ease-out;
  pointer-events: none;
}
#voice.on { opacity: 1; transform: translate(-50%, -50%); }
.voice-line {
  font-size: calc(21px * var(--headline-scale));
  font-weight: 600;
  letter-spacing: 0.2px;
  line-height: 1.25;
  color: var(--text-primary);
}
.voice-sub {
  font-size: calc(13px * var(--ui-scale));
  line-height: 1.5;
  color: var(--text-muted);
  margin: 8px auto 0;
  max-width: 180px;
}

/* ============================================================
   BEAT LABEL + FOOTER MARK
   ============================================================ */
/* Footer signature: the Murmur mark, white at 35% — the chrome
   convention (low-opacity, never full color, never competing). The
   committed asset is the pink waveform (img/Murmur.svg), so white
   comes from the mask + fill treatment and the asset stays untouched.
   Static chrome: sits centered directly above the copyright line,
   no interaction, no animation. */
#footer-mark {
  position: absolute;
  bottom: 30px; left: 50%;
  transform: translateX(-50%);
  width: 30px; height: 19px;   /* 377x235 source ratio, signature-small */
  background: #fff;
  opacity: 0.35;
  -webkit-mask: url('img/Murmur.svg') no-repeat center / contain;
  mask: url('img/Murmur.svg') no-repeat center / contain;
  /* Below the drawer (z 10): the mark is galaxy-floor chrome and never
     floats over the action bar. #beat-label sits at the same floor. */
  z-index: 5;
  pointer-events: none;
}
#beat-label {
  position: absolute;
  bottom: 10px; left: 50%;
  transform: translateX(-50%);
  font-size: calc(9px * var(--ui-scale));
  letter-spacing: 1.5px;
  text-transform: uppercase;
  color: var(--text-muted);
  opacity: 0.4;
  /* Galaxy-floor chrome, BELOW the drawer (z 10): the copyright shows only
     when the drawer is down. When it's open, the opaque drawer + action bar
     cover it — the footer never reads through the CTA. */
  z-index: 5;
  pointer-events: none;
  white-space: nowrap;
}

/* ============================================================
   CAPABILITY-AWARE INPUT (spec §4)
   Hover + keyboard are additive on fine pointers; touch stays
   first-class on coarse. Neither is ever required to finish.
   ============================================================ */

/* 44px minimum touch targets on coarse pointers. */
@media (pointer: coarse) {
  .drawer-action { min-height: 44px; }
  /* The CTA mark, enlarged for touch only. Same glyphs — arm's length on a
     handheld eats the detail that a desktop viewing distance keeps. em-based
     so it tracks the button's 12px label; line-height 0 so the taller glyph
     can't push against the 44px target above it. */
  .drawer-action .btn-icon {
    margin-left: 5px;
    font-size: 1.45em;
    line-height: 0;
    vertical-align: -0.08em;
  }
  /* The per-card Edit mark takes the SAME touch enlargement as the master CTA
     above — one glyph, one affordance, so touch must not leave one of them at
     desktop size while the other grows. Written as an explicit 12px base rather
     than 1.45em because this control (unlike .drawer-action) sets no font-size
     of its own for an em to resolve against; the product is identical. Gap goes
     to 5px to match the master's margin-left for the same reason. */
  .ai-draft-edit { gap: 5px; }
  .ai-draft-edit .btn-icon {
    font-size: calc(12px * var(--ui-scale) * 1.45);
    line-height: 0;
    vertical-align: -0.08em;
  }
  .comment-action { min-height: 44px; }
  .alert-pill { min-height: 40px; }
  .drawer-handle-area { min-height: 48px; }
}

/* Hover affordances — fine pointer only, so a tap never triggers them. */
@media (hover: hover) and (pointer: fine) {
  .bubble.entered:hover { filter: brightness(1.12); }
  .bubble.entered:not(.hot):hover { transform: scale(1.09); }
  .alert-pill:hover { border-color: var(--text-muted); }
  .drawer-action:hover { background: #232B3D; }          /* = white 5% over card */
  .drawer-action.primary:hover { background: #252D55; }  /* = indigo 18% over card */
  .drawer-actions.confirming .drawer-action.primary:hover { background: #412532; }  /* = red 18% over card */
  .comment-action:hover { border-color: var(--text-muted); }
}

/* Keyboard focus ring (galaxy is tab-navigable; Enter/Space open, Esc closes). */
.bubble:focus-visible { outline: 2px solid var(--text-primary); outline-offset: 5px; }
.drawer-action:focus-visible,
.comment-action:focus-visible,
.alert-pill:focus-visible { outline: 2px solid var(--text-primary); outline-offset: 2px; }

/* ============================================================
   RESPONSIVE LADDER (spec §5) — mobile-first, reflow not rewrite.
   Same DOM at every rung; only layout properties change.
   Phone is the baseline above. Tablet and desktop layer on top.
   ============================================================ */

/* ---- Tablet and up (≥700px): Stage caps at iPad Pro portrait (1024px),
   centered. Only the Atmosphere extends past it. The drawer stays a bottom
   sheet on every surface — Stage-constrained and centered, same gesture,
   same axis. Desktop gets no layout of its own, it just displays well. ---- */
@media (min-width: 700px) {
  #app { max-width: 1024px; }

  /* Bottom sheet keeps the touch model but centers so line lengths stay readable. */
  #drawer {
    left: 50%; right: auto;
    /* Addendum 51: the drawer widens on the SAME factor as its type, so the measure
       (characters per line) is invariant by construction. */
    width: min(calc(560px * var(--ui-scale)), 88%);
    /* height inherits the base full-detent calc (addendum 28) */
    transform: translate(-50%, 100%);
  }
  #drawer.open { transform: translate(-50%, 0); }

  /* Edit sheet tracks the drawer's column exactly — same width, same center,
     so it reads as the tray's own surface, never a full-viewport takeover. */
  .edit-sheet {
    left: 50%; right: auto;
    width: min(calc(560px * var(--ui-scale)), 88%);
    transform: translate(-50%, 100%);
  }
  .edit-sheet.open { transform: translate(-50%, 0); }
}



/* ---- Addendum 51: the scale steps. The one deliberate width branch (S10.1's
   carve-out from S9's capability-not-width rule): a large screen genuinely is
   further from the eye and genuinely has room. Two steps, coarse on purpose --
   a continuous vw-driven scale would make phone guarantees unprovable. Type,
   drawer, and cards all ride --ui-scale together so the measure holds; the
   headline is the composition's anchor and steps steeper (addendum 48's
   phone-sized headline would read stranded inside rings that grew). ---- */
@media (min-width: 1200px) {
  :root { --ui-scale: 1.45; --headline-scale: 1.65; }
}
@media (min-width: 1600px) {
  :root { --ui-scale: 1.58; --headline-scale: 1.9; }
}

/* ---- Reduced motion: compose straight into layouts, no big moves.
   The engine also skips the ambient orbit under this preference. ---- */
@media (prefers-reduced-motion: reduce) {
  #headline, #headline.centered, #voice, .bubble, .peek-tooltip, #galaxy-layer {
    transition-duration: 0.01ms !important;
  }
}

/* ---- Triage drawer (Mode B): by-campaign breakdown rows ----
   Each row is a handoff — tapping routes the dropdown into that
   campaign's lens (Mode A), where the queue can act. ---- */
.campaign-row {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  min-height: 52px;
  padding: 12px 14px;
  margin-bottom: 8px;
  background: var(--card-bg);
  border: 1px solid var(--card-divider);
  border-radius: 12px;
  color: var(--text-body);
  font-family: inherit;
  font-size: calc(13px * var(--ui-scale));
  text-align: left;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  transition: border-color 200ms ease, background-color 200ms ease;
}
.campaign-row:active { background: rgba(71, 85, 105, 0.25); }
@media (hover: hover) and (pointer: fine) {
  .campaign-row:hover { border-color: var(--card-border); }
}
.campaign-row-dot {
  width: 8px; height: 8px;
  border-radius: 50%;
  flex: none;
}
.campaign-row-name {
  font-weight: 600;
  color: var(--text-primary);
  flex: 1;
}
.campaign-row-read {
  font-size: calc(11px * var(--ui-scale));
  color: var(--text-muted);
  white-space: nowrap;
}
.campaign-row-read strong { color: var(--text-body); font-weight: 600; }
.campaign-row-chevron {
  color: var(--text-muted);
  font-size: calc(16px * var(--ui-scale));
  line-height: 1;
  flex: none;
}

/* ============================================================
   VOLUME FEED — Tier 2, the log. Lighter rows than the actionable
   Tier 1 cards: this is browse, not work. Handled items carry the
   actual sent reply inline at committed (full-legibility) weight —
   never draft weight, drafts don't appear in the log.
   ============================================================ */
.metric-tappable { cursor: pointer; -webkit-tap-highlight-color: transparent; }
.metric-tappable .metric-value { text-decoration: underline dotted rgba(148, 163, 184, 0.55); text-underline-offset: 4px; }

.log-header {
  font-size: calc(12px * var(--ui-scale));
  font-weight: 600;
  letter-spacing: 0.8px;
  text-transform: uppercase;
  color: var(--text-muted);
  margin: 22px 0 10px;
  padding-top: 16px;
  border-top: 1px solid var(--card-divider);
}
.log-card {
  padding: 12px 2px;
  border-bottom: 1px solid var(--card-divider);
}
/* Aggregate feed only: a comment is a doorway to its campaign (§7.2). Tappable
   affordance — a fine-pointer hover tint and a pointer cursor; the tap itself
   routes (engine), it does not act. */
.log-tappable { cursor: pointer; border-radius: 8px; }
.log-tappable:hover { background: rgba(100, 116, 139, 0.10); }
.log-tappable:focus-visible { outline: 2px solid var(--role-ai); outline-offset: 2px; }
/* The routed comment's brief "this is the one you followed" flash (§7.2,
   addendum 40) — an indigo ring that fades, no layout shift, no edit mode. */
.comment-flash { animation: commentFlash 1.6s ease-out; border-radius: 8px; }
@keyframes commentFlash {
  0%, 12% { box-shadow: 0 0 0 2px var(--role-ai), 0 0 20px 2px rgba(167, 139, 250, 0.45); }
  100%    { box-shadow: 0 0 0 0 rgba(167, 139, 250, 0); }
}
.log-meta {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-bottom: 6px;
}
.log-user { font-size: calc(12px * var(--ui-scale)); font-weight: 600; color: var(--text-body); }
.log-time { font-size: calc(11px * var(--ui-scale)); color: var(--text-muted); flex: 1; }
.log-badge {
  font-size: calc(10px * var(--ui-scale));
  font-weight: 600;
  letter-spacing: 0.3px;
  padding: 3px 8px;
  border-radius: 9px;
  white-space: nowrap;
}
.log-badge.ai   { color: var(--ai-indigo-bright); background: var(--ai-bg); }
.log-badge.you  { color: var(--s-amber); background: rgba(245, 158, 11, 0.12); }
.log-badge.raw  { color: var(--text-muted); background: rgba(100, 116, 139, 0.14); }
.log-badge.eyes { color: var(--s-amber); background: rgba(245, 158, 11, 0.16); }
.log-text { font-size: calc(13px * var(--ui-scale)); line-height: 1.5; color: var(--text-body); }
/* The sent reply, committed weight: fully legible indigo, a quiet
   left rule — "here's what got said," not a draft awaiting judgment. */
.log-reply {
  margin-top: 7px;
  padding: 2px 0 2px 10px;
  border-left: 2px solid var(--ai-indigo);
  font-size: calc(12.5px * var(--ui-scale));
  line-height: 1.5;
  color: var(--text-body);
}
.log-reply.you { border-left-color: var(--s-amber); }
.log-reply-who {
  display: block;
  font-size: calc(10px * var(--ui-scale));
  font-weight: 600;
  letter-spacing: 0.4px;
  color: var(--ai-indigo-bright);
  margin-bottom: 2px;
}
.log-reply.you .log-reply-who { color: var(--s-amber); }
