/* ================================================================== */
/* Mivia Sign - design tokens + base components                       */
/*                                                                    */
/* Follows the brand guidelines exactly:                              */
/*   Palette  : Chartreuse #D4E030 · Ink #0A0A0A · Bone #F0EEE9 ·     */
/*              White #FFFFFF                                         */
/*   Primary  : Plus Jakarta Sans (Regular/Medium/SemiBold only -     */
/*              never Light 300, never Bold 700)                      */
/*   Secondary: Geist Mono - labels, metadata, timestamps, technical  */
/*              readouts. Always ALL CAPS. Always Medium 500.         */
/*              Never used for body copy or large sizes.              */
/*                                                                    */
/* Default theme is LIGHT - bone ground, ink text. Dark surfaces      */
/* exist for hero cards + the plugin, not for the whole app.          */
/* ================================================================== */

/*
  Fonts are self-hosted from /brand/fonts/*.woff2 rather than
  loaded from Google Fonts. Three reasons:
    1. No DNS + TLS hop to fonts.gstatic.com on first paint -
       shaved ~120-250ms on cold loads.
    2. The woff2 URLs are stable under our control, so we can
       <link rel="preload"> them in Base.astro with matching
       URLs and be sure the fetcher + stylesheet match (Google
       rotates hashed URLs between versions, which breaks
       preload hint matching).
    3. Both files are tiny - Plus Jakarta Sans is a single
       variable woff2 covering 400/500/600 (~27KB), Geist Mono
       500 (~15KB). Preloaded, they usually arrive before
       first paint, so there's no FOUT at all on a warm
       connection.

  font-display: swap keeps the fallback visible if the real
  font is slow; the metric-matched fallback declarations below
  make the swap dimensionless so there's no layout shift even
  if the fallback does get painted for a frame.
*/
@font-face {
  font-family: "Plus Jakarta Sans";
  font-style: normal;
  font-weight: 200 800;  /* variable font covers full weight range */
  font-display: swap;
  src: url("/brand/fonts/plus-jakarta-sans-latin.woff2") format("woff2-variations"),
       url("/brand/fonts/plus-jakarta-sans-latin.woff2") format("woff2");
}
@font-face {
  /* Variable Geist Mono - declares the full wght axis
     (100–900) so font-weight transitions interpolate
     smoothly. Used by the certificate's "How it works"
     hover state, which animates 500 → 600. The static
     500-only file shipped previously is replaced; the
     variable file is ~31KB vs ~15KB but unlocks the smooth
     animation and any future weight needs without adding
     more @font-face declarations. */
  font-family: "Geist Mono";
  font-style: normal;
  font-weight: 100 900;
  font-display: swap;
  src: url("/brand/fonts/geist-mono-variable-latin.woff2") format("woff2-variations"),
       url("/brand/fonts/geist-mono-variable-latin.woff2") format("woff2");
}

/*
  Metric-matched fallbacks. The ascent / descent / line-gap /
  size-adjust numbers make "-fallback" occupy exactly the same
  line box as the real face, so when the real font swaps in
  there's zero layout shift (no CLS).

  Plus Jakarta Sans vs Arial - derived via capsize methodology:
    size-adjust     = xHeight(PJS) / xHeight(Arial)      = 102.4%
    ascent-override = ascent(PJS)  / (upm × size-adjust) =  99.6%
    descent-override= descent(PJS) / (upm × size-adjust) =  39.6%
    line-gap-override= 0 (PJS has zero typographic line-gap)

  The previous values (descent 23%, size-adjust 102%) had the
  descent number ~17pp too low, so the fallback box was
  noticeably shorter than the real face - that's where the
  tiny layout jolt on first paint was coming from.
*/
@font-face {
  font-family: "Plus Jakarta Sans-fallback";
  src: local("Arial");
  ascent-override: 99.6%;
  descent-override: 39.6%;
  line-gap-override: 0%;
  size-adjust: 102.4%;
}
@font-face {
  font-family: "Geist Mono-fallback";
  src: local("Menlo"), local("Monaco"), local("Consolas");
  ascent-override: 100.1%;
  descent-override: 25.4%;
  line-gap-override: 0%;
  size-adjust: 100.4%;
}

:root {
  /* Brand palette */
  --chartreuse:       #D4E030;
  --chartreuse-hover: #C2CE25;  /* one notch darker for interaction */
  --ink:              #0A0A0A;
  --bone:             #F0EEE9;
  --white:            #FFFFFF;

  /* Tokens (component-level). Keep these mapped to the brand
     primitives so a future theme flip is a one-file edit. */
  --bg:              var(--bone);      /* page ground */
  --surface:         var(--white);     /* raised cards */
  --surface-sunken:  #E6E3DD;          /* hover / inset */
  --fg:              var(--ink);       /* body text */
  --fg-dim:          rgba(10, 10, 10, 0.58);  /* secondary text */
  --fg-faint:        rgba(10, 10, 10, 0.38);  /* tertiary / disabled */
  --accent:          var(--chartreuse);
  --accent-hover:    var(--chartreuse-hover);
  --danger:          #E83D35;          /* from the "Offline Mode" plugin card */
  --border:          rgba(10, 10, 10, 0.12);
  --border-strong:   rgba(10, 10, 10, 0.22);
  --radius:          14px;
  --radius-sm:       8px;
  --radius-lg:       20px;

  /* Type - real face first, metric-matched fallback second,
     then system fonts. While Google Fonts is loading, the
     fallback paints in identical metrics so the swap is
     imperceptible. */
  --font:       "Plus Jakarta Sans", "Plus Jakarta Sans-fallback",
                -apple-system, BlinkMacSystemFont, "Inter",
                system-ui, sans-serif;
  --font-mono:  "Geist Mono", "Geist Mono-fallback",
                ui-monospace, SFMono-Regular, "SF Mono",
                Menlo, Consolas, monospace;

  /* Height of the sticky/fixed navigation bar. Exposed so page
     layouts can account for it (body padding, hero math, anchor
     scroll-margin). Updated per breakpoint below. */
  --nav-h: 56px;

  /* Site-wide content rail. Every page wrapper (hero inner,
     landing-section, main, library-wrap, plug-section-inner)
     resolves to the same content edge so vertical scroll across
     the site feels like one column. `--rail` is the inner content
     max-width; `--rail-x` is the horizontal padding around it.
     For one-layer wrappers (max-width + padding on the same
     element) use `max-width: calc(var(--rail) + 2 * var(--rail-x))`.
     For two-layer wrappers (outer bg/padding + inner div) put
     `max-width: var(--rail)` on the inner. */
  --rail:    1100px;
  --rail-x:  clamp(24px, 5vw, 64px);

  /* Contribution-heatmap ramp on the /projects page. Five fixed
     levels - empty + four chartreuse stops - bucketed in
     heatmap.js by fixed thresholds (1/3/6/10+). L0 reuses the
     existing surface-sunken bone tint so empty days read as
     part of the page, not as data. L3 is the brand chartreuse
     itself; L4 is one notch deeper for the heaviest days. */
  --heat-l0: #E6E3DD;
  --heat-l1: #EFF2C8;
  --heat-l2: #E0E580;
  --heat-l3: #D4E030;
  --heat-l4: #A8B324;
}
@media (max-width: 720px) {
  :root { --nav-h: 58px; }
  html, body { font-size: 17px; line-height: 1.5; }
  h1 { font-size: clamp(2rem, 8vw, 2.5rem); line-height: 1.08; letter-spacing: -0.022em; }
  h2 { font-size: clamp(1.5rem, 6vw, 1.875rem); line-height: 1.15; }
  h3 { font-size: 1.0625rem; line-height: 1.3; }
  .mono, [data-mono] { font-size: 0.8125rem; letter-spacing: 0.05em; }
  main { padding: 48px var(--rail-x) 64px; }
  .mivia-header-link, .primary-button, .accent-button,
  .secondary-button, .reset-button, .drop-cta {
    min-height: 44px; padding-block: 12px;
  }
}
/* Apple-style adaptive landscape on phone-class viewports.
   We do NOT block landscape (iOS Safari can't lock orientation
   from a regular tab anyway). Compress vertical chrome so the
   page stays usable when the device is rotated. Bound:
   phone-class width AND landscape AND short viewport — excludes
   iPad split-view which can be landscape but is taller. */
@media (max-width: 932px) and (orientation: landscape) and (max-height: 500px) {
  :root { --nav-h: 44px; }
  main { padding-top: 24px; padding-bottom: 32px; }
  h1 { font-size: clamp(1.5rem, 6vh, 2rem); line-height: 1.05; }
  h2 { font-size: clamp(1.25rem, 4.5vh, 1.5rem); }
  .drop-page { min-height: auto; }
}

* { box-sizing: border-box; }

/* ==============================================================
   Focus rings — best-practice global gating.

   Browser-default :focus rings appear on EVERY focus event,
   including mouse clicks, which paints jarring black outlines
   around any clickable element after activation. The fix is the
   modern :focus-visible split:

     - :focus:not(:focus-visible) → mouse-induced focus.
                                    Suppress outline.
     - :focus-visible             → keyboard-induced focus
                                    (Tab / Shift+Tab / arrow
                                    keys in some widgets).
                                    Show a branded ring so
                                    keyboard users can still
                                    track their position.

   Components that explicitly set `outline: none` on
   :focus-visible (e.g. .mivia-header-link, .field input) keep
   their custom focus styling — the cascade still wins for those
   per-component rules. Everything else (small ad-hoc buttons
   like .ce-remove, links inside cards, etc) gets a clean
   chartreuse ring on Tab and nothing on click.
   ============================================================== */
:focus:not(:focus-visible) { outline: none; }
/* Site-wide: kill the focus-visible outline entirely. The
   chartreuse 2px ring was getting overridden by ink
   2px rings on a handful of per-component rules
   (.lib-seg-btn, .library-filter-toggle, lib-seg-btn:focus-
   visible etc.) AND iOS Safari was layering its own
   webkit-focus-ring on top, producing the heavy black frame
   the user was seeing around random pills. !important
   defeats those component-level overrides without needing
   to chase each one individually. */
:focus-visible {
  outline: none !important;
}
/* Kill iOS Safari's grey tap-highlight rectangle on every
   interactive element so taps register cleanly without the
   momentary translucent flash. */
* {
  -webkit-tap-highlight-color: transparent;
}

/* Best-practice touch targets on iOS/Android: Apple HIG + MD
   both recommend 44×44 px minimum. Grab is disabled on decorative
   SVG so long-press doesn't pop an image menu. */
html {
  -webkit-text-size-adjust: 100%;
  text-size-adjust: 100%;
  -webkit-tap-highlight-color: transparent;
  color-scheme: light;
  /*
    scroll-behavior stays `auto` (the default). Smooth scrolling
    was tempting for anchor jumps but it also animates the
    browser's scroll-restoration on refresh - so a hard reload
    would animate-scroll down to wherever the user last was,
    instead of landing instantly at y=0. Any page that needs a
    smooth anchor jump can opt in via element-level
    `scroll-behavior: smooth` or a JS scroll with
    `behavior: "smooth"`.
  */
}
img, svg { user-select: none; -webkit-user-drag: none; }

/* Honour prefers-reduced-motion - strip all animations + smooth
   scrolling. Lighthouse A11y + Apple requirement. */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
    scroll-behavior: auto !important;
  }
}

[hidden] { display: none !important; }

/* Responsive visibility utilities — copy that needs to differ
   between desktop and touch (e.g. "Drop" vs "Tap" on /scan,
   /sign where mobile users can't drag-drop a file from the
   filesystem). Default: both shown. Mobile (≤720px) hides the
   desktop variant; ≥721px hides the mobile variant. */
.show-mobile { display: none; }
@media (max-width: 720px) {
  .show-desktop { display: none; }
  .show-mobile  { display: inline; }
}

html, body {
  margin: 0;
  padding: 0;
  color: var(--fg);
  font-family: var(--font);
  font-weight: 500;            /* Medium is our body weight */
  font-size: 16px;
  line-height: 1.55;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  /*
    Kill any horizontal scroll globally. A full-bleed chartreuse
    strip (or any section that extends beyond the viewport edge)
    would trigger iOS/trackpad elastic overflow sideways otherwise,
    exposing the background through the document edge. `overflow-x:
    clip` alone is enough — content can't scroll horizontally so
    there's nothing for the rubber-band gesture to act on.

    NOTE: deliberately NOT setting `overscroll-behavior-x: none`.
    That property also disables the browser's two-finger swipe-back
    history gesture on Chrome/Safari trackpads, which users rely
    on for navigation. Since `overflow-x: clip` removes scrollable
    horizontal content, leaving the default behaviour means
    swipe-back works AND rubber-band has nothing to do.
  */
  overflow-x: clip;
}

/*
  Rubber-band overscroll colour - split top / bottom.
  ---------------------------------------------------------------
  The requirement is:
    Top bounce    → chartreuse (matches the sticky nav)
    Bottom bounce → ink        (matches the ink footer)

  Chromium + Safari propagate only the *computed background
  colour* of the root element to the "canvas" surface that shows
  during rubber-band. Gradient images on html do NOT stretch into
  the over-scroll area - they get clipped to the element box.

  Workaround that works in both engines:
   1. html gets a solid ink background colour → bottom bounce.
   2. A pseudo-element on html sits `position: fixed` across the
      top half of the viewport with chartreuse. position:fixed
      elements DO get revealed during rubber-band (Safari stages
      them on a layer that scales with the viewport). z-index: -1
      keeps it behind body so body's opaque bone paints over it
      during normal scroll - the chartreuse only surfaces when
      the body edge pulls down off the viewport during the top
      bounce.
*/
html { background: var(--ink); }
html::before {
  content: "";
  position: fixed;
  inset: 0 0 auto 0;           /* top-left-right, bottom auto */
  height: 50vh;
  background: var(--chartreuse);
  z-index: -1;
  pointer-events: none;
}
body {
  background: var(--bg);
  /*
    Room for the fixed nav - body content starts below the nav
    rather than underneath it. Fixed nav means the bar stays
    stationary during rubber-band on iOS (and sits above the
    compositor scroll on macOS) for a more stable feel than the
    old sticky nav which followed the content during the bounce.
  */
  padding-top: calc(var(--nav-h) + env(safe-area-inset-top));
}

/* Anchor-link jumps clear the fixed nav - otherwise the target
   heading would land behind the nav. */
:target, [id] { scroll-margin-top: calc(var(--nav-h) + env(safe-area-inset-top) + 8px); }

body { min-height: 100vh; }

/* Layout wrapper around <slot /> in Base.astro. Guarantees that
   the page content occupies at least the visible viewport
   (minus the fixed nav) so the footer never floats up under a
   short page (e.g. /library with no search results). Pages
   with longer content overflow naturally; pages with their own
   full-height layouts are unaffected because min-height
   doesn't shrink them. */
.page-main {
  min-height: calc(100vh - var(--nav-h));
  min-height: calc(100svh - var(--nav-h));
  display: flex;
  flex-direction: column;
}
/* iOS 26 only: brute-force 100px overshoot past 100dvh so the
   section bottom clears the floating Liquid Glass URL-bar (the
   bar is ~80-90px tall but env(safe-area-inset-bottom) only
   reports the home-indicator strip, ~34px). Scoped to ≤720px so
   desktop pages don't grow an extra 100px of empty bottom space. */
@media (max-width: 720px) {
  .page-main {
    min-height: calc(100dvh - var(--nav-h) + 100px);
  }
}
.page-main > * { flex-shrink: 0; }

/*
  Smart ::selection that adapts to the bg of whatever element
  the user is dragging across. Driven by a custom property pair
  (--sel-bg / --sel-fg) seeded with the default at :root and
  overridden by an inline-script DOM walker (in Base.astro) on
  any element whose computed background-color matches the
  brand palette:
    chartreuse bg → 30% ink highlight (soft tint, doesn't blow
                    out on the bright surface)
    ink bg        → chartreuse highlight (re-asserts the brand
                    on the ink button INSIDE a chartreuse
                    section, where the parent's --sel-bg would
                    otherwise cascade through)
  Custom properties cascade naturally to ::selection on every
  descendant, so nothing else needs to know about this.
*/
:root {
  --sel-bg: var(--chartreuse);
  --sel-fg: var(--ink);
}
::selection {
  background: var(--sel-bg);
  color: var(--sel-fg);
}

a { color: inherit; text-decoration: none; }
/*
  No global a:hover colour change. A prior rule forced every <a>
  hover to ink, which turned chartreuse-on-ink pill buttons (e.g.
  the landing hero CTA) invisible on hover when their own :hover
  override happened to lose the specificity fight. Buttons define
  their own hover states; body-text links inherit ink already.
*/

/* Type hierarchy - SemiBold for titles, never Bold. */
h1, h2, h3, h4 {
  font-weight: 600;
  letter-spacing: -0.02em;
  margin: 0;
  color: var(--ink);
}
h1 { font-size: clamp(2rem, 4.5vw, 3rem);   line-height: 1.05; }
h2 { font-size: clamp(1.5rem, 3vw, 2rem);   line-height: 1.1; }
h3 { font-size: 1.125rem;                    line-height: 1.25; }

p { margin: 0; }

code, kbd {
  font-family: var(--font-mono);
  font-weight: 500;
  font-size: 0.85em;
  background: var(--surface-sunken);
  padding: 1px 6px;
  border-radius: 4px;
}

/* Canonical mono label: small, all caps, medium weight, 0.06em
   tracking. Use for role pills, quota chips, status readouts,
   table column headers, form label hints - anywhere the brand
   guidelines call for Geist Mono. Never use for body copy. */
.mono,
[data-mono] {
  font-family: var(--font-mono);
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  font-feature-settings: "tnum";
}

/*
  Tabular numerals globally — Apple's standard treatment for any
  number that lives in UI chrome (counters, times, hashes, BPM,
  block heights, durations, quota readouts). Proportional figures
  cause numbers to JITTER as their values change ("11" is wider
  than "12" with proportional figures because the proportional 1
  is narrower; tabular forces every digit to the same width). On
  a counter that ticks or a timestamp that updates, the wobble is
  visible and reads as cheap.

  Applied via `font-variant-numeric: tabular-nums` on :root so it
  inherits everywhere. Body prose paragraphs opt back out via
  :where() (zero specificity) so multi-digit numbers in marketing
  copy still flow naturally with proportional figures — the way
  long-form text was designed to read. The .tnum class still
  works as an explicit hint, mostly for legacy callers.
*/
:root { font-variant-numeric: tabular-nums; }
:where(p, li, blockquote, dd) { font-variant-numeric: normal; }
.tnum, time { font-variant-numeric: tabular-nums; }

main {
  max-width: calc(var(--rail) + 2 * var(--rail-x));
  margin: 0 auto;
  padding: 72px var(--rail-x) 96px;
  display: flex;
  flex-direction: column;
  gap: 32px;
}

header h1 { margin: 0 0 8px; }

header .tagline {
  color: var(--fg-dim);
  font-size: 1rem;
  margin: 0;
  line-height: 1.5;
}

/* ----------------- Header / top-of-page nav ----------------- */

#mivia-header {
  /*
    Fixed (not sticky) - stays pinned to the viewport during
    rubber-band bouncing on iOS Safari. On macOS Safari the
    compositor still shifts fixed elements during the bounce but
    the effect is subtler than sticky. Body gets matching
    padding-top: var(--nav-h) so content doesn't slide under.
  */
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 40;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  height: calc(var(--nav-h) + env(safe-area-inset-top));
  padding-top: env(safe-area-inset-top);
  padding-left: max(clamp(24px, 4vw, 48px), env(safe-area-inset-left));
  padding-right: max(clamp(24px, 4vw, 48px), env(safe-area-inset-right));
  background: var(--chartreuse);
}
/* Pages can opt-in via body.nav-follows-body to make the nav
   dissolve into the page bg instead of presenting a chartreuse
   slab. We previously used `background: inherit` here on the
   theory that the inherited value would track body's animated
   bg per-frame. In practice browsers compute `inherit` once at
   style time, not per animation tick, so the nav either snapped
   late or didn't follow at all — visible as a nav-vs-body lag
   during the success-state takeover.
   Fix: give the nav its own explicit bg + matching transition
   so its `background-color` interpolates from bone → chartreuse
   on the same 600ms / cubic-bezier(0.32, 0.72, 0, 1) curve as
   body. Both transitions kick off in the same frame (the
   `.is-result-success` class lands once on body, both rules
   match on the same recalc), and identical duration + easing
   means the two interpolations stay phase-locked. */
body.nav-follows-body #mivia-header {
  background: var(--bone);
  transition: background-color 600ms cubic-bezier(0.32, 0.72, 0, 1);
}
body.nav-follows-body.is-result-success #mivia-header {
  background: var(--chartreuse);
}
/* Kill the global html::before chartreuse-top-50vh layer on
   nav-follows-body pages. With nav now bone, that chartreuse
   strip leaks into view during top-overscroll bounce (and
   anywhere else body bg doesn't fully paint), reading as a
   stale brand band the page no longer wants. The footer-bg JS
   sniffer already pins <html> bg to body's current colour for
   bottom overscroll, so removing the pseudo here gives bone
   (or chartreuse during the success takeover) at both edges. */
html:has(body.nav-follows-body) { background: var(--bone); }
html:has(body.nav-follows-body)::before { display: none; }

.mivia-header-brand {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  color: var(--ink);
  text-decoration: none;
}
.mivia-header-brand img,
.mivia-header-brand svg {
  height: 22px;
  width: auto;
  display: block;
  /*
    Declare the transform transition on the base rule so that the
    animation runs in both directions for same-page state flips
    (scroll → `.is-scrolled` toggles).
  */
  transform-origin: left top;
  transition: transform 560ms cubic-bezier(0.2, 0.8, 0.2, 1);
  will-change: transform;
  /*
    Pull the wordmark out of the root view-transition group so
    cross-page navigations morph its size instead of crossfading
    two snapshots. Astro's ClientRouter snapshots the whole page
    on both sides of the swap - even with transition:persist on
    the header, both snapshots capture the img at its two
    different scales, and the root crossfade makes it look like
    a teleport. Naming the img creates its own VT group; the
    browser animates that group's size between the old and new
    states. Same-duration + same easing as the CSS transition so
    both code paths feel identical.
  */
  view-transition-name: mivia-brand;
}
@media (max-width: 720px) {
  .mivia-header-brand img,
  .mivia-header-brand svg { height: 32px; width: auto; }
}
/*
  Instant page swaps — no crossfade, no fade, no morph. The
  default View Transitions API behaviour runs
  ::view-transition-old(root) and ::view-transition-new(root)
  CONCURRENTLY with opposite opacity tweens, painting both
  pages on top of each other for ~250ms (a crossfade). Even
  a sequential fade-out → fade-in adds ~280ms of "you're
  navigating" UI that's just dead time once link prefetch
  has the destination warm.

  Killing the animation on every snapshot pseudo (root +
  any named groups) makes the swap a single repaint at
  destination — Apple-style, no flourish. Combined with
  hover-prefetch (astro.config.mjs) and the early-update
  theme-color hook (Base.astro), navigation feels click →
  done.
*/
/* Hide the OLD snapshot entirely; the NEW snapshot renders at its
   destination state with no animation. `animation: none` alone
   leaves BOTH old + new at their default opacity:1 for the swap
   frame — that paints two copies of the wordmark on top of each
   other (and the rest of the page), and transparent SVG strokes
   stack to read darker for a frame. Removing the old snapshot
   from the layer eliminates the overlap; the new snapshot is the
   only visible state during the swap window, so the destination
   paints cleanly with no flash.
   `mivia-brand` is named here so the persisted header wordmark
   doesn't get pulled into the root group's snapshot — its named
   group is also OLD-hidden / NEW-instant. */
::view-transition-old(root),
::view-transition-old(mivia-brand) {
  display: none;
}
::view-transition-new(root),
::view-transition-new(mivia-brand) {
  animation: none;
}


/*
  Oversized brand at page top on chartreuse-hero pages.
  ----------------------------------------------------
  On pages whose hero is chartreuse the nav bar visually merges with
  the hero, so the wordmark can breathe at 2× scale for a beat. As
  soon as the user scrolls any amount, body.is-scrolled is toggled
  on and the wordmark shrinks back to its 1× resting size.

  We scale via `transform` (not height/width) so the bounding box
  the flex parent measures stays at 22px - the rest of the nav row
  (links, avatar, Log in / Create account) never reflows even as
  the wordmark grows and shrinks. `transform-origin: left center`
  anchors the growth to the left padding edge so only empty
  vertical space to its right is borrowed.

  Reduced-motion users get the resting size immediately - no
  transition and no oversized state.
*/
/* Desktop-only: oversize the wordmark on chartreuse-hero pages
   while at the top, then settle to 1× on scroll. Scoped to
   min-width: 721px because the mobile rule (≤720px) sets the
   wordmark to 32px at rest — no scale-up on phones. Without
   this scope the unconditional rule appeared LATER in source
   order than the mobile override and won on specificity tie. */
@media (min-width: 721px) {
  body.has-chartreuse-hero .mivia-header-brand img,
  body.has-chartreuse-hero .mivia-header-brand svg {
    transform: scale(1.6);
  }
  body.has-chartreuse-hero.is-scrolled .mivia-header-brand img,
  body.has-chartreuse-hero.is-scrolled .mivia-header-brand svg {
    transform: scale(1);
  }
}
@media (prefers-reduced-motion: reduce) {
  .mivia-header-brand img,
  .mivia-header-brand svg {
    transition: none;
  }
  body.has-chartreuse-hero .mivia-header-brand img,
  body.has-chartreuse-hero .mivia-header-brand svg {
    transform: none;
  }
}

.mivia-header-actions {
  display: flex;
  align-items: center;
  gap: 6px;
  flex-wrap: wrap;
}

.mivia-header-link {
  appearance: none;
  background: transparent;
  border: none;
  color: var(--fg);
  cursor: pointer;
  font-family: inherit;
  font-size: 0.9rem;
  font-weight: 500;
  font-variation-settings: "wght" 500;
  padding: 8px 12px;
  border-radius: var(--radius-sm);
  text-decoration: none;
  line-height: 1;
  position: relative;        /* so text paints above the pill */
  z-index: 1;
  /* inline-flex column lets a 0-height ::before phantom (always
     bold) reserve the bold-weight WIDTH of the label, while the
     visible text below renders at the link's current weight. The
     parent's outer width is therefore the bold width regardless
     of state - animating wght up/down on aria-current changes
     does not push siblings around. */
  display: inline-flex;
  flex-direction: column;
  align-items: center;
  transition:
    color 120ms ease,
    font-variation-settings 240ms cubic-bezier(0.32, 0.72, 0, 1),
    font-weight 240ms cubic-bezier(0.32, 0.72, 0, 1);
}
.mivia-header-link::before {
  content: attr(data-text);
  font-weight: 700;
  font-variation-settings: "wght" 700;
  height: 0;
  overflow: hidden;
  visibility: hidden;
  pointer-events: none;
}
.mivia-header-link:focus-visible {
  outline: none;
}
/* Active + hover indicator - the variable font interpolates wght
   500 → 700; the ::before phantom above keeps the bounding box at
   bold width so neighbouring links don't shift. JS sets
   aria-current="page" on the matching link; :hover bolds whatever
   the cursor is over. */
.mivia-header-link[aria-current="page"],
.mivia-header-link:hover,
.mivia-header-link:focus-visible {
  font-weight: 700;
  font-variation-settings: "wght" 700;
}
/* If any sibling link is hovered, the current-page link relaxes
   back to normal weight - only one link should ever read as
   "selected" at a time, and the user's hover wins because that's
   the link they're about to click. */
.mivia-header-actions--desktop:has(.mivia-header-link:hover)
  .mivia-header-link[aria-current="page"]:not(:hover) {
  font-weight: 500;
  font-variation-settings: "wght" 500;
}

.mivia-header-actions--desktop {
  position: relative;
}

.mivia-header-cta {
  background: var(--ink);
  color: var(--chartreuse);
  border-radius: 999px;        /* fully rounded pill, like the primary buttons */
  padding: 10px 14px;          /* +2px on every side vs the bare nav link */
}
/* No hover state — the Create-account CTA stays steady on
   pointer hover (the pill is already a strong affordance and the
   hover-to-white flip read as a brittle "did I do something?"
   bounce). Keyboard focus-visible still gets a focus ring from
   the global :focus-visible rule. */
.mivia-header-cta:focus-visible {
  background: var(--ink);
  color: var(--white);
  outline: none;
}
/* On nav-follows-body pages the nav is bone, not chartreuse, so
   the CTA's chartreuse text reads as a stray brand accent floating
   on a neutral bg. Drop the text to bone so the pill is just an
   ink slab with bone label, matching the page palette. Hover
   still goes to white for the bump. */
body.nav-follows-body .mivia-header-cta {
  color: var(--bone);
}
/* Chartreuse takeover (success state on /scan) flips the page
   bg to chartreuse including the nav. The CTA's text should
   match the brand colour so the ink pill reads as "ink slab,
   chartreuse label" — same logic as the nav-follows-body bone
   rule above, just keyed to the chartreuse ground. Wins over
   the .nav-follows-body rule via specificity (one extra class). */
body.nav-follows-body.is-result-success .mivia-header-cta,
body.is-result-success .mivia-header-cta {
  color: var(--chartreuse);
}
/* The CTA is its own affordance (filled pill) - the wght bump
   used by the text nav links is redundant noise on a button.
   Keep weight + variation steady on hover/focus, override the
   :has() rule for the desktop row. The phantom ::before is still
   harmless (height: 0) but we hide it explicitly for clarity. */
.mivia-header-cta,
.mivia-header-cta:hover,
.mivia-header-cta:focus-visible,
.mivia-header-cta[aria-current="page"],
.mivia-header-actions--desktop:has(.mivia-header-link:hover)
  .mivia-header-cta:not(:hover) {
  font-weight: 500;
  font-variation-settings: "wght" 500;
}
.mivia-header-cta::before { display: none; }

/* ----------------- Buttons ----------------- */
/*
  Three buttons:
    primary-button   → Ink bg, White text (confident, default action)
    accent-button    → Chartreuse bg, Ink text (shopping / upgrade moments)
    secondary-button → outlined ink (secondary / ghost action)
  `.reset-button` is an alias for the outlined look used inside
  success / error cards.
*/
.primary-button,
.accent-button,
.secondary-button,
.reset-button {
  appearance: none;
  font-family: inherit;
  font-weight: 600;
  font-size: 0.95rem;
  line-height: 1;
  padding: 13px 22px;
  border-radius: 999px;
  cursor: pointer;
  border: 1px solid transparent;
  transition: background 120ms ease, color 120ms ease,
              border-color 120ms ease, transform 60ms ease;
  text-decoration: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
}

/*
  Button colours are resolved through CSS variables so a
  chartreuse-bg ancestor can flatten the hover state and flip
  the ink-fill text colour without needing per-page rules. The
  defaults below preserve the historical look on bone / ink /
  white surfaces.
    --btn-ink-fg / -hover         → ink-filled pill text
    --btn-ink-bg-hover            → ink-filled pill hover bg
    --btn-ghost-bg-hover          → outlined pill hover bg
    --btn-ghost-border-hover      → outlined pill hover border
  Chartreuse contexts (body.is-result-success and any element
  the smart-selection adapter tags as chartreuse-bg) override
  these so hover === rest visually.
*/
.primary-button {
  background: var(--ink);
  color: var(--btn-ink-fg, var(--white));
}
/* Default hover on bone backgrounds: brand chartreuse fill with
   ink text - the primary action lights up on hover with the
   site's signature colour. The CSS-var fallbacks here drive
   that default; chartreuse-bg contexts (body.is-result-success
   + smart-selection adapter) override the vars so a button
   already sitting on chartreuse stays calm on hover (rest ===
   hover) instead of fighting the ground colour. */
@media (hover: hover) {
  .primary-button:hover:not(:disabled) {
    background: var(--btn-ink-bg-hover, var(--chartreuse));
    color: var(--btn-ink-fg-hover, var(--ink));
  }
}

.accent-button {
  background: var(--chartreuse);
  color: var(--ink);
}
@media (hover: hover) {
  .accent-button:hover:not(:disabled) {
    background: var(--chartreuse-hover);
  }
}

.secondary-button,
.reset-button {
  background: transparent;
  color: var(--ink);
  border-color: var(--border-strong);
}
@media (hover: hover) {
  .secondary-button:hover:not(:disabled),
  .reset-button:hover:not(:disabled) {
    background: var(--btn-ghost-bg-hover, rgba(10, 10, 10, 0.05));
    border-color: var(--btn-ghost-border-hover, var(--ink));
  }
}

.primary-button:disabled,
.accent-button:disabled,
.secondary-button:disabled,
.reset-button:disabled {
  opacity: 0.4;
  cursor: not-allowed;
}

.primary-button:active:not(:disabled),
.accent-button:active:not(:disabled) {
  transform: scale(0.99);
}

/* ----------------- Inputs / fields ----------------- */
.field {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.field label,
.field-label {
  font-family: var(--font-mono);
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  font-size: 0.72rem;
  color: var(--fg-dim);
}

.field input[type="text"],
.field input[type="email"],
.field input[type="password"],
.field input[type="search"],
.field select,
.field textarea,
input[type="text"].input,
input[type="email"].input,
input[type="password"].input {
  appearance: none;
  background: var(--surface);
  border: 1px solid var(--border);
  color: var(--ink);
  padding: 12px 14px;
  /* 16px+ stops iOS Safari from zooming when the field is
     focused - anything below 16 triggers the auto-zoom. */
  font-size: 1rem;
  font-family: inherit;
  border-radius: var(--radius-sm);
  outline: none;
  transition: border-color 120ms ease, background 120ms ease,
              box-shadow 120ms ease;
  width: 100%;
}
.field input:focus,
.field select:focus,
.field textarea:focus {
  border-color: var(--ink);
}
.field input::placeholder { color: var(--fg-faint); }

.field select {
  background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'><path fill='%230a0a0a' d='M6 8L0 0h12z'/></svg>");
  background-repeat: no-repeat;
  background-position: right 14px center;
  padding-right: 36px;
  cursor: pointer;
}

.field-help {
  color: var(--fg-dim);
  font-size: 0.82rem;
  line-height: 1.5;
  margin: 0;
}

/* ============================================================
   Whole-page drop UX - used by /sign + /verify.

   Replaces the old boxed dashed `.dropzone` pattern (still used
   below for the per-recipient form on /sign). The page itself
   is the drop target via `wireDocumentDrop` in app.js; the
   visual element here is just an idle hero, a processing
   ring, a result card, or an error card - depending on
   `data-stage` on the parent.

   Apple-feel motion: 480ms crossfades on stage swap, 600ms
   body-bg takeover on success, soft cubic-bezier ease.
   ============================================================ */

/* The page wrapper that holds the staged hero. Centred,
   generous padding, pulls the eye to the middle. */
.drop-page {
  max-width: calc(var(--rail) + 2 * var(--rail-x));
  margin: 0 auto;
  padding: clamp(80px, 14vw, 160px) var(--rail-x) clamp(80px, 14vw, 160px);
  min-height: calc(100vh - var(--nav-h));
  min-height: calc(100svh - var(--nav-h));
  display: flex;
  align-items: center;
  justify-content: center;
}
/* iOS 26 only: 100px overshoot past 100dvh on .drop-page so the
   section bottom clears the floating URL bar. Scoped to ≤720px
   so desktop scan/sign pages keep their original 100svh height. */
@media (max-width: 720px) {
  .drop-page {
    min-height: calc(100dvh - var(--nav-h) + 100px);
  }
}
/* The upper cluster of the configure stage (eyebrow + title +
   filename pill + segmented control) is wrapped in
   .drop-configure-head. In SINGLE mode the wrapper is layout-
   transparent (display: contents) so the whole cluster including
   the buttons is centred as one unit by .drop-page's flex
   centring - preserving the existing single-mode look exactly.
   In MULTI mode we stop centring the entire (now very tall)
   cluster and instead pin .drop-configure-head to its OWN
   100vh-tall flex region with internal centring - locking the
   eyebrow / title / filename / segmented Y at the same place as
   single mode (matched via the padding-bottom that compensates
   for single's buttons + gap below the segmented control). The
   multi body then flows below the head in normal block flow. */
.drop-configure-head {
  display: contents;
}
.drop-page.is-mode-multi {
  flex-direction: column;
  align-items: stretch;
  justify-content: flex-start;
  padding-top: 0;
  padding-bottom: clamp(40px, 8vw, 80px);
}
.drop-page.is-mode-multi .drop-configure {
  display: block;
  text-align: center;
}
.drop-page.is-mode-multi .drop-configure-head {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 18px;
  /* Push the head down to land at the same Y as single-mode's
     centred cluster top. Single mode centres a cluster of
     height --single-cluster-h within 100vh - nav, putting the
     head_top at (100vh - nav - cluster) / 2. We use the same
     formula as padding-top so the head's first child sits at the
     same Y. The variable is measured by the inline script in
     sign.astro / sign-mock.astro on the very first setMode() call
     (while still in single mode); falls back to a 310px estimate
     before measurement. The head has no min-height so its box
     height = padding-top + content_h, and the body flows directly
     below the content (no extra gap). */
  padding-top: max(40px, calc((100vh - var(--nav-h) - var(--single-cluster-h, 310px)) / 2));
  padding-top: max(40px, calc((100svh - var(--nav-h) - var(--single-cluster-h, 310px)) / 2));
}
.drop-page.is-mode-multi .sign-mode-body--multi {
  margin-top: 24px;
}

/* The stage container. All four stages live as sibling
   sections; `data-stage` on this element decides which is
   visible. Each stage handles its own enter/exit animation
   via `.is-stage` rules below. */
.drop-stage {
  width: 100%;
  position: relative;
}
.drop-stage > section {
  display: none;
  opacity: 0;
  transform: translateY(8px) scale(0.99);
  transition:
    opacity 360ms cubic-bezier(0.32, 0.72, 0, 1),
    transform 360ms cubic-bezier(0.32, 0.72, 0, 1);
  text-align: center;
}
.drop-stage[data-stage="idle"]       .drop-idle,
.drop-stage[data-stage="configure"]  .drop-configure,
.drop-stage[data-stage="processing"] .drop-processing,
.drop-stage[data-stage="success"]    .drop-success,
.drop-stage[data-stage="error"]      .drop-error {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 18px;
  opacity: 1;
  transform: translateY(0) scale(1);
}

/* Idle copy. Big confident headline, small tagline,
   single Choose-a-file pill, format readout. */
.drop-idle .drop-eyebrow,
.drop-configure .drop-eyebrow,
.drop-success .drop-eyebrow,
.drop-error .drop-eyebrow {
  font-family: var(--font-mono);
  font-weight: 500;
  font-size: 0.72rem;
  text-transform: uppercase;
  letter-spacing: 0.1em;
  color: var(--fg-dim);
  margin: 0;
}
.drop-eyebrow--ink    { color: var(--ink) !important; }
.drop-eyebrow--danger { color: var(--danger) !important; }

.drop-headline {
  font-size: clamp(2.25rem, 6vw, 3.75rem);
  font-weight: 600;
  letter-spacing: -0.025em;
  line-height: 1.05;
  color: var(--ink);
  margin: 4px 0 0;
}
.drop-sub {
  font-size: 1.05rem;
  line-height: 1.55;
  color: var(--fg-dim);
  max-width: 48ch;
  margin: 0;
}

.drop-pick {
  display: inline-flex;
  align-items: center;
  cursor: pointer;
  padding: 14px 26px;
  border-radius: 999px;
  background: var(--ink);
  color: var(--bone);
  font-size: 0.95rem;
  font-weight: 600;
  letter-spacing: -0.005em;
  /* color transitions too so the text → ink swap on hover
     reads alongside the bg → chartreuse swap. */
  transition:
    background 160ms cubic-bezier(0.32, 0.72, 0, 1),
    color 160ms cubic-bezier(0.32, 0.72, 0, 1),
    transform 160ms cubic-bezier(0.32, 0.72, 0, 1);
  margin-top: 8px;
}
@media (hover: hover) {
  .drop-pick:hover  {
    background: var(--chartreuse);
    color: var(--ink);
  }
}
.drop-pick:active { transform: scale(0.98); }

.drop-formats {
  font-family: var(--font-mono);
  font-weight: 500;
  font-size: 0.72rem;
  text-transform: uppercase;
  letter-spacing: 0.12em;
  color: var(--fg-dim);
  margin: 4px 0 0;
}

/* Processing - filename above, ring centred, phase below.
   The ring stroke colours flip on the chartreuse takeover, but
   the chartreuse takeover only fires AFTER processing ends, so
   inside this block we always show ink-on-bone. */
.drop-processing { padding-top: 8px; }
.drop-filename {
  font-family: var(--font-mono);
  font-weight: 500;
  font-size: 0.78rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--fg-dim);
  margin: 0;
  /* Stay on one line - long filenames truncate with an ellipsis
     instead of wrapping into a multi-line block that pushes the
     ring around. */
  display: block;
  max-width: min(60ch, 90%);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.drop-ring {
  width: 96px;
  height: 96px;
  display: block;
}
.drop-ring-track,
.drop-ring-head {
  fill: none;
  stroke-width: 4;
  stroke-linecap: round;
}
.drop-ring-track { stroke: rgba(10, 10, 10, 0.10); }
.drop-ring-head {
  stroke: var(--ink);
  /* Default: indeterminate-style - short visible arc that the
     `.is-indeterminate` animation rotates around the circle.
     determinate mode overrides via inline style. */
  stroke-dasharray: 60 229;
  stroke-dashoffset: 0;
  transform-origin: 50% 50%;
  transform: rotate(-90deg);
  /* Faster linear transition for the unified-ring controller — the
     JS writes ~8 Hz `--ring-fraction` updates and CSS smooths the
     gaps. The previous 280 ms cubic-bezier was tuned for one-shot
     snaps (upload-done → download-done); a per-tick spring would
     read as laggy against the asymptotic creep. The legacy snap-
     style transition is now driven only on stage entry / exit. */
  transition:
    stroke-dashoffset 100ms linear;
}
.drop-ring-head.is-indeterminate {
  animation: drop-ring-spin 1.4s linear infinite;
}
@keyframes drop-ring-spin {
  from { transform: rotate(-90deg); }
  to   { transform: rotate(270deg); }
}
.drop-phase {
  font-size: 1rem;
  font-weight: 500;
  color: var(--ink);
  margin: 0;
}

/* Success + error - large headline, optional sub / fields,
   row of CTAs at the bottom. Result fields render as a
   two-column dl on wide and a single column on narrow. */
.drop-success .drop-result-title,
.drop-error .drop-error-title {
  font-size: clamp(2rem, 5vw, 3rem);
  font-weight: 600;
  letter-spacing: -0.025em;
  line-height: 1.1;
  color: var(--ink);
  margin: 4px 0 0;
  /* Cap the headline width so long copy doesn't sprawl the
     full viewport - keeps the eye anchored on a centred block. */
  max-width: 22ch;
}
.drop-success .drop-result-sub,
.drop-error .drop-error-sub {
  font-size: 1.05rem;
  line-height: 1.55;
  color: var(--fg-dim);
  max-width: 48ch;
  margin: 0;
}

/* Public-state attribution + note. Used when /verify resolves
   to a Mivia-signed file the viewer is NOT a stakeholder of -
   we surface the signer's name only and explain why the rest
   is hidden. Replaces the old dl-with-hint layout that wrapped
   short values into one-character columns. */
.drop-result-attribution {
  font-size: clamp(1.4rem, 3vw, 1.8rem);
  font-weight: 600;
  letter-spacing: -0.015em;
  line-height: 1.2;
  color: var(--ink);
  margin: 12px 0 0;
  max-width: 30ch;
}
.drop-result-note {
  font-size: 0.95rem;
  line-height: 1.55;
  color: var(--ink);
  opacity: 0.72;
  max-width: 44ch;
  margin: 0;
}

/* License message rendered by the file owner - shown to public
   viewers who verify one of their files. Slightly styled so it
   reads as a contact line rather than body copy. */
.drop-result-license-message {
  font-family: var(--font-mono);
  font-weight: 500;
  font-size: 0.92rem;
  line-height: 1.55;
  color: var(--ink);
  background: rgba(10, 10, 10, 0.10);
  padding: 12px 18px;
  border-radius: 999px;
  margin: 8px 0 0;
  max-width: 56ch;
  white-space: pre-wrap;
}

/* Owner-only recommendation banner - appears when the file was
   only fingerprinted (plugin auto-register) and the owner is
   verifying it themselves, prompting them to also watermark it
   before sharing wide. */
.drop-result-recommendation {
  font-size: 0.92rem;
  line-height: 1.55;
  color: var(--ink);
  background: rgba(10, 10, 10, 0.08);
  border-left: 3px solid var(--ink);
  padding: 12px 16px;
  border-radius: var(--radius-sm);
  margin: 8px 0 0;
  max-width: 56ch;
  text-align: left;
}
.drop-error .drop-error-title { color: var(--danger); }

/* Owner-only "Sent to" row - appended into .drop-result-fields as a
   regular dt/dd pair. Renders during the auto-fired
   /api/identify-variant background call: ".pending" while the slow
   watermark extract is running, ".found" / ".unknown" / ".error"
   once the response lands. Each tone uses inherited foreground
   shifted by alpha so the row sits in line with the rest of the
   manifest table. */
.manifest-identify-status--pending {
  /* Subtle italic + muted colour signals "still working" without
     a separate spinner glyph (the verify result card already shows
     a chartreuse "found" frame; an inline spinner here would be
     visual noise). */
  font-style: italic;
  opacity: 0.65;
}
.manifest-identify-status--found {
  /* Default dd weight + colour - same look as every other resolved
     manifest field. No special styling needed for a hit. */
}
.manifest-identify-status--unknown {
  opacity: 0.7;
}
.manifest-identify-status--error {
  color: #b3261e;
}

.drop-result-fields {
  display: grid;
  /* Tight label column so the value column gets as much room as
     possible. dt sizes to its widest label; dd takes the rest of
     the row via `1fr`. */
  grid-template-columns: max-content 1fr;
  column-gap: 24px;
  row-gap: 0;
  /* Centre the dt and dd line-boxes vertically against the row's
     centre line. Baseline alignment was technically correct but
     visually wrong here - the dt is small caps with no descenders,
     so sharing a baseline with the dd's lowercase-bearing text
     parked the dt at the BOTTOM of the dd's glyph mass instead of
     its middle. With matching line-heights below, line-box centre
     ≈ glyph centre for both, so centre alignment puts the visual
     middles of the two texts on the same horizontal axis. */
  align-items: center;
  text-align: left;
  /* Adaptive width: shrink to fit the longest natural row, capped
     at the on-screen budget. ``fit-content(720px)`` is the magic
     value here - it sizes the grid to its intrinsic content width
     when that's smaller than 720px (so a table of short values
     stays compact and tightly centred), and clamps to 720px when
     a long filename would otherwise blow the layout out (in which
     case the dd's inner ellipsis-span kicks in). margin: auto on
     both sides keeps the result horizontally centred no matter
     which size the grid resolves to. */
  width: fit-content(720px);
  max-width: 100%;
  margin: 16px auto 8px;
}
.drop-result-fields dt,
.drop-result-fields dd {
  margin: 0;
  /* min-width: 0 is the canonical "let me shrink inside a grid
     track even though my content has its own intrinsic width"
     escape hatch - the inner ellipsis-span on the dd needs it. */
  min-width: 0;
  /* Modest vertical breathing room between rows - enough to read
     each pair as its own line, not so much that the table feels
     airy. 8px above + 8px below = ~16px between consecutive text
     lines on top of their natural line-height. */
  padding: 8px 0;
}
.drop-result-fields dt {
  font-family: var(--font-mono);
  font-weight: 500;
  font-size: 0.7rem;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: rgba(10, 10, 10, 0.62);
  /* Match the dd's line-height so the baseline alignment lines
     up cleanly without inheriting whatever ambient body
     line-height happens to be. */
  line-height: 1.4;
  /* Labels are short - no ellipsis needed, just don't let them
     wrap onto a second line if a future label gets long. */
  white-space: nowrap;
}
/* dd is a flex row so an optional info icon can sit beside the
   value without breaking the ellipsis truncation on the text. */
.drop-result-fields dd {
  display: flex;
  align-items: center;
  gap: 8px;
  color: var(--ink);
  font-size: 0.96rem;
  /* Match the dt's natural line-height so the centring resolves
     cleanly across rows. */
  line-height: 1.4;
}
/* The text span carries the ellipsis styles. flex: 0 1 auto +
   min-width: 0 lets it shrink below its intrinsic width when the
   row is too narrow, so `text-overflow: ellipsis` actually fires
   instead of the text just overflowing. */
.drop-result-fields dd > .dd-text {
  flex: 0 1 auto;
  min-width: 0;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* Info icon - small circle-i sitting next to any element that
   needs a hover-revealed explanation. Originally written for the
   verify card's Mark-type field (.drop-result-fields dd > .dd-info)
   but generalised so any markup can drop in
   `<span class="dd-info"><svg.../><span class="dd-info-bubble">…</span></span>`
   and get the same tooltip behaviour. tabindex=0 makes it
   focusable so keyboard users get the same affordance.
   `position: relative` anchors the absolutely-positioned bubble. */
.dd-info {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: 0 0 auto;
  width: 16px;
  height: 16px;
  color: rgba(10, 10, 10, 0.55);
  cursor: pointer;
  transition: color 120ms ease;
}
.dd-info:focus-visible {
  color: var(--ink);
  outline: none;
}
@media (hover: hover) {
  .dd-info:hover {
    color: var(--ink);
    outline: none;
  }
}
.dd-info svg {
  width: 100%;
  height: 100%;
  display: block;
}
/* Hover/focus tooltip. Positioned above the icon, centred on it,
   with a small downward-pointing caret so the relationship reads
   instantly. pointer-events: none means the bubble doesn't
   intercept hover (which would create a flicker loop when the
   cursor crosses from icon → bubble → icon). */
.dd-info > .dd-info-bubble {
  position: absolute;
  bottom: calc(100% + 10px);
  left: 50%;
  transform: translateX(-50%) translateY(4px);
  width: max-content;
  max-width: min(320px, 80vw);
  padding: 10px 12px;
  background: var(--ink);
  color: var(--bone);
  border-radius: 8px;
  font-family: var(--font-sans, inherit);
  font-size: 0.8rem;
  font-weight: 400;
  line-height: 1.45;
  letter-spacing: 0;
  text-transform: none;
  text-align: left;
  white-space: normal;
  pointer-events: none;
  opacity: 0;
  transition: opacity 140ms ease, transform 140ms ease;
  z-index: 10;
  box-shadow: 0 8px 24px rgba(10, 10, 10, 0.18);
}
.dd-info > .dd-info-bubble::after {
  /* Caret pointing down to the icon. Same dark fill as the
     bubble; positioned just below the bubble's bottom edge. */
  content: "";
  position: absolute;
  top: 100%;
  left: 50%;
  transform: translateX(-50%);
  border: 5px solid transparent;
  border-top-color: var(--ink);
  border-bottom: 0;
}
.dd-info:focus-visible > .dd-info-bubble {
  opacity: 1;
  transform: translateX(-50%) translateY(0);
}
@media (hover: hover) {
  .dd-info:hover > .dd-info-bubble {
    opacity: 1;
    transform: translateX(-50%) translateY(0);
  }
}

.drop-result-row {
  display: flex;
  gap: 12px;
  flex-wrap: wrap;
  justify-content: center;
  margin-top: 12px;
}
.drop-cta {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 13px 22px;
  font-size: 0.95rem;
  font-weight: 600;
  border-radius: 999px;
  border: 1px solid transparent;
  cursor: pointer;
  text-decoration: none;
  font-family: inherit;
  transition: background 160ms cubic-bezier(0.32, 0.72, 0, 1),
              color 160ms cubic-bezier(0.32, 0.72, 0, 1);
  min-width: 160px;
}
.drop-cta--ink {
  background: var(--ink);
  color: var(--btn-ink-fg, var(--bone));
}
@media (hover: hover) {
  .drop-cta--ink:hover {
    background: var(--btn-ink-bg-hover, var(--chartreuse));
    color: var(--btn-ink-fg-hover, var(--ink));
  }
}
.drop-cta--ghost {
  background: transparent;
  color: var(--ink);
  border-color: var(--ink);
}
@media (hover: hover) {
  .drop-cta--ghost:hover { background: var(--btn-ghost-bg-hover, rgba(10, 10, 10, 0.08)); }
}

/* Whole-page drag affordance. No overlay panel anymore; the
   page itself dims slightly (a fixed transparent ink wash
   above the nav, pointer-events:none so clicks still pass)
   and the .drop-headline crossfades from "Drop to scan." /
   "Drop to sign." → "Let go." while a file is dragged over.
   On drop or dragleave the body class is removed and both
   states transition back. */
body.is-dragging-file::after {
  content: "";
  position: fixed;
  inset: 0;
  z-index: 100;            /* above the nav (z-40) so the dim covers it too */
  background: rgba(10, 10, 10, 0.14);
  pointer-events: none;
  opacity: 1;
  animation: drop-dim-in 200ms cubic-bezier(0.32, 0.72, 0, 1);
}
@keyframes drop-dim-in {
  from { opacity: 0; }
  to   { opacity: 1; }
}
/* Headline crossfade — both spans live in the same line-box
   via grid-area stacking so the headline never reflows mid-
   gesture. The drag span is hidden at rest; on body.is-
   dragging-file they swap. */
.drop-headline {
  display: inline-grid;
  grid-template-areas: "stack";
  align-items: baseline;
}
.drop-headline > span {
  grid-area: stack;
  transition: opacity 200ms cubic-bezier(0.32, 0.72, 0, 1);
}
.drop-headline-drag { opacity: 0; }
body.is-dragging-file .drop-headline-rest { opacity: 0; }
body.is-dragging-file .drop-headline-drag { opacity: 1; }

/* Whole-page chartreuse takeover on a successful match.
   Animates body bg over 600ms with the Apple ease. The drop-
   stage's success section already sits centred within the
   page; the takeover just changes the surrounding ground. */
body.is-result-success {
  background: var(--chartreuse);
  transition: background-color 600ms cubic-bezier(0.32, 0.72, 0, 1);
  /* Cascading button-colour overrides - every ink-filled pill
     inside the success body gets chartreuse text (was white)
     and a flat hover state (rest === hover). Outlined pills
     also drop their hover bg. The smart-selection adapter sets
     the same vars on any other chartreuse-bg element it finds,
     so this rule + the JS together cover all chartreuse
     contexts site-wide automatically. */
  --btn-ink-fg: var(--chartreuse);
  --btn-ink-fg-hover: var(--chartreuse);
  --btn-ink-bg-hover: var(--ink);
  --btn-ghost-bg-hover: transparent;
  --btn-ghost-border-hover: var(--ink);
}
/* Idle/error stages still use bone - only success goes
   chartreuse. The body-bg transition runs in both directions,
   so resetting from success → idle smoothly fades back. */

/* Reduced-motion respect. */
@media (prefers-reduced-motion: reduce) {
  .drop-stage > section,
  .drop-pick,
  .drop-cta,
  body.is-result-success,
  .drop-ring-head,
  .drop-headline > span { transition: none !important; }
  .drop-ring-head.is-indeterminate { animation: none !important; }
  body.is-dragging-file::after { animation: none !important; }
}

/* Responsive */
@media (max-width: 720px) {
  /* Asymmetric padding-bottom compensates for the 100px overshoot
     in min-height (which we added so the section extends behind
     the iOS 26 floating URL bar and pushes the footer off-screen).
     Without this, flex-centering puts the content at the box's
     mid-point, which sits ~50px below the visual viewport's centre
     because the box extends past the URL bar. Adding 100px to
     padding-bottom shifts the centred content up by half (50px)
     so it lands in the actual visible content area, between the
     nav and the URL bar pill. */
  .drop-page { padding: 64px var(--rail-x) calc(64px + 100px); }
  /* Stack labels above values on phones - the dt + dd flex
     centring works either way, but a single column reads better
     when horizontal real-estate is tight. Tighten padding because
     each row now contributes two visual lines. */
  .drop-result-fields {
    grid-template-columns: 1fr;
    column-gap: 0;
  }
  .drop-result-fields dt { padding: 14px 0 2px; }
  .drop-result-fields dd { padding: 0 0 14px; }
  .drop-cta { width: 100%; max-width: 360px; }
}

/* ----------------- Dropzone (sign + verify) ----------------- */
.dropzone {
  border: 1.5px dashed var(--border-strong);
  border-radius: var(--radius);
  background: var(--surface);
  padding: 56px 24px;
  text-align: center;
  cursor: pointer;
  transition: border-color 120ms ease, background 120ms ease,
              transform 60ms ease;
}
.dropzone--compact { padding: 32px 24px; }
.dropzone--hero    { padding: 64px 24px; border-radius: var(--radius-lg); }

.dropzone:focus-within,
.dropzone.dropzone--over,
.dropzone.is-dragging {
  border-color: var(--ink);
  background: var(--surface);
  outline: none;
}
@media (hover: hover) {
  .dropzone:hover {
    border-color: var(--ink);
    background: var(--surface);
    outline: none;
  }
}
.dropzone.is-dragging { transform: scale(1.004); }

.dropzone-inner {
  display: flex;
  flex-direction: column;
  gap: 12px;
  align-items: center;
}

.dropzone-headline {
  font-size: 1.125rem;
  font-weight: 600;
  margin: 0;
  color: var(--ink);
}
.dropzone-sub {
  color: var(--fg-dim);
  margin: 0;
  font-size: 0.88rem;
}

.dropzone-button {
  display: inline-block;
  background: var(--ink);
  color: var(--white);
  padding: 10px 18px;
  border-radius: var(--radius-sm);
  font-size: 0.88rem;
  font-weight: 600;
  cursor: pointer;
  transition: background 120ms ease, color 120ms ease;
}
@media (hover: hover) {
  .dropzone-button:hover { background: #1a1a1a; color: var(--chartreuse); }
}

.dropzone-formats {
  /* Mono caption under the dropzone - technical readout territory. */
  font-family: var(--font-mono);
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--fg-dim);
  font-size: 0.7rem;
  margin: 8px 0 0;
}

/* ----------------- Status / spinner / progress ----------------- */
.status {
  display: flex;
  gap: 16px;
  align-items: center;
  padding: 20px;
  background: var(--surface);
  border-radius: var(--radius);
  border: 1px solid var(--border);
}
.status p { margin: 0; color: var(--fg-dim); }

.spinner {
  width: 18px; height: 18px;
  border-radius: 50%;
  border: 2px solid var(--border);
  border-top-color: var(--ink);
  animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }

.progress {
  width: 100%; height: 6px;
  background: var(--border);
  border-radius: 3px;
  overflow: hidden;
  margin-top: 2px;
}
.progress-bar {
  width: 0%; height: 100%;
  background: var(--ink);
  transition: width 0.15s linear;
}

/* ----------------- Result / error panes ----------------- */
.result, .error {
  background: var(--surface);
  border-radius: var(--radius);
  padding: 28px;
  border: 1px solid var(--border);
}
.result h2, .error h2 {
  margin: 0 0 16px;
  font-size: 1.25rem;
  font-weight: 600;
  letter-spacing: -0.01em;
}
.result h2 { color: var(--ink); }
.error  h2 { color: var(--danger); }
.error  p  { color: var(--fg-dim); margin: 0 0 20px; }

/* ----------------- Sign form (legacy shell) ----------------- */
.sign-form {
  display: flex;
  flex-direction: column;
  gap: 18px;
}

/* ----------------- Disclaimer card ----------------- */
.disclaimer {
  color: var(--fg-dim);
  font-size: 0.78rem;
  line-height: 1.55;
  margin: 0;
  padding: 14px 16px;
  background: var(--surface);
  border-radius: var(--radius-sm);
  border: 1px solid var(--border);
}

/* ----------------- Tabs (legacy two-tab switch) ----------------- */
.tabs {
  display: flex;
  gap: 4px;
  padding: 4px;
  background: var(--surface);
  border-radius: 10px;
  border: 1px solid var(--border);
  width: max-content;
}
.tab {
  background: transparent;
  border: none;
  color: var(--fg-dim);
  padding: 8px 20px;
  font-size: 0.85rem;
  font-weight: 500;
  font-family: inherit;
  border-radius: 7px;
  cursor: pointer;
  transition: background 120ms ease, color 120ms ease;
}
@media (hover: hover) {
  .tab:hover     { color: var(--ink); }
}
.tab--active     { background: var(--ink); color: var(--chartreuse); }

/* ----------------- Footer ----------------- */
footer {
  color: var(--fg-dim);
  font-size: 0.78rem;
  line-height: 1.6;
  margin-top: 24px;
  padding-top: 24px;
}
footer p { margin: 0; }

/* ----------------- Auth pages ----------------- */
/*
  Narrow auth card (login / signup / reset / claim) stays at
  ~440px - correct for a single-form flow. Account uses the
  same shell but widens to ~880px via .auth-card--wide so its
  subscription + plugin-token sections have room to breathe.
*/
.auth-wrap {
  display: flex;
  /*
    main { flex-direction: column } is inherited, so justify-content
    controls the vertical (main) axis. Horizontal centering needs
    align-items on the cross axis - without this, max-width on the
    card capped cross-axis stretch at its max, which visually
    anchored the card to the LEFT of main's inner padding area on
    wide viewports.
  */
  align-items: center;
  justify-content: center;
  padding: 64px clamp(24px, 4vw, 48px);
  width: 100%;
  box-sizing: border-box;
}
.auth-card {
  width: 100%;
  max-width: 440px;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 36px clamp(24px, 4vw, 40px);
  box-sizing: border-box;
}
.auth-card--wide { max-width: 880px; padding: 48px clamp(24px, 4vw, 56px); }

/*
  Split-hero auth layout - chartreuse hero on the left, white
  .auth-card on the right. Used on /signup and /plugin-auth for
  the "first impression" pages where the moment matters. Stacks
  to single column at ≤ 960 px so mobile is always hero-above,
  card-below.

  Markup shape:
    <main class="auth-split-wrap">
      <div class="auth-split-hero"> headline + sub-lede + trust </div>
      <div class="auth-split-card">
        <section class="auth-card"> form </section>
      </div>
    </main>
*/
.auth-split-wrap {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  min-height: calc(100vh - var(--nav-h));
  min-height: calc(100svh - var(--nav-h));
  /* Push up into the nav region so the chartreuse hero reads
     as continuous with the chartreuse nav (same pattern as the
     landing hero). */
  margin-top: calc(var(--nav-h) * -1);
}
.auth-split-hero {
  background: var(--chartreuse);
  color: var(--ink);
  padding: calc(clamp(48px, 8vw, 96px) + var(--nav-h))
           clamp(32px, 5vw, 72px)
           clamp(48px, 8vw, 96px);
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 20px;
}
.auth-split-hero-eyebrow {
  font-family: var(--font-mono);
  font-weight: 500;
  font-size: 0.72rem;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: rgba(10, 10, 10, 0.72);
  margin: 0;
}
.auth-split-hero-title {
  font-size: clamp(2rem, 4.5vw, 3.25rem);
  font-weight: 600;
  letter-spacing: -0.03em;
  line-height: 1.05;
  margin: 0;
  max-width: 18ch;
  color: var(--ink);
}
.auth-split-hero-sub {
  font-size: clamp(1rem, 1.4vw, 1.12rem);
  line-height: 1.55;
  color: var(--ink);
  opacity: 0.78;
  margin: 0;
  max-width: 42ch;
}
.auth-split-hero-note {
  font-family: var(--font-mono);
  font-weight: 500;
  font-size: 0.68rem;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: rgba(10, 10, 10, 0.6);
  margin: 12px 0 0;
}

.auth-split-card {
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 64px clamp(24px, 4vw, 48px);
  background: var(--bone);
}
/* The nested .auth-card keeps its existing styles (440px card,
   white surface, padding, border). No overrides needed - the
   split layout just provides a different container. */

@media (max-width: 960px) {
  .auth-split-wrap {
    grid-template-columns: 1fr;
    min-height: 0;
  }
  .auth-split-hero {
    padding: calc(clamp(32px, 6vw, 64px) + var(--nav-h))
             clamp(24px, 5vw, 48px)
             clamp(32px, 6vw, 64px);
  }
  .auth-split-card {
    padding: 48px clamp(24px, 4vw, 48px);
  }
}

/*
  Shared avatar preview - used by /signup and /account above the
  name field to show the live typographic face as the user types.
  Lives in styles.css (not scoped to a single page) so any form
  that wants a preview just drops the markup in. The disc stays
  chartreuse on every non-chartreuse surface (auth cards are white);
  the nav avatar is a separate component with its own translucent
  tint that blends into the chartreuse header.
*/
.mivia-avatar-preview {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  margin: 0 0 20px;
}
.mivia-avatar-preview-circle {
  width: 96px;
  height: 96px;
  border-radius: 999px;
  overflow: hidden;
  background: var(--chartreuse);
  display: inline-flex;
  align-items: center;
  justify-content: center;
}
.mivia-avatar-preview-circle svg {
  width: 100%;
  height: 100%;
  display: block;
}
.mivia-avatar-preview-note {
  margin: 0;
  font-family: var(--font-mono);
  font-weight: 500;
  font-size: 0.68rem;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--fg-dim);
  text-align: center;
}

/* Account-page inline <dl> grid shouldn't overflow on narrow. */
@media (max-width: 480px) {
  .auth-card--wide dl {
    grid-template-columns: 1fr !important;
    gap: 4px 0 !important;
  }
}
.auth-card h1 { margin: 0 0 6px; font-size: 1.5rem; }
.auth-card .subtitle {
  margin: 0 0 24px;
  color: var(--fg-dim);
  font-size: 0.9rem;
}
.auth-card form {
  display: flex;
  flex-direction: column;
  gap: 14px;
}
.auth-card label {
  display: flex;
  flex-direction: column;
  gap: 6px;
  font-family: var(--font-mono);
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--fg-dim);
  font-size: 0.7rem;
}
.auth-card input[type="email"],
.auth-card input[type="password"],
.auth-card input[type="text"] {
  appearance: none;
  background: var(--bone);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  padding: 12px 14px;
  /* 16px+ prevents iOS auto-zoom on focus. */
  font-size: 1rem;
  color: var(--ink);
  font-family: inherit;
  font-weight: 500;
  text-transform: none;
  letter-spacing: normal;
  width: 100%;
}
.auth-card input:focus {
  outline: none;
  border-color: var(--ink);
}

/* ============== Floating-label field (Apple-style) ==============
   Input has a transparent placeholder=" " so :placeholder-shown
   only matches while the field is empty AND unfocused. When the
   user clicks in or types something, the sibling <label> slides
   from the centre of the field up to the top and shrinks. The
   actual placeholder character stays invisible (no glyph) - the
   label IS the visible affordance. Input ::placeholder is hidden
   so the space character doesn't render as a stray dot. */
.auth-card .floating-field {
  position: relative;
  display: block;
}
.auth-card .floating-field input[type="email"],
.auth-card .floating-field input[type="password"],
.auth-card .floating-field input[type="text"] {
  border-radius: 999px;          /* fully rounded pill, per request */
  /* Extra top padding reserves room for the floated label so the
     value text doesn't sit on top of it once the label rises. */
  padding: 26px 22px 10px;
  background: transparent;       /* ink outline only, no fill */
  border: 1px solid var(--ink);
}
.auth-card .floating-field input:focus {
  /* Just darken the border on focus - no halo. The label
     animation + the field state itself is enough affordance. */
  outline: none;
  border-color: var(--ink);
}
.auth-card .floating-field input::placeholder {
  color: transparent;
}
.auth-card .floating-field label {
  /* Override the auth-card's mono/uppercase label default so the
     floating label reads like body text at rest, then shrinks to
     a small caption on focus. */
  position: absolute;
  left: 22px;
  top: 50%;
  transform: translateY(-50%);
  font-family: inherit;
  font-weight: 500;
  font-size: 1rem;
  text-transform: none;
  letter-spacing: normal;
  color: var(--fg-dim);
  background: transparent;
  pointer-events: none;
  transition:
    top 220ms cubic-bezier(0.32, 0.72, 0, 1),
    transform 220ms cubic-bezier(0.32, 0.72, 0, 1),
    font-size 220ms cubic-bezier(0.32, 0.72, 0, 1),
    color 160ms ease;
  /* Reset the flex-column auth-card label rules above. */
  display: inline-block;
  gap: 0;
}
/* Floated state: focus, OR has-value (placeholder is hidden),
   OR has been autofilled by the browser. Keep the same family /
   case / spacing as the resting label - only size + position
   change - so the transition reads as a single shape morphing,
   not a font-swap. */
.auth-card .floating-field input:focus + label,
.auth-card .floating-field input:not(:placeholder-shown) + label,
.auth-card .floating-field input:autofill + label,
.auth-card .floating-field input:-webkit-autofill + label {
  top: 12px;
  transform: translateY(0);
  font-size: 0.7rem;
  color: var(--ink);
}
/* Suppress Chrome / Safari's pale-yellow autofill bg + replace
   with a bone-matching inset shadow so the input still reads
   as transparent on the bone login bg. The text-fill-color
   override pulls the typed value back to ink (Chrome forces
   dark blue otherwise). */
.auth-card .floating-field input:-webkit-autofill,
.auth-card .floating-field input:-webkit-autofill:hover,
.auth-card .floating-field input:-webkit-autofill:focus,
.auth-card .floating-field input:-webkit-autofill:active {
  -webkit-box-shadow: 0 0 0 1000px var(--bone) inset;
  -webkit-text-fill-color: var(--ink);
  caret-color: var(--ink);
  /* Re-state the ink border because the box-shadow inset
     replaces the visual surface but doesn't touch the border. */
  border: 1px solid var(--ink);
}
@media (prefers-reduced-motion: reduce) {
  .auth-card .floating-field label { transition: none; }
}

/* ---- Inline circular submit (Apple-style) ----
   A 40px ink disc that lives inside the password field. The
   form's :has() rule below fades it in only when BOTH the email
   and password inputs have content (placeholder-shown == false
   on both). Pressing it submits the form just like a normal
   submit button - JS handler doesn't need to change. */
.floating-field--with-submit input[type="password"] {
  /* Reserve room on the right so the typed value never sits
     under the submit disc. */
  padding-right: 60px;
}
/* When both a reveal toggle AND a submit chevron sit inside
   the same field, push the value-text further left so it
   never crosses either icon. */
.floating-field--with-toggle input[type="password"],
.floating-field--with-toggle input[type="text"] {
  padding-right: 102px;
}

/* ---- Password reveal toggle (eye icon) ----
   36px tappable target inside the field, sitting to the
   left of the submit chevron. Default state shows the eye
   without a slash (password is hidden, click to reveal); on
   .is-shown the slash crosses the eye (password is visible,
   click to hide). top:50% + translateY(-50%) is geometrically
   centred against the input's box height; the asymmetric
   floating-label padding (26/10) doesn't shift the box
   centre, only the typed-text baseline. */
.password-toggle {
  position: absolute;
  /* Default sits left of the inline chevron (chevron is at
     right:10 + 40px wide; 4px gap → right:54px). When the
     field has no inline submit (e.g. the first password field
     on /reset-password), the override rule below slides the
     toggle into the right:10 slot so it doesn't float in
     dead space. */
  right: 54px;
  top: 50%;
  transform: translateY(-50%);
  width: 36px;
  height: 36px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  background: transparent;
  border: none;
  border-radius: 999px;
  cursor: pointer;
  color: var(--ink);
  /* Hidden by default - only appears once the password
     field has content (typed or autofilled). Without input
     to reveal/hide, the icon has no purpose, and showing it
     alongside the chevron looks unbalanced. */
  opacity: 0;
  pointer-events: none;
  transition: opacity 200ms cubic-bezier(0.32, 0.72, 0, 1);
  padding: 0;
  line-height: 0;
}
.floating-field--with-toggle:has(input:not(:placeholder-shown)) .password-toggle,
.floating-field--with-toggle:has(input:-webkit-autofill) .password-toggle,
.floating-field--with-toggle:has(input:autofill) .password-toggle {
  opacity: 0.55;
  pointer-events: auto;
}
/* Toggle-only fields (no inline chevron) get the toggle in
   the rightmost slot so it doesn't sit in dead space halfway
   in. Targets fields that have --with-toggle but NOT
   --with-submit (e.g. the first password field on
   /reset-password). */
.floating-field--with-toggle:not(.floating-field--with-submit) .password-toggle {
  right: 10px;
}
@media (hover: hover) {
  .password-toggle:hover { opacity: 1; }
}
.password-toggle:focus-visible {
  outline: none;
  opacity: 1;
  box-shadow: 0 0 0 2px var(--ink);
}
.password-toggle .pwt-icon {
  width: 22px;
  height: 22px;
  display: block;
}
/* Default state - password is hidden, show the eye. The
   eye-slash sits in the same flow but display: none so it
   doesn't take layout space. Toggle swaps which one renders. */
.password-toggle .pwt-icon-slash { display: none; }
.password-toggle.is-shown .pwt-icon-eye { display: none; }
.password-toggle.is-shown .pwt-icon-slash { display: block; }
@media (prefers-reduced-motion: reduce) {
  .password-toggle { transition: none; }
}
.floating-submit {
  appearance: none;
  position: absolute;
  right: 10px;
  top: 50%;
  transform: translateY(-50%) scale(0.85);
  width: 40px;
  height: 40px;
  border-radius: 999px;
  border: none;
  background: var(--ink);
  color: var(--white);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  opacity: 0;
  pointer-events: none;
  transition:
    opacity 200ms cubic-bezier(0.32, 0.72, 0, 1),
    transform 220ms cubic-bezier(0.32, 0.72, 0, 1),
    background-color 160ms ease,
    color 160ms ease;
}
.floating-submit svg {
  width: 26px;
  height: 26px;
  display: block;
}
/* Submit chevron visibility:
     - shows when the password field's wrapper has focus
       anywhere within it (input or chevron - :focus-within
       keeps it visible mid-click)
     - stays visible once the password has content, even
       after focus moves elsewhere (so tabbing to the eye
       toggle or the email field doesn't make the submit
       affordance vanish)
   Disappears only when the password is BOTH empty AND
   unfocused - i.e. there's nothing to submit and the user
   isn't engaged with that field. The input selector matches
   both type="password" and type="text" (after the reveal
   toggle flips it). */
.auth-card form:has(.floating-field--with-submit:focus-within) .floating-submit,
.auth-card form:has(.floating-field--with-submit input:not(:placeholder-shown)) .floating-submit,
.auth-card form:has(.floating-field--with-submit input:-webkit-autofill) .floating-submit,
.auth-card form:has(.floating-field--with-submit input:autofill) .floating-submit {
  opacity: 1;
  pointer-events: auto;
  transform: translateY(-50%) scale(1);
}

/* No on-blur red. Validation feedback only fires on submit
   click - the JS submit handler sets aria-invalid on each
   field that fails checkValidity(), and the existing
   [aria-invalid="true"] rule paints those red. The per-field
   input listener clears aria-invalid the moment the field
   becomes valid, so red drops live as the user fixes each. */

/* ---- Subtle shake on auth fail ----
   Three short 2px oscillations over ~280ms. translate3d +
   will-change hint the compositor to run the animation on
   the GPU at the device's native refresh rate (120Hz on
   ProMotion / high-refresh displays) - without these the
   browser sometimes runs CSS transforms on the main thread
   at 60Hz and the wiggle reads choppy. */
@keyframes auth-shake {
  0%, 100% { transform: translate3d(0, 0, 0); }
  25%      { transform: translate3d(-2px, 0, 0); }
  50%      { transform: translate3d(2px, 0, 0); }
  75%      { transform: translate3d(-2px, 0, 0); }
}
.auth-card form.is-shaking {
  animation: auth-shake 280ms ease-in-out;
  will-change: transform;
}
@media (prefers-reduced-motion: reduce) {
  .auth-card form.is-shaking { animation: none; }
}

/* ---- Loading spinner on the submit chevron ----
   Replaces the chevron glyph with a small rotating ring
   while the submit fetch is in flight. Same ink disc; only
   the inner mark changes. JS adds .is-loading on submit
   start, removes on response. */
.floating-submit.is-loading {
  pointer-events: none;
}
.floating-submit.is-loading svg { opacity: 0; }
.floating-submit.is-loading::after {
  content: "";
  position: absolute;
  width: 18px;
  height: 18px;
  border: 1.5px solid currentColor;
  border-top-color: transparent;
  border-radius: 999px;
  animation: floating-submit-spin 0.7s linear infinite;
}
@keyframes floating-submit-spin {
  to { transform: rotate(360deg); }
}
@media (prefers-reduced-motion: reduce) {
  .floating-submit.is-loading::after { animation: none; }
}

/* ---- Live password requirements (Apple-style) ----
   Always reserves its layout space so toggling visibility
   doesn't shove the auth-footer up and down - only opacity
   transitions. Each <li> ticks itself green via .is-met
   (set by JS as regex rules pass); the check inside the
   icon fades in on .is-met. */
.pw-rules {
  list-style: none;
  margin: 8px 0 0;
  padding: 0 22px;
  /* 2-col grid so the four rules lay out as a 2x2 block
     instead of a four-row vertical stack — keeps the area
     between password field and submit shallower. */
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 4px 16px;
  font-size: 0.78rem;
  color: var(--fg-dim);
  opacity: 0;
  pointer-events: none;
  transition: opacity 200ms cubic-bezier(0.32, 0.72, 0, 1);
}
/* Reveal when the password input is focused or has content.
   The list always occupies its natural height; only opacity
   changes, so neither the message nor the footer below shifts
   when the rules appear or disappear. */
.auth-card form:has(#signup-password:focus) .pw-rules,
.auth-card form:has(#signup-password:not(:placeholder-shown)) .pw-rules,
.auth-card form:has(#signup-password:autofill) .pw-rules,
.auth-card form:has(#signup-password:-webkit-autofill) .pw-rules,
.auth-card form:has(#reset-password-new:focus) .pw-rules,
.auth-card form:has(#reset-password-new:not(:placeholder-shown)) .pw-rules,
.auth-card form:has(#reset-password-new:autofill) .pw-rules,
.auth-card form:has(#reset-password-new:-webkit-autofill) .pw-rules {
  opacity: 1;
  pointer-events: auto;
}
.pw-rules li {
  display: flex;
  align-items: center;
  gap: 8px;
  transition: color 200ms ease;
}
.pw-rule-icon {
  width: 14px;
  height: 14px;
  flex-shrink: 0;
  display: block;
}
.pw-rule-icon .pw-check {
  opacity: 0;
  transition: opacity 200ms cubic-bezier(0.32, 0.72, 0, 1);
}
.pw-rules li.is-met {
  color: var(--ink);
}
.pw-rules li.is-met .pw-rule-icon circle {
  stroke: var(--ink);
}
.pw-rules li.is-met .pw-rule-icon .pw-check {
  opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
  .pw-rules,
  .pw-rules li,
  .pw-rule-icon .pw-check { transition: none; }
}
/* Mobile-only shortened text. The desktop spans render the
   full phrasing ("10 or more characters" etc.); on narrow
   screens they wrap to a second line inside the 2-col grid
   and look broken. The mobile spans use abbreviated forms
   ("10+ characters", "Lowercase letter", etc.) that fit on
   one line at typical phone widths. Toggle is pure CSS via
   display swap — desktop is byte-identical to before. */
.pw-rule-text--mobile { display: none; }
@media (max-width: 480px) {
  .pw-rule-text--desktop { display: none; }
  .pw-rule-text--mobile { display: inline; }
}
/* No hover state - the chevron's appearance is enough of an
   affordance on its own. Keyboard users still get a focus
   ring so it's not invisible to them. */
.floating-submit:focus-visible {
  outline: none;
  box-shadow: 0 0 0 2px var(--ink);
}
.floating-submit:active { transform: translateY(-50%) scale(0.94); }
@media (prefers-reduced-motion: reduce) {
  .floating-submit,
  .floating-submit:active { transition: none; transform: translateY(-50%); }
}
.auth-card .primary-button { margin-top: 8px; }
/* Sitewide .msg pill - the inline-alert pattern used by every
   auth form, settings save, project invite, etc. Fully rounded,
   content-width (hugs the text), centred within its container,
   and animates between hidden and visible states so the form
   below doesn't snap-jump when an error appears or clears.
   Anatomy:
     - At rest (hidden via the `hidden` HTML attribute): height
       collapses to 0, padding/border collapse to 0, opacity
       fades out, but the element STAYS in display:block (the
       UA `[hidden] { display: none }` is overridden so the
       transition can fire across the boundary).
     - On show (attribute removed): height/padding/border/opacity
       interpolate back. `interpolate-size: allow-keywords` is
       what lets `height: 0` -> `height: auto` actually animate
       (modern Chrome + Safari; older browsers fall back to a
       snap, which is what they did before).
   Any code that toggles `el.hidden = true/false` (the existing
   showMessage/hideMessage helpers in public/auth.js, or per-
   page setMsg helpers like on /join + /plugin-auth) gets the
   animation for free. */
.msg {
  display: block;
  overflow: hidden;
  box-sizing: border-box;
  width: fit-content;
  max-width: 100%;
  margin-left: auto;
  margin-right: auto;
  border-radius: 999px;
  border-style: solid;
  border-color: var(--border);
  background: var(--bone);
  color: var(--ink);
  font-size: 0.85rem;
  line-height: 1.4;
  text-align: center;
  interpolate-size: allow-keywords;
  transition:
    height 320ms cubic-bezier(0.32, 0.72, 0, 1),
    opacity 240ms ease,
    padding-top 320ms cubic-bezier(0.32, 0.72, 0, 1),
    padding-bottom 320ms cubic-bezier(0.32, 0.72, 0, 1),
    border-top-width 320ms cubic-bezier(0.32, 0.72, 0, 1),
    border-bottom-width 320ms cubic-bezier(0.32, 0.72, 0, 1),
    margin-top 320ms cubic-bezier(0.32, 0.72, 0, 1),
    margin-bottom 320ms cubic-bezier(0.32, 0.72, 0, 1);
}
/* Hidden state. Specificity (0,2,0) beats UA's `[hidden]`
   (0,1,0), so display stays block and the transition above can
   actually fire across height/padding/border/margin. */
.msg[hidden] {
  display: block;
  height: 0;
  opacity: 0;
  padding-top: 0;
  padding-bottom: 0;
  padding-inline: 16px;
  border-top-width: 0;
  border-bottom-width: 0;
  border-left-width: 1px;
  border-right-width: 1px;
  margin-top: 0;
  margin-bottom: 0;
  pointer-events: none;
}
/* Visible state. */
.msg:not([hidden]) {
  height: auto;
  opacity: 1;
  padding: 10px 18px;
  border-width: 1px;
  margin-top: 10px;
  margin-bottom: 0;
}
.msg[data-kind="error"]   { border-color: var(--danger); color: var(--danger); }
.msg[data-kind="success"] { border-color: var(--ink);    color: var(--ink); }

/* Honour reduced-motion preference - skip the slide/fade and
   just toggle visibility instantly. */
@media (prefers-reduced-motion: reduce) {
  .msg { transition: none; }
}

/* Invalid form fields - set on submit failure (e.g. wrong
   credentials at login). Red border only, no halo. The red
   stays even when the user clicks back into the field; the
   border drops back to ink the moment the input event fires
   (JS clears aria-invalid). */
.auth-card input[aria-invalid="true"],
.auth-card input[aria-invalid="true"]:focus {
  outline: none;
  border-color: var(--danger);
}

.auth-card .auth-divider {
  display: flex;
  align-items: center;
  gap: 12px;
  color: var(--fg-dim);
  font-family: var(--font-mono);
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  font-size: 0.7rem;
  margin: 8px 0;
}
.auth-card .auth-divider::before,
.auth-card .auth-divider::after {
  content: "";
  height: 1px;
  flex: 1;
  background: var(--border);
}
.auth-card .social-button {
  appearance: none;
  background: var(--bone);
  border: 1px solid var(--border);
  color: var(--ink);
  padding: 11px 16px;
  border-radius: 999px;
  font-size: 0.9rem;
  font-weight: 500;
  font-family: inherit;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
  transition: background 120ms ease, border-color 120ms ease;
}
@media (hover: hover) {
  .auth-card .social-button:hover:not(:disabled) {
    background: var(--bone);
    border-color: var(--ink);
  }
}
.auth-card .social-button:disabled { opacity: 0.45; cursor: not-allowed; }

.auth-card .auth-footer {
  margin-top: 24px;
  padding-top: 16px;
  font-size: 0.85rem;
  color: var(--fg-dim);
  text-align: center;
  font-weight: 500;
}
.auth-card .auth-footer a { color: var(--ink); text-decoration: underline; }
.auth-card .auth-footer a:hover { color: var(--ink); }

/* Bare auth card - no white surface, no border, no padding,
   no shadow. Used on the chartreuse login page where the form
   sits directly on the brand bg without a containing chrome.
   The form's natural max-width (matching the input pills) is
   the only thing constraining horizontal extent. */
.auth-card--bare {
  background: transparent;
  border: none;
  padding: 0;
}
/* Match the submit button's height to the input pills. The
   floating-label inputs have asymmetric vertical padding
   (26px top, 10px bottom) which works out to ≈60px rendered
   height after the 1px border + line-height. The button
   normally sits at ≈44px; lift it via min-height so the
   form's three pills (email + password + submit) read as a
   uniform stack. */
.auth-card--bare .primary-button {
  min-height: 60px;
}
.auth-card--bare h1 {
  font-size: clamp(2rem, 4vw, 2.5rem);
  font-weight: 600;
  letter-spacing: -0.02em;
  margin: 0 0 28px;
  text-align: center;
  color: var(--ink);
}
/* Auth footer with two states. At rest only "Create an
   account" is visible, sitting dead centre. After a failed
   sign-in (JS adds .is-needed) Forgot password? fades in on
   the left and Create slides smoothly from centre to the
   right edge - flexbox can't transition justify-content, so
   we position both children absolutely and animate the
   transform/left properties instead. The footer reserves a
   fixed height so the parent layout doesn't reflow when the
   children change between centred and edge-anchored. */
.auth-card--bare .auth-footer {
  position: relative;
  height: 1.4rem;
  margin-top: 20px;
  padding: 0 22px;          /* matches the .floating-field input
                               horizontal padding so the link
                               edges line up with the visible
                               start/end of the field text */
}
.auth-card--bare .auth-footer #forgot-link {
  position: absolute;
  top: 50%;
  left: 22px;
  transform: translateY(-50%);
  opacity: 0;
  pointer-events: none;
  white-space: nowrap;
  transition: opacity 280ms cubic-bezier(0.32, 0.72, 0, 1);
}
.auth-card--bare .auth-footer.is-needed #forgot-link {
  opacity: 1;
  pointer-events: auto;
}
/* Create an account starts centred (left:50% + translateX(-50%))
   and on .is-needed slides to right:22px (left:calc(100%-22px)
   + translateX(-100%)). The matched transitions on left and
   transform keep the slide stable. */
.auth-card--bare .auth-footer a[href="/signup"] {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  /* Without white-space:nowrap the element shrink-to-fits
     to whatever width is "available" between its left edge
     and the parent's right edge. As we animate left toward
     the right edge, that available space shrinks to ~22px
     and the text wraps to 3 lines mid-transition. Force a
     single line so width stays at content-intrinsic. */
  white-space: nowrap;
  transition:
    left 380ms cubic-bezier(0.32, 0.72, 0, 1),
    transform 380ms cubic-bezier(0.32, 0.72, 0, 1);
}
.auth-card--bare .auth-footer.is-needed a[href="/signup"] {
  left: calc(100% - 22px);
  transform: translate(-100%, -50%);
}
@media (prefers-reduced-motion: reduce) {
  .auth-card--bare .auth-footer #forgot-link,
  .auth-card--bare .auth-footer a[href="/signup"] {
    transition: none;
  }
}
/* Signup's auth-footer is a single <span> ("Already have an
   account? Log in") - centre it absolutely inside the same
   reserved-height container so the page layout matches the
   login page exactly. */
.auth-card--bare .auth-footer > span {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  white-space: nowrap;
}

/* Footer links - calm at rest, underline on hover. No weight
   shift (the underline is the affordance), so no need for the
   ::before phantom or wght transitions. */
.auth-card--bare .auth-footer a {
  text-decoration: none;
  font-weight: 500;
  color: var(--ink);
  transition: color 120ms ease;
}
.auth-card--bare .auth-footer a:hover,
.auth-card--bare .auth-footer a:focus-visible {
  text-decoration: underline;
  text-underline-offset: 3px;
  outline: none;
}

/* Fullbleed auth wrap - reserve the entire space below the nav
   so the footer ships below the fold even on tall viewports.
   Matches the pattern used elsewhere on the site for hero
   sections that must own the first viewport. */
.auth-wrap--fullbleed {
  min-height: calc(100vh - var(--nav-h));
  min-height: calc(100svh - var(--nav-h));
}

/* ----------------- Status pills & status dots ----------------- */
/*
  Plugin-style status dots: small filled circle + uppercase mono
  label. Used on the quota chip, status lines, and anywhere we
  want to echo the plugin's chartreuse-dot + mono-caption pattern.
*/
.status-dot {
  display: inline-block;
  width: 8px; height: 8px;
  border-radius: 999px;
  background: var(--ink);
  vertical-align: middle;
  margin-right: 8px;
}
.status-dot--ok      { background: var(--ink); }
.status-dot--warn    { background: var(--chartreuse); }
.status-dot--err     { background: var(--danger); }
.status-dot--off     { background: transparent; border: 1px solid currentColor; }

/* Accessibility-visible-only class for screen-reader labels. */
.visually-hidden {
  position: absolute !important;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  border: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
}

/* Keyboard focus ring - chartreuse glow at 3 px. Only shows for
   keyboard users (focus-visible) so mouse clicks don't light it
   up. Apple-level polish. */
:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 2px;
}

/* ================================================================
   Responsive refinements
   ================================================================
   Mobile-first assumptions baked into the base rules above:
   - font-size: 1rem (16px) on all inputs to kill iOS zoom
   - container widths use max-width (shrink below it naturally)
   - padding uses clamp() to scale down on narrow viewports

   Breakpoints (from desktop-down):
     1024 px - tablet landscape / small laptop
      720 px - tablet portrait; nav collapses to menu
      640 px - large phone; multi-col grids go 1-col
      480 px - compact phone; tighter spacing, smaller hero type
   ================================================================ */

@media (max-width: 720px) {
  main {
    padding: 48px clamp(20px, 5vw, 32px) 72px;
    gap: 28px;
  }
  header h1         { font-size: clamp(1.75rem, 7vw, 2.25rem); }
  header .tagline   { font-size: 0.95rem; }

  #mivia-header { gap: 10px; }

  .dropzone         { padding: 40px 20px; }
  .dropzone--hero   { padding: 48px 20px; }
  .dropzone--compact{ padding: 28px 20px; }
  .dropzone-headline { font-size: 1rem; }

  .primary-button,
  .accent-button,
  .secondary-button,
  .reset-button {
    /* iOS / Android tap-target min 44 px; 46 px clears pointer
       sloppiness on borders. */
    min-height: 46px;
    width: 100%;
  }

  .auth-card { padding: 28px 22px; }
  .auth-card--wide { padding: 32px 22px; }
}

@media (max-width: 480px) {
  main { padding: 40px 18px 64px; }
  .auth-wrap { padding: 40px 16px; }
  .auth-card { padding: 24px 20px; border-radius: 12px; }
  h1 { font-size: clamp(1.5rem, 8vw, 2rem); }

  #mivia-header { padding: 10px 16px; }
  .mivia-header-link { padding: 6px 8px; font-size: 0.86rem; }

  .dropzone { padding: 32px 16px; }
  .dropzone--hero { padding: 40px 16px; border-radius: 16px; }
}
