/* ── Reset / tokens — FotoLekti, on the boulpik Palefò design system ──
   The palette mirrors boulpik/css/app.css: an RGB-triplet token system
   (--accent-N-rgb) so colors compose into gradients and translucent tints.
   The legacy fotolekti tokens (--accent, --bg-input, --border, --radius …)
   are kept as ALIASES derived from the new system, so every existing rule
   recolors automatically. See CANON.md. */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
[hidden] { display: none !important; }

/* The shared file picker is hidden ACCESSIBLY (not display:none), so a
   <label for="picker-input"> reliably opens the chooser on iOS Safari and
   mobile webviews — where labels tied to a display:none file input silently
   do nothing. The element stays in the layout at 1px, fully transparent.
   NOTE: deliberately NO `pointer-events: none` here — on Chrome that can stop
   the <label> from opening the file dialog. */
.visually-hidden-input {
  position: absolute;
  width: 1px; height: 1px;
  padding: 0; margin: -1px;
  border: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
  opacity: 0;
}

:root {
  /* ── NervalFrost scheme (adopted from mesaj.palefo.dev, 2026-08-08 —
     see CANON.md "Approved deviations"). The token NAMES keep the original
     CANON-LOCK [10] structure so every existing rule recolors untouched;
     only the VALUES changed, mirroring mesaj's static/css/styles.css. ── */

  /* In-frame background + page wash behind the phone frame (CANON-LOCK [1]).

     Light mode is TWO surfaces, the way Mesaj Pa m is: an OUTER colour filling
     the frame — which is also what iOS tints the status bar and URL bar with —
     and an INNER card that holds every piece of app content, inset on all four
     sides with rounded corners. Before this the app was ONE flat surface to the
     edge: content sat directly on the colour Mesaj uses as its backdrop, so there
     was no card, no corner, and nothing to separate the app from the phone.

     The card is not flat white — Franck did not want it to read as a stark card
     against the phone. It is a diagonal gradient: near-white at the top-left,
     reaching the original periwinkle #e3e5fa by 62% of the way down and holding
     it, so the lower half is the surface the app always had. A linear ramp
     across the whole card was tried first and barely read: the periwinkle only
     arrived in the corner, and the camera frame covers the middle.

     Because the card's bottom-right comes close to the outer colour, the outer
     was darkened until that weakest corner still separates. Measured: outer
     #d2d6f3 is 1.150:1 against the card's #e3e5fa bottom-right (Mesaj's whole
     card is 1.154:1) and 1.350:1 against its #f7f8fc top-left. Keeping the
     earlier #dcdff7 outer would have left that corner at 1.057:1, invisible.
     The dark skin draws the same card with the same inset and radius, inverted:
     a lighter wash around its near-black card (css/alt.css). */
  --app-bg-color:   #d2d6f3;         /* OUTER — the frame, and iOS's bars */
  --card-bg:        linear-gradient(135deg,
                      #f7f8fc 0%, #f7f8fc 12%, #e3e5fa 62%, #e3e5fa 100%);
                                     /* INNER — the card the app sits on */
  --card-inset:     8px;             /* card to frame edge, all four sides */
  --card-radius:    25px;            /* mesaj's .dash-panel radius */

  /* Top-bar geometry, named because the gear's position is COMPUTED from it
     (see #topbar .settings-btn). A literal repeated in that calc would go stale
     the day any of these moved. */
  --bar-pad:        12px;            /* #topbar side padding */
  --brand-pad:      44px;            /* .brand side padding (room for the gear) */
  --gear-box:       44px;            /* the gear's tap target */
  --gear-glyph:     26px;            /* the cog drawn inside it; its ink fills this box */
  --vf-share:       0.9;             /* the photo frame's width, as a share of the stage */
  /* The gear's centre sits this far BELOW the title's line-box centre. By
     bounding box the cog and the word "FotoLekti" were already centred (both ink
     rows 29-54 on the light skin at 390x844; 21-46 on dark) — and the gear still looked high, because a word with
     tall letters carries its weight in the lowercase body, not its box. Measured
     per pixel row: the dense x-height band ("oto", "e", "i") is rows 38-54,
     centred on 46; the gear was centred on 42. Mesaj Pa m nudges its own gear 3px
     by hand for the same reason. The title is 31px at every size and in both
     subtitle states, so this holds everywhere. */
  --gear-optical-drop: 4px;
  /* The wash has to READ as a different surface from the screen inside the
     bezel. An earlier ramp passed through #e3e5fa, which was then the in-frame
     colour, so at ≥480 the phone dissolved into the page. Deeper blue → purple,
     well clear of the frame interior at every stop, so the device reads as an
     object sitting on a surface. */
  --page-bg: linear-gradient(135deg,
    #8fa3dd 0%, #9d9ee0 38%, #ab97dd 68%, #bb95d4 100%);
  --frame-shadow-1: rgba(85, 70, 150, 0.22);
  --frame-shadow-2: rgba(85, 70, 150, 0.13);

  /* ── Brand palette (RGB triplets) — NervalFrost violet family ── */
  --accent-1-rgb:      124,  77, 255;  /* violet   — mesaj mic-disc #7c4dff */
  --accent-2-rgb:      49,  86, 142;   /* navy     — --nf-blue (unchanged) */
  --accent-3-rgb:      26,  42,  62;   /* deep navy — text + dark accents */
  --accent-purple-rgb:  84,  44, 194;  /* deep violet — mesaj #542CC2 */

  /* Brand gradient (violet → deep violet), mesaj's 160deg disc gradient. */
  --gradient-primary: linear-gradient(160deg,
    rgb(var(--accent-1-rgb)) 0%,
    rgb(var(--accent-purple-rgb)) 100%);

  /* Soft NervalFrost shadows (mesaj card + button shadows). */
  --shadow-color: rgba(85, 70, 150, 0.28);
  --btn-shadow:   rgba(84, 44, 194, 0.35);

  /* Palefò radius tokens. */
  --radius-md:   12px;
  --radius-lg:   24px;
  --radius-full: 999px;

  /* Text. */
  --text-primary:   rgb(49, 86, 142);
  --text-secondary: rgba(49, 86, 142, 0.62);
  --text-muted:     rgba(49, 86, 142, 0.45);

  /* ── Legacy aliases (derived from the system above) ──
     Existing fotolekti rules reference these names; redefining them here
     in terms of the Palefò tokens reskins those rules without touching them. */
  --bg-surface:     #f5f6fb;            /* Nerval Frost — chrome surfaces */
  --bg-panel:       rgba(84, 44, 194, 0.06);
  --bg-input:       rgba(84, 44, 194, 0.08);   /* mesaj --nf-disc lavender */
  --bg-input-hover: rgba(84, 44, 194, 0.16);
  --accent:         rgb(var(--accent-1-rgb));
  --accent-hover:   #8f65ff;
  --accent-soft:    rgba(var(--accent-1-rgb), 0.12);
  --accent-purple:  rgb(var(--accent-purple-rgb));
  --accent-gradient: var(--gradient-primary);
  --good:           #5fb088;            /* deepened so it reads on glass */
  --good-deep:      #4a9a73;
  --warn:           #f5a623;
  --danger:         #e94560;
  --danger-hover:   #ff6b81;
  --border:         rgba(160, 140, 225, 0.5);  /* mesaj --nf-frame-stroke */
  --border-strong:  rgba(var(--accent-1-rgb), 0.55);
  --radius:         var(--radius-md);
  --topbar-h:       86px;            /* wordmark + subtitle + gear, one bar (CANON-LOCK [4] deviation) */
  --statusbar-h:    24px;
  --hero-h:         130px;           /* hero banner (CANON-LOCK [3]; 168 → 130 — see CANON.md.
                                        The BAND is 130: a 10px gap plus the 120px artwork. 10 and
                                        not 18, because what has to look centred is the gap from the
                                        TITLE'S INK, and the bar carries ~16px of its own empty space
                                        below the glyphs. Measured with an 18px band gap: 36px from
                                        the text, 19px to the frame — the imbalance had flipped, not
                                        gone. The matching gap below is --stage-pad-top.
                                        Mesaj Pa m's .mic-hero band is the family analog and is
                                        fluid: measured 122 / 124 / 128 / 130px at 360x640,
                                        390x700, 390x844, 430x932 — but it wraps a disc AND its
                                        caption, so the bands are not like for like. What is
                                        comparable is the artwork: 120px here against mesaj's 86px
                                        disc. FotoLekti's illustration is the larger of the two. */
  --logo-h:         44px;            /* Palefò logo footer band (CANON-LOCK [7]) */
  --logo-img-h:     42px;            /* the seal itself inside that band */
  --wordmark-size:  31px;            /* the FotoLekti title */
  --cap-size:       11px;            /* the three button captions */
  /* Vertical rhythm of the chrome stack. Fixed on the canonical 430x932 frame;
     scaled fluidly on short mobile viewports (see the responsive block after
     CANON-LOCK [1]). */
  --stage-gap:       0px;            /* hero bottom → stage top; the gap below the artwork is
                                        --stage-pad-top, so that it can equal the gap above it */
  --stage-gap-tight: 6px;            /* result state (keeps the canonical +6 offset) */
  --chips-gap:       40px;           /* title block → language chips */
  --stage-pad:       24px;           /* #stage inner padding (sides + bottom) */
  --hero-pad-top:    10px;           /* gap from the bar to the artwork. Paired with
                                        --stage-pad-top below it; the two sum to 36 so the frame's
                                        top never moves, and the split between them is what makes
                                        the INK gaps equal — measured 28px above the artwork and
                                        27px below (equal within a pixel, and the residual is
                                        instrument threshold, not layout), in both the one-line and
                                        two-line bar states.
                                        Unequal on purpose: the bar keeps empty space under the
                                        title's glyphs, and what has to look balanced is the gap
                                        from the INK, not from the band edge. */
  --stage-pad-top:   26px;           /* top only: the gap between the artwork and the photo frame.
                                        26 + the band's 10 = 36, the same total as before, so the
                                        frame does not move; the artwork just sits 8px higher, which
                                        is what puts equal INK gaps above and below it.
                                        Kept separate from --stage-pad because the BOTTOM padding
                                        is what sets the Palefò seal's breathing room, and the two
                                        jobs want different numbers. That bottom padding used to
                                        equal --statusbar-h exactly, which centred the seal; it is
                                        now --stage-pad minus --row-drop. The seal does not move —
                                        the captions come down toward it, because the whole row
                                        slides down to centre the discs. (The equality with
                                        --statusbar-h was only ever exact here in :root; the
                                        ladder's bands had already parted the two, 18 against 22
                                        at the 770 step.) See --row-drop. */
  --pane-gap:        24px;           /* gap between viewfinder and capture row */
  --row-drop:        16px;           /* Centres the BUTTON between the photo frame and the
                                        Palefò seal. The row box (disc + caption) was already
                                        centred, so measuring that found nothing wrong; the
                                        developer was measuring the disc, and by that reading
                                        the gaps were 28 above and 53 below at 430x932 — the
                                        caption block sits inside the lower gap and takes about
                                        32px of it. This slides the row down so the DISC has
                                        equal air either side, and the caption rides down with
                                        it. Nothing else moves: the seal, the frame and the
                                        artwork all stay put, and the photo window's height is
                                        unchanged to 0.00px at every viewport, because the value
                                        is ADDED to .stage-pane's gap and SUBTRACTED from
                                        #stage's bottom padding in the same step.
                                        It is per-band because the space is. At 430x932 and
                                        390x932 it lands 40/41 — centred. Below roughly 880 tall
                                        it cannot: the caption is inside the lower gap, so that
                                        gap can never be shorter than the caption plus a little
                                        air, and the disc stops short of centre. 390x664 gets
                                        27/38 against 19/46 before.
                                        Every band except the top one is the largest drop that
                                        still leaves the caption 6px clear of the seal — that
                                        floor is what binds, not the centring, and each band
                                        lands on it exactly. At 430x932 and 390x932 the disc
                                        reaches centre first and the caption stops 9px clear.
                                        The 580 band is 9 rather than 11 for that reason: 11
                                        centres better on paper but leaves the caption 4px off
                                        the seal, measured.
                                        Per band, A above / B below the disc, 390 wide unless
                                        noted:
                                          932 40/41  (430x932 the same)   880 33/38
                                          844 33/38   820 31/38   771 31/38   760 29/38
                                          700 29/38   681 29/38   664 27/38   640 24/38
                                          620 24/38   600 23/38   581 23/38   568 20/38
                                          540 20/38   320x480 20/38   320x430 20/38
                                        REACH: the gap sits on .stage-pane, so camera, fallback,
                                        working and error gain it between their children, while
                                        history and result set their own row-gap (16 and 14) and
                                        do not move. All six lose the same from the floor, which
                                        comes off #stage. Both skins get this identically — it is
                                        in the shared stylesheet, verified equal on every field
                                        at eight viewports. */
  /* The floor: the shortest frame the layout can hold with the camera window
     still at its 240px minimum. Below this the frame stops shrinking and clips
     off the bottom of the viewport rather than letting the panes collapse into
     the chrome. It was 640px (Galaxy A-class) when the chrome above the stage
     was fixed; the shed ladder at the end of this file now strips that chrome
     in order, so the frame can follow the viewport much further down: 104px
     on both skins (640 → 536; the two shared one floor once the dark skin
     gained the card). */
  /* 536 on both skins now (the dark skin's 520 override went when it gained
     the card): the inset takes 2 × --card-inset = 16px of height out of every
     layout.
     Light's redesigned capture row is 24px shorter, so 512 would still hold a
     240px camera window — and it was tried: the shorter frame lifted the + card
     24px on screens at the floor. Measured at 320x480 with each language's text
     confirmed on the card and its fonts loaded: its top edge went above the
     screen in Spanish, Portuguese and Russian (-4.9px) and Korean (-6.2px), and
     in German and Japanese, whose longer rows make the card tallest, far enough
     to put most of the title off-screen (-22.9/-29.2px, against -8.1/-14.4 on
     live). At 536 the card sits ~9px LOWER than live there, in every language. */
  --frame-min-h:    536px;
  --safe-top:       env(safe-area-inset-top, 0px);
  --safe-bottom:    env(safe-area-inset-bottom, 0px);
}

html, body {
  height: 100%;
  overflow: hidden;
  font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif;
  font-size: 16px;
  color: var(--text-primary);
  background: var(--app-bg-color);
  -webkit-tap-highlight-color: transparent;
  touch-action: manipulation;
  user-select: none;
  -webkit-user-select: none;
}
body {
  display: flex;
  justify-content: center;
  align-items: safe center;
}

button { font-family: inherit; cursor: pointer; border: none; background: none; color: inherit; }

/* ── CANON-LOCK [1]: phone-frame simulation ──
   Mobile (<480px): edge-to-edge passthrough. Desktop/tablet (>=480px):
   a simulated 430x932 iPhone frame, centered on the --page-bg wash, with a
   black bezel. .device-frame is the positioning context for the fixed-style
   chrome (#topbar / #stage / #statusbar are absolute within it). js/frame.js
   scales the frame to fit shorter desktop viewports (MIN_SCALE 0.65). */
.device-frame {
  position: relative;
  width: 100vw;
  height: 100vh;
  height: 100dvh;
  overflow: hidden;
  background: var(--app-bg-color);
}
@media (min-width: 480px) {
  body { background: var(--page-bg); }
  .device-frame {
    width: 430px;
    height: 932px;
    max-width: 430px;
    max-height: 932px;
    border-radius: 2.8rem;
    border: 0.7rem solid #0d0d0d;
    box-shadow:
      0 1.8rem 4.5rem var(--frame-shadow-1),
      0 0.6rem 1.6rem var(--frame-shadow-2),
      inset 0 0 0 1px rgba(0, 0, 0, 0.04);
  }
}

/* ── Mobile: ONE canvas, one absorber (the Mesaj Pa m model) ──
   This block used to REFLOW: eight spacing tokens each shrank on their own
   vh curve, so nothing held its proportion to anything else and the layout
   drifted with viewport height. Mesaj Pa m states the alternative outright —
   "the 430x932 phone canvas is the design at EVERY width" — with a single
   element absorbing the leftover height while the top and bottom blocks hold
   their spot.

   FotoLekti now does the same. The tokens keep their canonical values at
   every size (that is the CEILING: nothing grows past the design, extra
   height becomes breathing room, never bigger chrome), and the camera /
   result window is the sole absorber via `flex: 1 1 auto`.

   The FLOOR is --frame-min-h. Below it the frame stops shrinking and simply
   extends past the bottom of the viewport, so the Palefo seal and the token
   count clip off-screen instead of the stage collapsing under the panes and
   letting them paint over the wordmark above and the seal below (measured
   before this change: 29px of overlap up and 28px down at 360x480). Locking
   and clipping is the deliberate trade. The floor is 536px on both skins — see
   the token, and THE SCALE LADDER at the end of this file for why it is no
   longer 640. */
@media (max-width: 479.98px) {
  .device-frame {
    min-height: var(--frame-min-h);
  }
}
/* CANON-LOCK [2]: background glow layer — sits behind all chrome (z-index 0). */
.bg-wrapper {
  position: absolute;
  /* The card. The glow layer (.bg-top) lives inside it and is clipped by its
     radius, so the card warms to violet at the top exactly as the flat surface
     did — rather than the glow painting only the thin outer sliver. Both skins
     inset it the same 8px now. */
  inset: var(--card-inset);
  border-radius: var(--card-radius);
  background: var(--card-bg);
  pointer-events: none;
  z-index: 0;
  overflow: hidden;
}
.bg-top {
  position: absolute;
  top: 0; left: 0; right: 0;
  height: 55%;
  background: radial-gradient(ellipse 70% 50% at 50% 22%,
    rgba(var(--accent-1-rgb), 0.16) 0%, transparent 70%);
}

/* ── Top bar ── */
/* The bar carries the wordmark AND the gear on one line, mesaj's arrangement
   (its h1.page-title sits at the very top with the gear overlapping the same
   line).

   The gear is positioned against .brand-line — the TITLE's own line box — not
   against the bar. Pinned to the bar it was 8px low at 390x700 and 4px low at
   390x844: one offset cannot serve both, because the ladder sheds the subtitle
   and the title then re-centres inside a shorter bar. Riding the line box, it
   is aligned in both states by construction. */
#topbar {
  position: absolute;
  /* Inset from the TOP only. Horizontally the bar keeps live's full width: its
     content is the centred wordmark and the gear, and both sit far inside the
     card's side edges on their own (see the note on the card's insets). */
  top: var(--card-inset); left: 0; right: 0;
  height: calc(var(--topbar-h) + var(--safe-top));
  padding-top: var(--safe-top);
  /* Transparent bar — blends into the page wash (no white band). */
  background: transparent;
  border-bottom: none;
  display: flex; align-items: center; justify-content: center;
  padding-left: var(--bar-pad); padding-right: var(--bar-pad);
  z-index: 10;
}
.brand {
  width: 100%;
  text-align: center;
  min-width: 0;
  pointer-events: none;
  /* Room for the gear at the right, so a long wordmark can never slide under
     it. Symmetric, so the wordmark still centres on the FRAME. */
  padding: 0 var(--brand-pad);
}
.brand-line {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
}
#topbar .settings-btn {
  position: absolute;
  /* The cog's RIGHT INK EDGE sits on the photo frame's right edge, extended up.
     Everything here is measured inside .brand-line, whose width L is the frame
     less both paddings, so `%` is a share of L. The photo frame is --vf-share of
     the stage, which is the frame less 2 × --stage-pad; both are centred. Solving
     "button right − ink inset = frame right" for `right` gives:

       right = 50% × (1 − share) − share × (bar-pad + brand-pad − stage-pad)
               − (gear-box − gear-glyph) / 2

     It tracks every width, and the compressed ladder step's smaller --stage-pad,
     because it is the same arithmetic the frame itself uses. Before this the
     cog stopped 21.9-32.8px short of that edge on the live build, depending on
     width. */
  right: calc(50% * (1 - var(--vf-share))
              - var(--vf-share) * (var(--bar-pad) + var(--brand-pad) - var(--stage-pad))
              - (var(--gear-box) - var(--gear-glyph)) / 2);
  top: calc(50% + var(--gear-optical-drop));
  transform: translateY(-50%);
  /* .brand is pointer-events:none so the wordmark never eats a tap meant for
     the pane behind it; the gear has to opt back in. */
  pointer-events: auto;
}
/* Every transform state has to be restated here, because the centring
   translate above lives at a higher specificity (1,1,0) than the plain
   .settings-btn rules (0,2,0) and would otherwise cancel them. Missing ONE of
   them is silent: the gear simply stops moving. It happened to `.open` —
   measured, the open gear's computed transform was identical to the closed
   one — so the list is: hover, active, open. */
#topbar .settings-btn:hover  { transform: translateY(-50%) rotate(20deg); }
#topbar .settings-btn:active { transform: translateY(-50%) rotate(20deg) scale(0.92); }
#topbar .settings-btn.open   { transform: translateY(-50%) rotate(20deg); }
/* ── CANON-LOCK [3]: Hero banner (boulpik layout) ──
   Cropped/resized illustration, anchored below the bar and above the title. */
.hero-section {
  position: absolute;
  top: calc(var(--card-inset) + var(--topbar-h) + var(--safe-top));
  left: 0; right: 0;
  height: var(--hero-h);
  display: flex;
  align-items: center;
  justify-content: center;
  /* No side padding: the artwork's width is now stated as a share of the FRAME
     (see .hero-section img), so the band must not inset it first. The top gap is
     deliberately SMALLER than --stage-pad-top below it — see that token. */
  padding: var(--hero-pad-top) 0 0;
  z-index: 4;
  pointer-events: none;
}
/* The hero is line art on transparency now, not a photograph, so the card
   treatment it used to wear has to go with it: no radius and no box-shadow,
   because both follow the element box rather than the alpha and would draw a
   rounded rectangle and a drop shadow around empty space.

   SIZED BY WIDTH, NOT HEIGHT. The band used to set the height and the artwork
   just filled it, so the logo's width was a side effect of the viewport's
   height — measured at 84% of the frame on desktop, 79% on a Pro Max, 64% on
   a 360x640 phone, 56% at 360x560. A 1.5x swing driven by something that has
   nothing to do with width. Stating the width as a share of the frame fixes
   the proportion everywhere; the height follows the artwork's own 2.20:1.
   At 70% of a 430px frame that is 301 wide. The band's content box is what
   caps the height — 120px — so the artwork renders 301x120 there. --hero-h has
   moved twice since this rule was written (168 → 124 → 130); the width share is
   what this rule fixes, and that is unchanged. */
.hero-section img {
  width: 70%;
  height: auto;
  max-height: 100%;
  object-fit: contain;
}

/* ── CANON-LOCK [4] deviation: the title block moved INTO the bar ──
   It used to be a centred block below the hero, which cost 84px of vertical
   budget (60px block + an 18px gap + a 6px gap) and pushed the camera window
   down to 20% of a 390x700 screen. The Playfair-italic gradient treatment of
   CANON-LOCK [11] is unchanged — only the position and the type size. */
.title-text {
  font-family: 'Playfair Display', 'Georgia', serif;
  font-style: italic;
  font-weight: 500;
  /* 24 → 31px: Franck asked for 30% bigger. The 8px the bar gains is taken
     back out of the space below it (--hero-h and --stage-pad-top), so the top
     of the photo frame does not move. Tokenised (same 31px here) so the
     short-screen ladder can scale it with everything else. */
  font-size: var(--wordmark-size);
  line-height: 1.15;
  letter-spacing: 0.01em;
  background: var(--gradient-primary);
  -webkit-background-clip: text;
  background-clip: text;
  -webkit-text-fill-color: transparent;
  color: transparent;
}
/* The subtitle belongs WITH the wordmark — it explains the app, not the
   illustration — so it sits under it inside the bar, and it is the first thing
   the short-phone ladder sheds. */
.subtitle {
  margin-top: 1px;
  /* Same font as the title (Playfair Display italic). */
  font-family: 'Playfair Display', 'Georgia', serif;
  font-style: italic;
  font-size: 13px;
  font-weight: 400;
  letter-spacing: 0.2px;
  color: var(--text-primary);
}

/* ── Stage / panes ── */
#stage {
  position: absolute;
  /* Top clears the bar (wordmark + gear) and the hero banner. */
  top: calc(var(--card-inset) + var(--topbar-h) + var(--safe-top) + var(--hero-h) + var(--stage-gap));
  /* Full width, exactly as on live. On the light skin the card IS inset 8px at
     the sides — but the stage's own side padding (--stage-pad, 24px; 18px at the
     compressed ladder step) already keeps every pane's content well inside that
     edge, so insetting the stage as well only took 16px of width from every
     screen. Measured when it did: the Kreyòl history title "Foto pase yo" and
     error text wrapped at 320 wide, Spanish, Russian and German text wrapped at
     360 and 320, and three separate attempts to compensate in the capture row
     alone each failed an audit. Only the vertical offsets carry the inset. */
  left: 0; right: 0;
  /* Leave room for the Palefò logo footer band + the status bar below it. */
  bottom: calc(var(--card-inset) + var(--statusbar-h) + var(--logo-h) + var(--safe-bottom));
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--stage-pad-top) var(--stage-pad)
           calc(var(--stage-pad) - var(--row-drop));
  /* Transparent so the .bg-top glow reads through; sits above .bg-wrapper. */
  background: transparent;
  z-index: 1;
}

/* ── CANON-LOCK [7]: Palefò logo footer ──
   Brand seal anchored at the bottom of the frame, just above the status bar
   (mirrors boulpik). Shown across all panes.

   The band was 54px for a 42px logo, and the whitespace around it measured 28px
   above / 38px below — not centred, and more air than the seal needs. The band
   is 44px now and the status bar 24px, which lands the seal at a measured 25px
   above / 25px below: centred, and the total gap down from 66px to 50px, the
   25% asked for. The logo itself is untouched at 42px. */
.palefo-logo {
  position: absolute;
  left: 0; right: 0;
  bottom: calc(var(--card-inset) + var(--statusbar-h) + var(--safe-bottom));
  height: var(--logo-h);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 2;
  pointer-events: none;
}
.palefo-logo img {
  height: var(--logo-img-h);
  width: auto;
  opacity: 0.92;
}
.stage-pane {
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: calc(var(--pane-gap) + var(--row-drop));
}

/* ── CAMERA pane: live viewfinder + shutter ── */
.viewfinder-wrap {
  /* Photo frame: 90% width, tall portrait box (vertical height boosted ~75%
     into a 3:4 portrait from the old landscape box). Shrinks to fit if the
     stage is short. */
  position: relative;   /* anchors the flip-camera overlay button */
  /* Fill the vertical space like a native photo app: grow into whatever the
     stage offers (the old fixed 3:4 box left dead space above and below on
     tall phones), shrink cleanly on short ones. min-height keeps flexbox
     from letting it collapse under the capture row. */
  flex: 1 1 auto;
  align-self: center;
  width: calc(100% * var(--vf-share));
  max-width: 500px;
  /* The absorber's floor. 140px was what let this collapse to a 308x140
     letterbox — a 2.2:1 banner — on a 390x700 iPhone, because everything above
     it was spending 308px of a 700px screen before the camera started. With
     the wordmark in the bar, the hero at mesaj's 124px and the seal tightened,
     the stage can hold a portrait window, so the floor rises to match. The
     ladder below sheds chrome rather than letting this shrink further. */
  min-height: 240px;
  border-radius: var(--radius-lg);
  overflow: hidden;
  /* Warm-up backdrop: soft frost gradient instead of a black hole while the
     camera stream is being granted; the live video covers it once playing. */
  background:
    radial-gradient(120% 90% at 50% 20%, rgba(var(--accent-1-rgb), 0.28), transparent 60%),
    linear-gradient(180deg, rgba(var(--accent-2-rgb), 0.55), rgba(20, 16, 44, 0.92));
  border: 1px solid rgba(var(--accent-1-rgb), 0.25);
  box-shadow:
    0 0.8rem 1.8rem var(--shadow-color),
    0 0.3rem 0.7rem rgba(var(--accent-2-rgb), 0.22);
  display: flex;
  align-items: center;
  justify-content: center;
}
#viewfinder {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  /* Center taps on the video itself do nothing — only the snap button below
     captures. Prevents accidental pause/play on iOS Safari, and ensures the
     viewfinder is a live preview, not a touchable surface. */
  pointer-events: none;
}
/* Selfie mode mirrors the PREVIEW only (like native camera apps); the
   captured frame stays un-mirrored so text in the photo remains OCR-able. */
#viewfinder.mirrored { transform: scaleX(-1); }

/* Flip-camera toggle — floats in the viewfinder's top-right corner. */
.flip-btn {
  position: absolute;
  top: 10px;
  right: 10px;
  z-index: 2;
  width: 38px;
  height: 38px;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.85);
  color: var(--text-primary);
  display: flex;
  align-items: center;
  justify-content: center;
  border: 1px solid var(--border);
  box-shadow: 0 2px 8px rgba(var(--accent-2-rgb), 0.18);
  transition: transform 0.12s ease, background 0.18s ease;
}
.flip-btn:hover  { background: #fff; color: var(--accent); }
.flip-btn:active { transform: scale(0.92); }
.flip-btn svg    { width: 20px; height: 20px; }

/* Capture row — the camera-app arrangement: gallery · SHUTTER · history, with
   the shutter both the visual and the geometric centre and the + pinned to the
   left edge, out of flow.

   This used to live in css/alt.css only, so the two skins disagreed: the dark
   one centred a 98px shutter between two 70px discs, while the light one fell
   back to DOM order and drew SHUTTER · gallery · history, all three identical
   82px white discs with no prominence at all. Measured left edges at 390px:
   dark 62/146/258, light 83/177/271. Same geometry in both skins now — only
   the ink differs. */
.capture-row {
  position: relative;
  display: flex;
  gap: 14px;
  align-items: center;
  justify-content: center;
  flex-wrap: nowrap;
  width: 100%;
}
/* -2px, measured: at left:0 the 34px + box overlaps the gallery disc's tap
   target by 2px on the canonical 430px frame. */
.capture-row > .plus-wrap {
  position: absolute;
  left: -2px;
  top: 50%;
  transform: translateY(-50%);
  /* The translate makes .plus-wrap a stacking context, which traps the menu's
     own z-index:60 inside it — without a z-index here the card would render
     UNDER the viewfinder (#viewfinder is z-index 1 and the reticle
     pseudo-element is z-index 2 in the same context). Verified in-browser. */
  z-index: 60;
}
.capture-row > [data-cap="load"]    { order: 1; }
.capture-row > [data-cap="snap"]    { order: 2; }
.capture-row > [data-cap="history"] { order: 3; }

/* Each button is a column: the disc, then its caption underneath. The captions
   used to live INSIDE the discs at 9.5px over two lines in a 70px circle —
   cramped, and it meant the shutter could not have one at all without becoming
   a text container again. Outside, they read, the icons get the whole disc, and
   in the dark skin they sit on the page rather than on the button, which is
   where their contrast is best.

   The caption box is a FIXED two lines high, and the side discs carry a margin
   that makes their box as tall as the shutter's, so all three columns are
   exactly the same height. That is what keeps both the discs' centres AND the
   captions' baselines on one line. A caption that wrapped to two lines in one
   language and one in another would tilt the row, hence the fixed height. */
.cap-item {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
  flex-shrink: 0;
}
.cap-text {
  height: 26px;
  /* Fluid, not a fixed 92px: at 320px the stage is 284 wide and three 92px
     columns plus two 14px gaps came to 304, so the row overflowed its own box.
     25.5vw gives 81.6px there — three of them plus the gaps is 273. */
  width: clamp(72px, 25.5vw, 92px);
  font-size: var(--cap-size);
  font-weight: 700;
  letter-spacing: 0.1px;
  line-height: 1.15;
  text-align: center;
  color: var(--text-primary);
}

/* The two side discs. The vertical margin makes their BOX as tall as the
   shutter's, which is what puts all three captions on one baseline: the row
   centres each column, so columns of unequal height drop their captions to
   different heights — measured, the shutter's sat 4.9px lower than its
   neighbours'. The margin is symmetric, so the disc centres stay on one line
   too. Both facts matter; aligning either one alone looks like a mistake. */
/* Size, margin, fill and padding for these three come from THE CAPTURE ROW at
   the end of this file, which both skins share; what is left here is the
   shape, the layout of their contents, and the light skin's ink. */
.load-btn,
.history-btn {
  flex-shrink: 0;
  border-radius: 50%;
  border: none;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 3px;
  color: var(--text-primary);
  cursor: pointer;
  /* Palefò drop shadow — the boulpik mic/hero treatment: a sky-blue glow over a
     tighter navy shadow. */
  box-shadow:
    0 0.7rem 1.6rem var(--shadow-color),
    0 0.3rem 0.7rem rgba(var(--accent-2-rgb), 0.28);
  transition: transform 0.12s ease, box-shadow 0.18s ease;
}

/* ── The shutter ──
   Larger than its neighbours so it is unmistakably the primary action, and a
   real shutter rather than a picture of a camera: a stroke-only ring with a
   solid core inside it, which is the affordance every phone camera uses. Both
   skins replace this look with the clock face — see the capture-row redesign at
   the end of the file. The gradient that used to fill it here was never part of
   CANON-LOCK [13]'s locked spec, which names the gallery, replay, restart and
   active chips and not `.snap-btn`.

   "Larger", not "much larger". It was 97.5px against 70.2px side discs — 1.39x,
   which Franck called overwhelming, and he was right: at that size it stops
   reading as one of three buttons and starts reading as the whole row. 1.14x
   is enough to say which one is primary. */
.snap-btn {
  flex-shrink: 0;
  border-radius: 50%;
  border: none;
  padding: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  color: #fff;
  box-shadow:
    0 0.7rem 1.6rem var(--shadow-color),
    0 0.35rem 0.9rem var(--btn-shadow);
  transition: transform 0.12s ease, box-shadow 0.18s ease;
}
.shutter-ring {
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
}
.shutter-core {
  border-radius: 50%;
  /* The press: the core contracts inside a ring that does not move, which is
     what a physical shutter does. Sits on the CORE, not the button, so the
     ring stays put and the motion reads as the mechanism firing. */
  transition: transform 0.09s ease;
}
.snap-btn:active .shutter-core { transform: scale(0.82); }
/* The clock-face tick ring is the shutter on BOTH skins now — see the capture
   row at the end of the file, which sizes and shows it. The dark skin recolours
   it and nothing else. */

.snap-btn:active,
.load-btn:active,
.history-btn:active {
  transform: translateY(1px) scale(0.96);
  box-shadow:
    0 0.4rem 0.9rem var(--shadow-color),
    0 0.2rem 0.5rem rgba(var(--accent-2-rgb), 0.28);
}
/* The icons own the whole disc now that the captions have moved out. */
.load-icon,
.history-icon {
  width: 34px;
  height: 34px;
  color: var(--accent);
}


/* Subtle inline link/button for secondary actions */
.text-link {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 8px 14px;
  font-size: 14px;
  font-weight: 600;
  color: var(--text-secondary);
  background: none;
  border-radius: 999px;
}
.text-link:hover  { color: var(--accent); background: var(--accent-soft); }
.text-link:active { color: var(--accent); background: var(--accent-soft); }

/* The FALLBACK pane's styling lived here — a frost sheet, a 140px gradient
   disc and an 18px hint. The pane is gone: a missing camera now greys the
   camera screen's own window instead of switching to a screen that duplicated
   the load and history buttons. */

/* ── Camera unavailable ──
   Covers .viewfinder-wrap, so the window the reader already knows goes quiet
   instead of the app jumping to another screen. The wrap is the positioning
   context (it is position:relative for the reticle). */
.camera-off {
  position: absolute;
  inset: 0;
  z-index: 3;                 /* over the video (1) and its reticle (2) */
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 10px;
  padding: 0 18px;
  text-align: center;
  border-radius: inherit;
  background: transparent;    /* the window itself carries the colour, below */
}
/* The window's live-camera backdrop is a near-black navy — right behind a video
   frame, wrong as a resting state, where it reads as a hole punched in a pale
   card. With no camera there is no video coming, so the window borrows Mesaj Pa
   m's API-return frame (.asr-thread in its styles.css): a frost gradient that
   fades DOWNWARD to fully transparent, so the frame melts into the card by the
   time it reaches the bottom, and no shadow — nothing to lift, because it is no
   longer a pane sitting on the card, it IS the card. Same stops as Mesaj's, and
   the window keeps its own size and radius. The float shadow comes back the
   moment a camera does. css/alt.css restores dark's own ground and lift. */
/* Scoped to the light skin by NAME. The alternative was to set this unscoped and
   undo it in css/alt.css, which meant copying dark's ground and its five shadow
   layers into a second place to keep them in step — and they did not stay in
   step: the copy quietly shipped a different, twice-as-dark ground for dark's
   no-camera window while its comment claimed nothing had changed. Scoping here
   means dark is never touched at all.
   border: 0 because Mesaj's .asr-thread has none, and the point is to MELT into
   the card: light's window otherwise keeps its 1px violet hairline, which draws
   a crisp rectangle round the bottom edge exactly where the gradient has faded
   to nothing. Only in this state — the live-camera window keeps its stroke. */
[data-skin="light"] #stage[data-camera="off"] .viewfinder-wrap {
  background: linear-gradient(180deg,
    rgba(226, 232, 252, 0.62) 0%,
    rgba(234, 238, 252, 0.34) 45%,
    rgba(245, 246, 251, 0.10) 78%,
    rgba(245, 246, 251, 0) 100%);
  border: 0;
  box-shadow: none;
}
.camera-off-title {
  margin: 0;
  font-size: 17px;
  font-weight: 700;
  color: var(--text-primary);
}
/* --text-secondary (0.62 alpha) measures 2.81:1 where this line sits — under the
   4.5:1 floor, on the one line that tells a reader what to do instead. Full
   --text-primary is 6.38:1. This app is FOR people who cannot read the thing in
   front of them; its own instructions do not get to be the faint ones.
   Method, because this gradient makes a bare ratio meaningless: ground sampled
   on the line's OWN row (y=426 at 390x844 light), in a glyph-free column inside
   the window (x=48..71) — rgb(236,238,251). Do NOT take the darkest pixel inside
   the text's box instead: that is an antialias halo, part ink already, and
   quoting it understates the ground. An earlier revision here did exactly that,
   and swapped this line's ground with the link's. */
.camera-off-hint {
  margin: 0;
  font-size: 14px;
  line-height: 1.35;
  color: var(--text-primary);
}
/* --accent (#7c4dff) is 4.13:1 at this row — still short of 4.5:1.
   --accent-purple, the family's deep violet, is 7.17:1 and is already a token,
   so no new colour enters the palette and CANON-LOCK [10] holds. Same method as
   above: row y=463, glyph-free column, ground rgb(235,237,251). */
.camera-off .text-link {
  color: var(--accent-purple);
  opacity: 1;
}
.camera-off .text-link svg { fill: currentColor; }

/* The shutter keeps its whole look when disabled — face, ticks, shadow, size.
   Dimming the disc was tried and rejected on sight: it all but vanished off the
   card, which reads as a broken app rather than a control waiting on a camera.
   What greys instead is its CAPTION. The button is still genuinely disabled —
   see setCameraAvailable() — so nothing here is cosmetic-only. */
.snap-btn[disabled] { cursor: not-allowed; }
.snap-btn[disabled]:active { transform: none; }
#stage[data-camera="off"] .capture-row > [data-cap="snap"] .cap-text {
  opacity: 0.45;
}

/* ── WORKING: image preview + spinner ── */
.preview-wrap {
  position: relative;
  width: 100%;
  max-width: 480px;
  border-radius: var(--radius-lg);
  overflow: hidden;
  background: var(--bg-panel);
  /* NervalFrost card: lavender stroke + mesaj's soft purple elevation. */
  border: 1px solid var(--border);
  box-shadow: 0 12px 34px rgba(85, 70, 150, 0.22);
}
#preview-img {
  width: 100%;
  height: auto;
  max-height: 60vh;
  object-fit: contain;
  display: block;
}
.working-overlay {
  position: absolute; inset: 0;
  /* Frost wash (--nf-frost at 82%) instead of plain white. */
  background: rgba(245, 246, 251, 0.82);
  display: flex; flex-direction: column;
  align-items: center; justify-content: center;
  gap: 16px;
  backdrop-filter: blur(2px);
}
.spinner {
  width: 56px; height: 56px;
  border-radius: 50%;
  /* Pale lavender track with a violet head — understated NervalFrost. */
  border: 5px solid rgba(160, 140, 225, 0.40);
  border-top-color: var(--accent);
  animation: spin 0.9s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.working-label {
  font-size: 16px;
  font-weight: 600;
  color: var(--text-primary);
  letter-spacing: 0.3px;
}

/* ── RESULT: play/replay button — small, flanked by rewind/forward ── */
.replay-btn {
  width: 34px;
  height: 34px;
  flex-shrink: 0;
  border-radius: 50%;
  background: var(--gradient-primary);
  color: #fff;
  display: flex; align-items: center; justify-content: center;
  box-shadow:
    0 0 0 3px var(--accent-soft),
    0 6px 16px var(--btn-shadow);
  transition: transform 0.12s ease;
}
.replay-btn:active { transform: scale(0.96); }
/* While audio is playing, the button shows the pause icon and switches to
   light red — signalling "tap to stop" rather than "tap to start". */
.replay-btn.playing {
  background: #ff8a9b;   /* light red, lighter than --danger coral */
  box-shadow:
    0 0 0 6px  rgba(255, 138, 155, 0.18),
    0 12px 36px rgba(255, 138, 155, 0.35);
  animation: pulse-playing 1.2s ease-in-out infinite;
}
@keyframes pulse-playing {
  0%, 100% { box-shadow: 0 0 0 6px  rgba(255, 138, 155, 0.18), 0 12px 36px rgba(255, 138, 155, 0.30); }
  50%      { box-shadow: 0 0 0 18px rgba(255, 138, 155, 0.05), 0 12px 36px rgba(255, 138, 155, 0.45); }
}
.replay-icon { width: 42%; height: 42%; }
/* Show play icon by default; swap to pause while audio is playing. */
.replay-btn .icon-pause { display: none; }
.replay-btn.playing .icon-play  { display: none; }
.replay-btn.playing .icon-pause { display: block; }

/* Result pane lays out the play button + transcript on top of the captured
   photo. The pane itself becomes the positioning context; .result-bg fills
   it as a blurred backdrop, and the pane's flex children render in front. */
.stage-pane[data-pane="result"] {
  position: relative;
  padding: 18px 24px;
  isolation: isolate;
  /* Rounded display card — clips the blurred backdrop + frost overlay too. */
  border-radius: var(--radius-lg);
  overflow: hidden;
  /* Stack from the top so the transport row (rewind/play/forward) is always
     visible — never pushed off-screen by a long transcript on short phones. */
  justify-content: flex-start;
  gap: 14px;
}

/* During playback the result pane rises so the transport row sits closer to the
   photo. (It used to trade places with a strip of language chips that lived
   here; the chips are gone — the UI language is a row in the settings sheet —
   but the tighter gap is worth keeping on its own.) */
#stage[data-state="result"] {
  top: calc(var(--card-inset) + var(--topbar-h) + var(--safe-top) + var(--hero-h) + var(--stage-gap-tight));
}
.result-bg {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  z-index: 0;
  filter: blur(6px) brightness(1.04) saturate(0.9);
  transform: scale(1.06);     /* hide blur edge halo */
  user-select: none;
  -webkit-user-drag: none;
  pointer-events: none;
}
/* Palefò-tinted wash over the photo (replaces the plain grey-white overlay)
   so UI elements stay readable while reading on-brand. */
.stage-pane[data-pane="result"]::before {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(180deg,
    rgba(227, 229, 250, 0.88) 0%,
    rgba(236, 226, 246, 0.84) 100%);
  z-index: 1;
  pointer-events: none;
}
/* All flex children of the result pane stack above the overlay. (.close-btn is
   absolutely positioned with its own z-index — deliberately NOT listed here.) */
.stage-pane[data-pane="result"] > .audio-controls,
.stage-pane[data-pane="result"] > .video-box,
.stage-pane[data-pane="result"] > .result-toolbar {
  position: relative;
  z-index: 2;
}

/* Audio chooser (multi-mode captures): which reading to hear. */
.audio-choice {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  justify-content: center;
}
.audio-choice[hidden] { display: none; }
.audio-choice-chip {
  padding: 5px 11px;
  border-radius: 999px;
  font-size: 11px;
  font-weight: 700;
  background: rgba(255, 255, 255, 0.85);
  color: var(--text-secondary);
  border: 1px solid var(--border);
  transition: background 0.15s ease, color 0.15s ease, transform 0.12s ease;
}
.audio-choice-chip:active { transform: scale(0.95); }
.audio-choice-chip.active {
  background: var(--gradient-primary);
  color: #fff;
  border-color: transparent;
  box-shadow: 0 0.2rem 0.5rem var(--btn-shadow);
}
.stage-pane[data-pane="result"] > .audio-choice { position: relative; z-index: 2; }
.stage-pane[data-pane="result"][data-editing] > .audio-choice { display: none; }

/* Transport row: ⏮ | ↺10 | play ▶ | ↻10 | ⏭, centered on one line. */
.audio-controls {
  display: flex;
  gap: 14px;
  align-items: center;
  justify-content: center;
}

/* Reduced rewind / skip / forward buttons flanking the play button. */
.rewind-btn,
.forward-btn,
.skip-btn {
  position: relative;
  width: 22px;
  height: 22px;
  flex-shrink: 0;
  border-radius: 50%;
  background: var(--accent-soft);
  color: var(--accent);
  display: flex;
  align-items: center;
  justify-content: center;
  border: 1px solid rgba(var(--accent-1-rgb), 0.18);
  box-shadow: 0 3px 9px rgba(var(--accent-2-rgb), 0.20);
  transition: transform 0.12s ease, background 0.18s ease;
}
.rewind-btn:hover,
.forward-btn:hover,
.skip-btn:hover { background: rgba(var(--accent-1-rgb), 0.18); }
.rewind-btn:active,
.forward-btn:active,
.skip-btn:active {
  transform: scale(0.92);
  background: var(--accent);
  color: #fff;
}
.rewind-btn svg,
.forward-btn svg { width: 11px; height: 11px; }
/* The ±10 glyphs carry a numeral, so they get a slightly larger, unpadded
   ring than the plain start/end triangles. */
.skip-btn { width: 26px; height: 26px; }
.skip-btn svg { width: 22px; height: 22px; }
/* Small visuals, comfortable thumbs: an invisible pad extends the tap target
   to ~44px without changing the 22px look. (Pseudo-elements count as part of
   the button for hit-testing.) */
.rewind-btn::after,
.forward-btn::after,
.skip-btn::after {
  content: "";
  position: absolute;
  inset: -11px;
  border-radius: 50%;
}

/* Video box — the captured photo shown crisply while the translation plays.
   Acts as the "screen" of the capture: a framed, rounded card holding the
   photo, with the caption overlay sitting on top of it. */
.video-box {
  position: relative;
  width: 100%;
  /* 4/3 sets the flex-basis height; flex-grow then stretches the box into all
     the space the smaller transport row + merged toolbar freed up, so the
     photo/document gets the tallest possible screen. min-height: 0 lets it
     shrink instead of overflowing on very short stages. */
  aspect-ratio: 4 / 3;
  flex: 1 1 auto;
  min-height: 0;
  overflow: hidden;
  border-radius: var(--radius-lg);
  border: 1px solid rgba(var(--accent-1-rgb), 0.30);
  background: rgba(var(--accent-1-rgb), 0.10);
  box-shadow:
    0 0.5rem 1.2rem var(--shadow-color),
    0 0.2rem 0.5rem rgba(var(--accent-2-rgb), 0.18);
}
.video-photo {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  user-select: none;
  -webkit-user-drag: none;
}

/* Caption overlay — the voiced text laid over the bottom of the photo on a
   dark scrim for legibility. Hidden unless captions are toggled on. */
.video-caption {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  margin: 0;
  /* The text may cover the FULL photo — long documents scroll within it. */
  max-height: 100%;
  overflow-y: auto;
  padding: 14px 16px;
  font-size: 17px;
  font-weight: 600;
  line-height: 1.45;
  color: #fff;
  text-align: center;
  /* Solid-enough scrim for however tall the caption grows; only the top
     ~44px softens into the photo so full-screen text stays readable. */
  background: linear-gradient(0deg,
    rgba(10, 22, 44, 0.82) 0%,
    rgba(10, 22, 44, 0.62) calc(100% - 44px),
    rgba(10, 22, 44, 0) 100%);
  text-shadow: 0 1px 3px rgba(0, 0, 0, 0.55);
}
.video-box[data-captions="off"] .video-caption { display: none; }

/* Caption toggle — a compact checkbox row under the video box. */
.caption-toggle {
  display: inline-flex;
  align-items: center;
  gap: 9px;
  font-size: 14px;
  font-weight: 600;
  color: var(--text-primary);
  cursor: pointer;
  user-select: none;
}
.caption-toggle input {
  width: 18px;
  height: 18px;
  accent-color: var(--accent);
  cursor: pointer;
}

.again-btn {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 12px 22px;
  border-radius: 999px;
  background: var(--bg-input);
  color: var(--text-primary);
  font-weight: 600;
  font-size: 15px;
  border: 1px solid var(--border);
}
.again-btn:hover  { background: var(--bg-input-hover); }
.again-btn:active { background: var(--accent); color: #fff; border-color: var(--accent); }

/* ── ERROR ── */
/* NervalFrost sheet, like the result/history panes: frost surface in a
   rounded card with the soft lavender stroke. */
.stage-pane[data-pane="error"] {
  background: var(--bg-surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
}
/* Understated NervalFrost take on the alert disc: a pale danger-tinted
   disc with the "!" in danger color, instead of a loud solid red circle. */
.error-icon {
  width: 110px; height: 110px;
  border-radius: 50%;
  background: rgba(233, 69, 96, 0.12);
  color: var(--danger);
  border: 1px solid rgba(233, 69, 96, 0.30);
  font-size: 64px;
  font-weight: 800;
  display: flex; align-items: center; justify-content: center;
  box-shadow: 0 2px 10px rgba(60, 30, 100, 0.06);
}
.error-text {
  font-size: 17px;
  font-weight: 600;
  color: var(--text-primary);
  text-align: center;
  max-width: 80%;
}
.restart-btn {
  padding: 14px 28px;
  border-radius: var(--radius-full);
  background: var(--gradient-primary);
  color: #fff;
  font-weight: 700;
  font-size: 16px;
  box-shadow: 0 0.4rem 1rem var(--btn-shadow);
  transition: transform 0.12s ease, filter 0.15s ease;
}
.restart-btn:hover  { filter: brightness(1.06); }
.restart-btn:active { transform: scale(0.97); filter: brightness(0.97); }

/* ── Status bar ── */
#statusbar {
  position: absolute;
  /* The one piece of chrome inset at the SIDES as well: the token count is
     right-aligned into the bottom corner, where the card's 25px radius curves
     away — without the side inset it would sit on the outer colour. */
  bottom: var(--card-inset); left: var(--card-inset); right: var(--card-inset);
  height: calc(var(--statusbar-h) + var(--safe-bottom));
  padding-bottom: var(--safe-bottom);
  /* Transparent, like the topbar — the token count floats on the wash
     instead of sitting in a white band. */
  background: transparent;
  border-top: none;
  display: flex; align-items: center; justify-content: flex-end;
  padding-left: 14px; padding-right: 14px;
  font-size: 12px;
  color: var(--text-muted);
  z-index: 10;
}
.balance { font-variant-numeric: tabular-nums; }

/* ─────────────────────────────────────────────────────────────────────
   Settings popover + Batch 1/2 chrome (restored after rollback)
   ──────────────────────────────────────────────────────────────────── */

/* Gear button in the topbar that opens the settings popover.
   Adopted from Mesaj Pa m's `.settings-gear` (Mesaj-Pam/static/css/styles.css)
   so the family shares one gear: a BARE outline glyph — no disc, no border,
   no fill — that darkens and turns 20° on hover, and adds a press-scale on
   click. Placement stays FotoLekti's own (right-aligned inside #topbar);
   only the button itself changed, so the bar's geometry is untouched. */
.settings-btn {
  width: var(--gear-box);    /* comfortable tap target, as in mesaj */
  height: var(--gear-box);
  padding: 0;
  background: none;
  border: none;
  color: rgba(86, 44, 195, 0.85);   /* mesaj --nf-purple: canon icon tint */
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  transition: color 0.2s ease, transform 0.25s ease;
}
.settings-btn svg    { width: var(--gear-glyph); height: var(--gear-glyph); }
.settings-btn:hover  { color: #542CC2; transform: rotate(20deg); }
.settings-btn:active { transform: rotate(20deg) scale(0.92); }
/* Panel open: hold the hover look. Mesaj has no open state — the filled disc
   that used to carry it here is gone, so the turn is what reads as "open". */
.settings-btn.open   { color: #542CC2; transform: rotate(20deg); }

/* ── Settings — the Mesaj Pa m sheet ──
   Adopted from Mesaj-Pam's .settings-sheet / .sheet-row: a bottom sheet with a
   centred Playfair-italic title, a circular close disc at the right, and
   hairline-separated rows whose label sits left and whose control sits right.
   FotoLekti's rows, their ORDER, their ids and their behaviour are unchanged —
   only the presentation.

   ONE row shape, and that is the whole point. The previous pass kept two
   (`is-inline` and `is-stacked`) so that segmented pills and eleven flag chips
   could sit full-width under their labels; the result had no right-hand column
   to read down and row heights of 53/121/85/58/115/86/52px. Every control is
   now the same compact <select>, so the labels share a left edge and the
   controls share a right one, and the rows are ~40px throughout.

   Mòd lekti is the single exception, and it is exactly what mesaj's sub-panels
   are for: the reading modes are MULTI-select, which no <select> can express.
   It gets a row that states the current choice and opens .settings-subpanel. */
.settings-popover {
  position: absolute;
  left: 0; right: 0; bottom: 0;
  top: auto;
  z-index: 100;
  min-width: 0;
  max-width: none;
  max-height: 82%;
  background: #ffffff;
  border: none;
  border-radius: 22px 22px 0 0;
  /* Second shadow IS the scrim. A ::before could not do it: .settings-popover
     has a z-index, so it forms a stacking context, and a z-index:-1 child
     paints above the element's own background — it tinted the white sheet
     lavender instead of dimming the app behind it. A spread shadow paints
     only outside the box, is clipped to the frame by .device-frame{overflow:
     hidden}, and captures no clicks, so app.js's click-outside-to-close still
     works. */
  box-shadow:
    0 -0.9rem 2.8rem rgba(85, 70, 150, 0.28),
    0 0 0 100vmax rgba(60, 50, 120, 0.30);
  padding: 14px 18px calc(14px + var(--safe-bottom));
  display: flex;
  flex-direction: column;
  gap: 0;
  overflow: hidden;            /* the sub-panel clips to the sheet */
  /* NO entrance transform. Mesaj slides its sheet up, and both ways of doing
     that here — a keyframe with `from: translateY(102%)`, or a transition out
     of @starting-style — position the sheet OFF the bottom of the frame until
     the motion completes. Measured: with the tab backgrounded the transition
     sat at currentTime 0 and the sheet stayed parked 654px below the frame,
     i.e. invisible. A settings panel that depends on an animation finishing in
     order to be on screen is a correctness bug, not a flourish. The sheet's
     form — bottom-anchored, centred italic title, hairline rows — is what
     carries the mesaj aesthetic; the slide is not worth the failure mode.
     The sub-panel below is shown outright for the same reason. */
}

/* Below the floor the frame deliberately extends past the bottom of the
   viewport — fine for the seal and the token count, but not for a sheet
   anchored to the frame's bottom edge: at 360x560 its last 82px (the account
   row and the skin switch) sat permanently below the fold, unreachable by
   scrolling. Lift the sheet by exactly the overflow and cap it against the
   VIEWPORT instead of the frame.
   This lives AFTER the base rule on purpose — it was first written up in the
   responsive block near the top of the file, where `bottom: 0` below it won
   on source order and the fix silently did nothing. */
@media (max-width: 479.98px) {
  .settings-popover {
    bottom: max(0px, calc(var(--frame-min-h) - 100vh));
    max-height: min(82%, calc(100vh - 44px));
  }
  @supports (height: 1dvh) {
    .settings-popover {
      bottom: max(0px, calc(var(--frame-min-h) - 100dvh));
      max-height: min(82%, calc(100dvh - 44px));
    }
  }
}

/* Header: centred title, with a disc button absolutely placed at one edge —
   close (×) at the right on the sheet, back (‹) at the left on a sub-panel.
   The markup order is untouched; only the layout moved. */
.settings-head {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0;
  margin: 0;
  padding-bottom: 6px;
  border-bottom: 1px solid var(--border);
  flex-shrink: 0;
}
.settings-title {
  font-family: 'Playfair Display', Georgia, serif;
  font-style: italic;
  font-weight: 600;
  font-size: 23px;
  letter-spacing: 0.01em;
  text-transform: none;
  color: rgba(49, 86, 142, 0.75);
}
.settings-close,
.subpanel-back {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  width: 34px;
  height: 34px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(84, 44, 194, 0.08);
  border: 1px solid rgba(86, 44, 195, 0.5);
  color: rgba(86, 44, 195, 0.65);
  cursor: pointer;
  opacity: 1;
  transition: color 0.2s ease, background 0.2s ease;
}
.settings-close { right: 0; }
.subpanel-back  { left: 0; }
.settings-close svg,
.subpanel-back svg { width: 16px; height: 16px; }
.settings-close:hover,
.subpanel-back:hover  { color: rgba(86, 44, 195, 0.95); background: rgba(84, 44, 194, 0.14); }
.settings-close:active,
.subpanel-back:active { transform: translateY(-50%) scale(0.92); }

/* The scrolling body. The sheet itself no longer scrolls, so the sub-panel can
   sit over it without being dragged around by the list's scroll position. */
.settings-list {
  overflow-y: auto;
  scrollbar-width: none;
  -ms-overflow-style: none;
}
.settings-list::-webkit-scrollbar { display: none; }

/* Rows: hairline-separated, label left, control right. Mesaj's metrics. */
.settings-row {
  display: flex;
  flex-direction: row;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  width: 100%;
  padding: 7px 2px;
  border: none;
  border-bottom: 1px solid var(--border);
  background: none;
  font-family: inherit;
  text-align: left;
}
/* display:flex above would otherwise override the `hidden` attribute, and
   #account-actions is hidden until you sign in (the Tchala trap — mesaj
   carries the same guard for the same reason). */
.settings-row[hidden] { display: none; }
.settings-row:last-child { border-bottom: none; }
.settings-label {
  font-size: 0.95rem;
  font-weight: 400;
  letter-spacing: 0.005em;
  text-transform: none;
  color: rgba(49, 86, 142, 0.88);
  /* The LABEL is what gives way, never the control — mesaj's .sheet-label has
     no flex-shrink:0 for the same reason. With both sides rigid, "Langue de
     l'application" at 320px pushed its select 4.5px past the hairline and out
     of the column, which is the whole defect this sheet exists to fix. A long
     label wraps to a second line instead; the right edge does not move. */
  min-width: 0;
}

/* A row that opens a sub-panel: label, then the current value, then a chevron. */
.settings-row-action { cursor: pointer; }
.settings-row-action:active { background: rgba(140, 120, 220, 0.08); }
.settings-value {
  margin-left: auto;
  min-width: 0;
  font-size: 0.85rem;
  font-weight: 500;
  color: rgba(49, 86, 142, 0.6);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.settings-chev {
  flex-shrink: 0;
  color: rgba(86, 44, 195, 0.4);
  font-size: 0.95rem;
  line-height: 1;
}

/* Every control in the sheet is this one select — that is what gives the
   column its right edge. Mesaj's .sheet-select, chevron and all. */
.settings-select {
  font-family: inherit;
  font-size: 0.85rem;
  font-weight: 500;
  color: var(--text-primary);
  background-color: rgba(255, 255, 255, 0.75);
  border: 1px solid rgba(86, 44, 195, 0.5);
  border-radius: 10px;
  padding: 6px 26px 6px 11px;
  max-width: 60%;
  appearance: none;
  -webkit-appearance: none;
  -moz-appearance: none;
  cursor: pointer;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='9' height='6' viewBox='0 0 12 8'%3E%3Cpath d='M1 1.5 6 6.5 11 1.5' fill='none' stroke='rgba(86,44,195,0.6)' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right 9px center;
  flex-shrink: 0;
}
.settings-select:hover { background-color: rgba(255, 255, 255, 0.95); }
.settings-select:focus { outline: none; border-color: var(--accent); }

.settings-copyright {
  text-align: center;
  font-size: 0.75rem;
  font-weight: 500;
  font-style: italic;
  color: rgba(49, 86, 142, 0.85);
  padding: 8px 0 2px;
}

/* ── Sub-panel (Mòd lekti) ──
   Covers the sheet rather than sliding over it: see the note on .settings-
   popover about transitions that never run. */
.settings-subpanel {
  position: absolute;
  inset: 0;
  z-index: 2;
  display: flex;
  flex-direction: column;
  background: inherit;
  border-radius: inherit;
  padding: 14px 18px calc(14px + var(--safe-bottom));
}
.settings-subpanel[hidden] { display: none; }
.subpanel-hint {
  font-size: 0.78rem;
  line-height: 1.45;
  color: rgba(49, 86, 142, 0.6);
  padding: 9px 2px 3px;
}

/* Mode rows are <label>s wrapping a real checkbox, so the whole row is the hit
   target and the native control keeps its semantics and keyboard behaviour. */
.check-row { cursor: pointer; }
.check-row input[type="checkbox"] {
  position: absolute;
  width: 1px; height: 1px;
  margin: -1px;
  padding: 0;
  border: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
}
.check-mark {
  flex-shrink: 0;
  width: 21px;
  height: 21px;
  border-radius: 6px;
  border: 1px solid rgba(86, 44, 195, 0.5);
  background: rgba(255, 255, 255, 0.75);
  position: relative;
  transition: background 0.15s ease, border-color 0.15s ease;
}
.check-mark::after {
  content: "";
  position: absolute;
  left: 7px; top: 3px;
  width: 5px; height: 10px;
  border: solid #fff;
  border-width: 0 2px 2px 0;
  transform: rotate(45deg) scale(0.6);
  opacity: 0;
  transition: opacity 0.15s ease, transform 0.15s ease;
}
.check-row input:checked + .check-mark {
  background: var(--gradient-primary);
  border-color: transparent;
}
.check-row input:checked + .check-mark::after {
  opacity: 1;
  transform: rotate(45deg) scale(1);
}
.check-row input:focus-visible + .check-mark {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

/* Top-left return button — overlays the result pane, taps return to idle.
   Kept compact and high-contrast so it's an unambiguous "exit" affordance. */
.close-btn {
  position: absolute;
  top: 10px;
  left: 10px;
  z-index: 3;
  width: 28px;
  height: 28px;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.85);
  color: var(--text-primary);
  display: flex;
  align-items: center;
  justify-content: center;
  border: 1px solid var(--border);
  box-shadow: 0 2px 8px rgba(var(--accent-2-rgb), 0.18);
  backdrop-filter: blur(4px);
  transition: background 0.18s ease, transform 0.12s ease, color 0.18s ease;
}
.close-btn:hover  { background: #fff; color: var(--accent); }
.close-btn:active { transform: scale(0.92); }
.close-btn svg    { width: 17px; height: 17px; }

/* Toolbar row at the bottom of the result pane: caption toggle + share
   side by side on one line. Wraps so the follow-up WhatsApp text chip can
   drop to its own line when revealed. */
.result-toolbar {
  display: flex;
  flex-wrap: wrap;
  gap: 10px 14px;
  align-items: center;
  justify-content: center;
}

/* The share row inside the toolbar (mesaj order: SMS | WhatsApp | Email |
   sheet). One flex item, so on narrow phones it wraps below the caption
   toggle as a unit instead of splitting mid-row. */
.share-row {
  display: flex;
  gap: 14px;
  align-items: center;
  justify-content: center;
}

/* Small icon buttons (share row). Some are <a> protocol links (sms:/mailto:),
   so anchor text decoration is reset. */
.icon-btn {
  width: 36px;
  height: 36px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--bg-input);
  color: var(--text-secondary);
  border: 1px solid var(--border);
  text-decoration: none;
  transition: background 0.18s ease, color 0.18s ease, transform 0.12s ease;
}
.icon-btn:hover  { background: var(--bg-input-hover); color: var(--text-primary); }
.icon-btn:active { transform: scale(0.92); }
.icon-btn svg    { width: 17px; height: 17px; }
.icon-btn[aria-pressed="true"] {
  background: var(--accent);
  color: #fff;
  border-color: var(--accent);
}

/* Hover tooltip on the share buttons (desktop / mouse only): an instant,
   NervalFrost-styled label drawn from the button's own i18n'd aria-label, so
   the four glyphs never need decoding. Also shown on keyboard focus. Sits
   above the button (the toolbar hugs the stage's bottom edge, so a label
   below would clip). pointer-events:none keeps it from stealing the hover. */
.share-row .icon-btn { position: relative; }
@media (hover: hover) {
  .share-row .icon-btn:hover { transform: translateY(-2px); }
  .share-row .icon-btn:active { transform: translateY(-2px) scale(0.92); }
}
.share-row .icon-btn::after {
  content: attr(aria-label);
  position: absolute;
  bottom: calc(100% + 9px);
  left: 50%;
  transform: translate(-50%, 4px);
  padding: 5px 9px;
  border-radius: 8px;
  background: rgba(var(--accent-2-rgb), 0.94);
  color: #fff;
  font-size: 12px;
  font-weight: 600;
  line-height: 1.2;
  letter-spacing: 0.1px;
  white-space: nowrap;
  box-shadow: 0 6px 16px rgba(var(--accent-2-rgb), 0.28);
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.14s ease, transform 0.14s ease;
  z-index: 5;
}
/* Small caret under the label. */
.share-row .icon-btn::before {
  content: "";
  position: absolute;
  bottom: calc(100% + 4px);
  left: 50%;
  transform: translate(-50%, 4px);
  border: 5px solid transparent;
  border-top-color: rgba(var(--accent-2-rgb), 0.94);
  border-bottom: 0;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.14s ease, transform 0.14s ease;
  z-index: 5;
}
@media (hover: hover) {
  .share-row .icon-btn:hover::after,
  .share-row .icon-btn:hover::before { opacity: 1; transform: translate(-50%, 0); }
}
.share-row .icon-btn:focus-visible::after,
.share-row .icon-btn:focus-visible::before { opacity: 1; transform: translate(-50%, 0); }

/* WhatsApp share — brand-green so it reads instantly as WhatsApp. A branded
   secondary action, like the red stop state on .replay-btn (canon [13] leaves
   secondary button styles free). Placed after the .icon-btn rules so the
   equal-specificity hover override wins by source order. */
.whatsapp-btn {
  background: #25d366;
  color: #fff;
  border-color: transparent;
}
.whatsapp-btn:hover { background: #1fbd59; color: #fff; }
/* Step-2 chip: "send the text too" — appears after the audio share, because
   WhatsApp audio messages can't carry a caption (text must go separately). */
.whatsapp-text-btn {
  height: 34px;
  padding: 0 16px;
  border-radius: var(--radius-full);
  background: #25d366;
  color: #fff;
  font-size: 13px;
  font-weight: 700;
  letter-spacing: 0.2px;
  white-space: nowrap;
  box-shadow: 0 3px 9px rgba(37, 211, 102, 0.40);
  transition: transform 0.12s ease, background 0.18s ease;
}
.whatsapp-text-btn:hover  { background: #1fbd59; }
.whatsapp-text-btn:active { transform: scale(0.96); }
/* Same chip after a share-sheet audio share: step 2 goes back through the
   sheet, so it wears the app's own colours instead of WhatsApp green. */
.whatsapp-text-btn[data-channel="sheet"],
.whatsapp-text-btn[data-channel="sheet"]:hover {
  background: var(--gradient-primary);
  box-shadow: 0 3px 9px var(--btn-shadow);
}

/* ─────────────────────────────────────────────────────────────────────
   History pane — IndexedDB-backed grid of past captures.
   ──────────────────────────────────────────────────────────────────── */
.stage-pane[data-pane="history"] {
  align-items: stretch;
  justify-content: flex-start;
  gap: 16px;
  padding: 16px;
  overflow-y: auto;
  /* NervalFrost sheet: frost surface in a rounded card with the soft
     lavender stroke, matching the result display card and mesaj's drawers. */
  background: var(--bg-surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
}
.history-header {
  display: flex;
  align-items: center;
  gap: 12px;
  flex-shrink: 0;
}
.history-title {
  font-size: 18px;
  font-weight: 800;
  color: var(--text-primary);
}
.history-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
  gap: 12px;
  width: 100%;
  align-content: start;
}
.history-empty {
  font-size: 15px;
  color: var(--text-secondary);
  text-align: center;
  padding: 32px 16px;
}

/* Individual thumbnail card. */
.history-item {
  position: relative;
  display: flex;
  flex-direction: column;
  /* NervalFrost card: white on frost, 14px corners, mesaj's understated
     card shadow; hover lifts with the soft purple elevation. */
  border-radius: 14px;
  overflow: hidden;
  background: #ffffff;
  border: 1px solid var(--border);
  box-shadow: 0 2px 10px rgba(60, 30, 100, 0.06);
  cursor: pointer;
  transition: transform 0.12s ease, box-shadow 0.2s ease;
}
.history-item:hover  { transform: translateY(-2px); box-shadow: 0 6px 18px rgba(85, 70, 150, 0.20); }
.history-item:active { transform: scale(0.98); }
.history-item img {
  width: 100%;
  height: 110px;
  object-fit: cover;
  display: block;
  background: #000;
}
.history-item-meta {
  padding: 8px 10px;
  display: flex;
  flex-direction: column;
  gap: 2px;
}
.history-item-text {
  font-size: 12px;
  color: var(--text-primary);
  font-weight: 600;
  line-height: 1.3;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
.history-item-time {
  font-size: 10px;
  color: var(--text-muted);
  font-variant-numeric: tabular-nums;
}
.history-item-del {
  position: absolute;
  top: 6px;
  right: 6px;
  width: 28px;
  height: 28px;
  border-radius: 50%;
  background: rgba(var(--accent-purple-rgb), 0.55);
  color: #fff;
  display: flex;
  align-items: center;
  justify-content: center;
  border: none;
  opacity: 0;
  transition: opacity 0.2s ease, background 0.2s ease;
}
.history-item:hover .history-item-del { opacity: 1; }
.history-item-del:hover { background: var(--danger); }
.history-item-del svg { width: 14px; height: 14px; }

/* (A short-mobile title shrink used to sit here. It was a reflow: the wordmark
   and subtitle got smaller on short phones while the block they lived in kept
   its height, so the type changed size without the layout agreeing. The type
   does scale again on short phones, but through --wordmark-size and --cap-size
   in THE SCALE LADDER at the end of this file, which moves the type and the
   band that holds it in the same step. That was the missing half here.) */

/* ─────────────────────────────────────────────────────────────────────
   Sign-in gate (ported from mesaj.palefo.dev) — mandatory Palefò SSO.
   NervalFrost: frost backdrop, white card, lavender strokes, violet
   primary. Shown/hidden by js/fotolekti-auth.js via .open.
   ──────────────────────────────────────────────────────────────────── */
.gate-modal {
  position: absolute;
  inset: 0;
  z-index: 200;                /* above all chrome, inside the frame */
  display: none;
  align-items: center;
  justify-content: center;
  padding: 20px;
}
.gate-modal.open { display: flex; }
.gate-backdrop {
  position: absolute;
  inset: 0;
  background: rgba(227, 229, 250, 0.80);   /* periwinkle wash */
  backdrop-filter: blur(6px);
}
.gate-card {
  position: relative;
  width: 100%;
  max-width: 330px;
  max-height: 100%;
  overflow-y: auto;
  background: #ffffff;
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  box-shadow: 0 22px 55px rgba(85, 70, 150, 0.30);   /* mesaj sheet shadow */
  padding: 22px 20px;
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: 10px;
  text-align: center;
}
.gate-logo {
  height: 40px;
  object-fit: contain;
  align-self: center;
}
.gate-title {
  font-family: 'Playfair Display', Georgia, serif;
  font-style: italic;
  font-weight: 500;
  font-size: 22px;
  color: var(--text-primary);
}
.gate-subtitle {
  font-size: 13.5px;
  line-height: 1.4;
  color: var(--text-secondary);
}
.gate-error {
  display: none;
  font-size: 13px;
  font-weight: 600;
  color: var(--danger);
  background: rgba(233, 69, 96, 0.10);
  border-radius: 10px;
  padding: 8px 10px;
}
.gate-error.show { display: block; }
.gate-error.info {
  color: var(--text-primary);
  background: var(--bg-input);
}
/* Social buttons: white pills with the lavender stroke; Facebook/Apple keep
   their brand fills (recognition beats theming, mesaj does the same). */
.signin-btn {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
  height: 44px;
  border-radius: var(--radius-full);
  font-size: 14px;
  font-weight: 700;
  background: #ffffff;
  color: var(--text-primary);
  border: 1px solid var(--border);
  box-shadow: 0 2px 10px rgba(60, 30, 100, 0.06);
  transition: transform 0.12s ease, filter 0.15s ease;
}
.signin-btn:active { transform: scale(0.98); }
.signin-btn-facebook { background: #1877F2; color: #fff; border: none; }
.signin-btn-apple    { background: #000000; color: #fff; border: none; }
.signin-ico { width: 20px; height: 20px; flex-shrink: 0; }
.signin-alt-methods {
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.signin-alt-pill {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  height: 38px;
  border-radius: var(--radius-full);
  font-size: 13px;
  font-weight: 600;
  background: var(--bg-input);
  color: var(--text-primary);
  border: 1px solid var(--border);
  text-decoration: none;
  cursor: pointer;
}
.signin-alt-pill:hover { background: var(--bg-input-hover); }
.signin-alt-pill-icon { width: 16px; height: 16px; flex-shrink: 0; }
.gate-hint {
  font-size: 12px;
  line-height: 1.4;
  color: var(--text-muted);
}
.gate-support {
  align-self: center;
  border: none;
  background: none;
  font-size: 12.5px;
  font-weight: 600;
  color: var(--accent);
  cursor: pointer;
  padding: 4px 8px;
}
.gate-support:hover { text-decoration: underline; }

/* Account — mesaj's .sheet-account, and now literally its shape: name + email
   at the left, one pill at the right, ONE row. There is no "Kont" label any
   more because mesaj doesn't have one and doesn't need one: the name is the
   label. (It used to be a three-area grid — label line, then who + button,
   then the two session buttons — which is how a single setting came to be
   86px tall.) The session-gated pair is now its own .settings-row. */
.account-row {
  gap: 12px;
}
.account-who {
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
}
.account-name {
  font-size: 0.95rem;
  font-weight: 400;
  letter-spacing: 0.005em;
  color: rgba(49, 86, 142, 0.88);
}
.account-email {
  font-size: 0.75rem;
  font-weight: 400;
  color: rgba(49, 86, 142, 0.5);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* Quiet outline pill, not a gradient one. Sign-in is not the sheet's call to
   action — it is one row among seven — and the gradient made it shout over
   every control above it. mesaj's .sheet-account-btn does exactly this.
   (.account-btn is not named in CANON-LOCK [13], so the gradient list is
   untouched.) */
.account-btn {
  flex-shrink: 0;
  font-family: inherit;
  font-size: 0.82rem;
  font-weight: 600;
  color: rgb(var(--accent-purple-rgb));
  background: rgba(84, 44, 194, 0.08);
  border: 1px solid rgba(86, 44, 195, 0.5);
  border-radius: var(--radius-full);
  padding: 7px 16px;
  cursor: pointer;
  transition: background 0.2s ease;
}
.account-btn:hover  { background: rgba(84, 44, 194, 0.14); }
.account-btn:active { transform: scale(0.97); }
/* mesaj's .sheet-row-buttons: equal widths, square corners so they read as
   secondary next to the pill above them. */
.account-actions {
  justify-content: space-between;
  gap: 10px;
}
.account-link {
  flex: 1 1 0;
  text-align: center;
  padding: 9px 0;
  border-radius: 10px;
  background: rgba(84, 44, 194, 0.07);
  border: 1px solid var(--border);
  font-size: 0.78rem;
  font-weight: 600;
  color: var(--text-secondary);
  cursor: pointer;
}
.account-link:hover { color: var(--accent); }
.account-link.danger:hover { color: var(--danger); }

/* ── Annotations on the result screen (js/annotate.js): scribble + voice ── */
/* Live drawing surface over the photo. Only accepts pointer input in draw
   mode; otherwise it just displays the strokes. */
.note-canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
  z-index: 2;
}
.video-box[data-note="draw"] .note-canvas {
  pointer-events: auto;
  touch-action: none;
  cursor: crosshair;
}
/* In draw mode the caption would sit between finger and canvas — lift it. */
.video-box[data-note="draw"] .video-caption { pointer-events: none; opacity: 0.35; }

/* Shared glass round button used by all annotation controls. */
.note-btn {
  width: 32px;
  height: 32px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(255, 255, 255, 0.86);
  color: var(--text-primary);
  border: 1px solid rgba(var(--accent-2-rgb), 0.18);
  box-shadow: 0 2px 8px rgba(var(--accent-2-rgb), 0.18);
  backdrop-filter: blur(4px);
  transition: background 0.15s ease, color 0.15s ease, transform 0.12s ease;
}
.note-btn:hover  { background: #fff; color: var(--accent); }
.note-btn:active { transform: scale(0.92); }
.note-btn svg    { width: 17px; height: 17px; }
.note-btn[aria-pressed="true"] { background: var(--gradient-primary); color: #fff; border-color: transparent; }
/* A dot marks an entry button whose annotation exists. */
.note-btn.has-note { position: relative; }
.note-btn.has-note::after {
  content: "";
  position: absolute;
  top: -2px; right: -2px;
  width: 10px; height: 10px;
  border-radius: 50%;
  background: #e5484d;
  border: 2px solid #fff;
}

/* Entry cluster — top-right of the photo, Stories-style. */
.note-entry {
  position: absolute;
  top: 10px; right: 10px;
  display: flex;
  gap: 8px;
  z-index: 4;
}

/* Voice-note chip — bottom-left mini player: ▶/⏸ · ⏹ · 🎤 0:12 · 🗑. */
.note-chip {
  position: absolute;
  left: 10px; bottom: 10px;
  z-index: 4;
  display: inline-flex;
  align-items: center;
  gap: 2px;
  height: 32px;
  padding: 0 5px;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.9);
  color: var(--text-primary);
  border: 1px solid rgba(var(--accent-2-rgb), 0.18);
  box-shadow: 0 2px 8px rgba(var(--accent-2-rgb), 0.18);
  font-size: 12px;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
}
.note-chip[hidden] { display: none; }
.note-chip svg { width: 15px; height: 15px; }
.note-chip-btn {
  width: 26px;
  height: 26px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  background: transparent;
  color: inherit;
  transition: background 0.15s ease, transform 0.12s ease;
}
.note-chip-btn:hover  { background: rgba(var(--accent-1-rgb), 0.14); }
.note-chip-btn:active { transform: scale(0.9); }
.note-chip-meta {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: 0 3px;
}
/* Stop only earns its place while the note is playing. */
.note-chip-stop { display: none; }
.video-box.note-playing .note-chip-stop { display: flex; }
.video-box.note-playing .note-chip-btn:hover { background: rgba(255, 255, 255, 0.22); }
.video-box.note-playing .note-chip { background: var(--gradient-primary); color: #fff; }
.video-box[data-note="voice"] .note-chip { display: none; }   /* the sheet shows the same info */

/* Tool strips — glass bar hugging the photo's bottom edge. */
.note-strip {
  position: absolute;
  left: 10px; right: 10px; bottom: 10px;
  z-index: 5;
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 7px 8px;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.86);
  border: 1px solid rgba(var(--accent-2-rgb), 0.18);
  box-shadow: 0 6px 18px rgba(var(--accent-2-rgb), 0.22);
  backdrop-filter: blur(8px);
}
.note-strip[hidden] { display: none; }
.note-swatches { display: flex; gap: 6px; margin-right: auto; }
.note-swatch {
  width: 22px; height: 22px;
  border-radius: 50%;
  border: 2px solid #fff;
  box-shadow: 0 0 0 1px rgba(var(--accent-2-rgb), 0.25);
  transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.note-swatch[data-color="#e5484d"] { background: #e5484d; }
.note-swatch[data-color="#ffd23f"] { background: #ffd23f; }
.note-swatch[data-color="#31568e"] { background: #31568e; }
.note-swatch[aria-checked="true"] { transform: scale(1.18); box-shadow: 0 0 0 2px var(--accent); }
.note-done {
  padding: 6px 12px;
  border-radius: 999px;
  background: var(--gradient-primary);
  color: #fff;
  font-size: 12px;
  font-weight: 700;
  box-shadow: 0 0.2rem 0.5rem var(--btn-shadow);
}
.note-done:active { transform: scale(0.96); }

/* Voice sheet: [● rec] [0:12 · hint] [▶] [↺] [🗑] [Fini] */
.note-rec {
  flex-shrink: 0;
  width: 36px; height: 36px;
  border-radius: 50%;
  display: flex; align-items: center; justify-content: center;
  background: #e5484d;
  color: #fff;
  box-shadow: 0 0 0 0 rgba(229, 72, 77, 0.45);
  transition: transform 0.12s ease;
}
.note-rec svg { width: 18px; height: 18px; }
.note-rec:active { transform: scale(0.94); }
.note-strip-voice[data-state="recording"] .note-rec { animation: note-pulse 1.1s ease-out infinite; }
@keyframes note-pulse {
  0%   { box-shadow: 0 0 0 0 rgba(229, 72, 77, 0.45); }
  100% { box-shadow: 0 0 0 12px rgba(229, 72, 77, 0); }
}
.note-voice-meta {
  display: flex;
  flex-direction: column;
  min-width: 0;
  margin-right: auto;
  line-height: 1.15;
}
.note-time { font-size: 14px; font-weight: 700; font-variant-numeric: tabular-nums; color: var(--text-primary); }
.note-hint { font-size: 11px; color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.note-strip-voice[data-state="recording"] .note-time { color: #e5484d; }
.note-when-recorded { display: none; }
.note-strip-voice[data-state="recorded"] .note-when-recorded { display: flex; }
.note-strip.note-flash .note-hint { color: #c9302c; font-weight: 700; }

/* Share buttons that will carry an annotation get a red corner dot. Drawn as
   a background layer because both pseudo-elements are the hover tooltip. */
.icon-btn.has-attachment,
.icon-btn.has-attachment:hover {   /* survives the .whatsapp-btn:hover `background` shorthand */
  background-image: radial-gradient(circle at calc(100% - 9px) 9px, #e5484d 0 3.5px, #fff 4px 5.5px, transparent 6px);
}

/* ── Crop mode (✂️): draggable, corner-resizable rect over the photo ────── */
.note-crop {
  position: absolute;
  inset: 0;
  z-index: 3;
  overflow: hidden;             /* clips the dimming shadow to the photo box */
  touch-action: none;
}
.note-crop[hidden] { display: none; }
.note-crop-box {
  position: absolute;
  border: 2px solid #fff;
  border-radius: 4px;
  /* Dim everything OUTSIDE the chosen area. */
  box-shadow: 0 0 0 9999px rgba(16, 14, 40, 0.55);
  cursor: move;
  touch-action: none;
}
/* Rule-of-thirds guides. */
.note-crop-box::before, .note-crop-box::after {
  content: "";
  position: absolute;
  pointer-events: none;
  border: 0 solid rgba(255, 255, 255, 0.45);
}
.note-crop-box::before { inset: 33.33% 0; border-width: 1px 0; }
.note-crop-box::after  { inset: 0 33.33%; border-width: 0 1px; }
.note-crop-h {
  position: absolute;
  width: 22px;
  height: 22px;
  /* Visible knob smaller than the touch area. */
  background: radial-gradient(circle, #fff 0 6px, transparent 6.5px);
  filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.4));
  touch-action: none;
}
.note-crop-h[data-h="nw"] { top: -11px; left: -11px; cursor: nwse-resize; }
.note-crop-h[data-h="se"] { bottom: -11px; right: -11px; cursor: nwse-resize; }
.note-crop-h[data-h="ne"] { top: -11px; right: -11px; cursor: nesw-resize; }
.note-crop-h[data-h="sw"] { bottom: -11px; left: -11px; cursor: nesw-resize; }
/* Edge handles: pill-shaped knobs mid-side; drag one axis only. */
.note-crop-h[data-h="n"], .note-crop-h[data-h="s"] {
  left: 50%;
  transform: translateX(-50%);
  width: 34px;
  background: radial-gradient(6px 3.5px at center, #fff 0 99%, transparent 100%);
  cursor: ns-resize;
}
.note-crop-h[data-h="n"] { top: -11px; }
.note-crop-h[data-h="s"] { bottom: -11px; }
.note-crop-h[data-h="e"], .note-crop-h[data-h="w"] {
  top: 50%;
  transform: translateY(-50%);
  height: 34px;
  background: radial-gradient(3.5px 6px at center, #fff 0 99%, transparent 100%);
  cursor: ew-resize;
}
.note-crop-h[data-h="e"] { right: -11px; }
.note-crop-h[data-h="w"] { left: -11px; }
/* While cropping, hide the overlays that would sit above the veil. */
.video-box[data-note="crop"] .video-caption,
.video-box[data-note="crop"] .note-chip,
.video-box[data-note="crop"] .note-canvas { display: none; }

/* Crop strip extras: plain (non-gradient) cancel + a spacer pushing the
   actions right, mirroring the draw strip's layout. */
.note-spacer { margin-right: auto; }
.note-plain {
  padding: 6px 12px;
  border-radius: 999px;
  background: transparent;
  color: var(--text-secondary);
  font-size: 12px;
  font-weight: 700;
}
.note-plain:hover { color: var(--text-primary); }

/* "Tradui zòn sa a" — appears after a crop is applied; feeds the cropped
   area back through the pipeline. Gradient pill, bottom-center of the photo. */
.note-reread {
  position: absolute;
  left: 50%;
  bottom: 10px;
  transform: translateX(-50%);
  z-index: 4;
  display: inline-flex;
  align-items: center;
  gap: 7px;
  height: 32px;
  padding: 0 14px;
  border-radius: 999px;
  background: var(--gradient-primary);
  color: #fff;
  font-size: 12px;
  font-weight: 700;
  white-space: nowrap;
  box-shadow: 0 4px 12px var(--btn-shadow);
  transition: transform 0.12s ease;
}
.note-reread:active { transform: translateX(-50%) scale(0.96); }
.note-reread svg { width: 15px; height: 15px; }
.note-reread[hidden] { display: none; }
/* Out of the way while a tool is open. */
.video-box[data-note="crop"] .note-reread,
.video-box[data-note="draw"] .note-reread,
.video-box[data-note="voice"] .note-reread { display: none; }

/* ── + options (mesaj's input-plus / plus-menu pattern) ─────────────────── */
.plus-wrap { position: relative; flex: 0 0 auto; }
/* The + card while it is mounted on the preview (edit-first) screen. It is
   moved here by js/app.js only during editing, so modes can be changed before
   the photo is sent — nothing is billed until Kontinye.

   BOTTOM-right, beside Kontinye — not the top corner. The photo box already
   owns its top-right (crop / re-read controls) and a + there overlaps their
   tap targets; and a card dropping from the top covers Kontinye itself, hiding
   the primary action while you choose modes. Down here the geometry matches
   the camera's capture row: the + sits next to the action button and the card
   rises above it, clear of both. */
.stage-pane[data-pane="result"] > .plus-wrap {
  position: absolute;
  bottom: 12px;
  right: 16px;
  z-index: 61;
}
/* Rises from a bottom anchor exactly as it does on the camera row (the base
   rule already sets bottom: 100% + 8px), but right-aligned so a 210px card
   hanging off a right-hand anchor cannot overflow the phone frame. */
.stage-pane[data-pane="result"] > .plus-wrap .plus-menu {
  left: auto;
  right: 0;
}
/* Safety net: should a mount ever be left behind, the finished result screen
   must not show a stray +. Only the editing state reveals it. */
.stage-pane[data-pane="result"]:not([data-editing]) > .plus-wrap { display: none; }
.plus-btn {
  width: 34px;
  height: 34px;
  padding: 0;
  border: none;
  background: transparent;
  color: rgba(var(--accent-2-rgb), 0.6);     /* mesaj's subdued blue-grey */
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  transition: color 0.2s ease, transform 0.15s ease;
}
.plus-btn svg { width: 24px; height: 24px; }
.plus-btn:hover { color: rgba(var(--accent-2-rgb), 0.9); }
.plus-btn:active { transform: scale(0.88); }
.plus-btn[aria-expanded="true"] { color: var(--accent); transform: rotate(45deg); }

.plus-menu {
  position: absolute;
  left: 0;
  bottom: calc(100% + 8px);          /* floats above the + (mesaj) */
  z-index: 60;
  min-width: 210px;
  padding: 6px;
  /* Fully OPAQUE card. The translateZ forces an own compositing layer —
     without it, iOS Safari's hardware video layer (the live viewfinder
     underneath) can bleed through overlapping DOM and the menu reads as
     transparent on the phone. */
  background: #ffffff;
  opacity: 1;
  transform: translateZ(0);
  -webkit-transform: translateZ(0);
  isolation: isolate;
  border: 1px solid var(--border);
  border-radius: 16px;
  box-shadow: 0 12px 34px rgba(85, 70, 150, 0.30);
}
.plus-menu[hidden] { display: none; }
.plus-menu-title {
  margin: 0;
  font-size: 0.72rem;
  font-weight: 700;
  letter-spacing: 0.03em;
  text-transform: uppercase;
  color: rgba(var(--accent-2-rgb), 0.5);
  padding: 4px 8px 6px;
}
.plus-item {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  padding: 9px 12px;
  border-radius: 10px;
  cursor: pointer;
  font-size: 0.92rem;
  font-weight: 500;
  color: rgb(var(--accent-2-rgb));
}
.plus-item:hover { background: rgba(86, 44, 195, 0.06); }
.plus-item input[type="checkbox"] {
  width: 17px;
  height: 17px;
  accent-color: var(--accent);
  flex-shrink: 0;
}
.plus-sep { height: 1px; margin: 4px 8px; background: var(--border); }
/* Condition sub-checkboxes under Konsèy — indented, smaller, and dimmed
   while Konsèy itself is unchecked. */
/* AI-advice disclaimer inside the + menu — fine print under Konsèy. */
.plus-note {
  margin: 2px 0 4px;
  padding: 0 12px 0 34px;
  font-size: 0.68rem;
  line-height: 1.35;
  color: var(--text-muted);
}
.plus-menu[data-advice="off"] .plus-note { opacity: 0.45; }
/* Free-text steer under Konsèy ("what kind of advice…"). Sits in the menu's
   own rhythm — same inset as a .plus-item's label, quiet until focused — so it
   reads as part of the Konsèy group rather than a separate form. Dimmed with
   the rest of that group when Konsèy is off. */
.plus-input {
  display: block;
  width: calc(100% - 16px);
  margin: 2px 8px 6px;
  padding: 6px 8px;
  font: inherit;
  font-size: 0.82rem;
  color: var(--text-primary);
  background: var(--bg-input);
  border: 1px solid transparent;
  border-radius: 8px;
  outline: none;
  transition: border-color 0.15s ease, background 0.15s ease;
}
.plus-input::placeholder { color: var(--text-muted); }
.plus-input:focus {
  border-color: var(--border-strong);
  background: var(--bg-surface);
}
.plus-menu[data-advice="off"] .plus-input { opacity: 0.45; }

/* ── Edit-first step: crop before translating ───────────────────────────── */
.edit-go-btn {
  position: absolute;
  left: 50%;
  bottom: 10px;
  transform: translateX(-50%);
  z-index: 4;
  height: 34px;
  padding: 0 20px;
  border-radius: 999px;
  background: var(--gradient-primary);
  color: #fff;
  font-size: 13px;
  font-weight: 700;
  white-space: nowrap;
  box-shadow: 0 4px 12px var(--btn-shadow);
  transition: transform 0.12s ease;
}
.edit-go-btn:active { transform: translateX(-50%) scale(0.96); }
.edit-go-btn[hidden] { display: none; }
/* Keep it out of the way while a tool strip is open. */
.video-box[data-note="crop"] .edit-go-btn,
.video-box[data-note="draw"] .edit-go-btn,
.video-box[data-note="voice"] .edit-go-btn { display: none; }

/* While editing (before any translation), the audio-and-share chrome and the
   annotations that only make sense on a result are hidden. */
.stage-pane[data-pane="result"][data-editing] > .audio-controls,
.stage-pane[data-pane="result"][data-editing] > .result-toolbar,
.stage-pane[data-editing] .video-caption,
.stage-pane[data-editing] .note-entry-btn[data-note="draw"],
.stage-pane[data-editing] .note-entry-btn[data-note="voice"],
.stage-pane[data-editing] .note-reread { display: none; }

/* Mode chips on history cards: tiny pills naming which readings the capture
   holds (Tradiksyon / Transkripsyon / Rezime / Eksplikasyon / Konsèy). One
   accent per mode so the mix is scannable at a glance. */
.history-item-modes {
  display: flex;
  flex-wrap: wrap;
  gap: 4px;
}
.history-mode-chip {
  font-size: 9px;
  font-weight: 700;
  letter-spacing: 0.02em;
  text-transform: uppercase;
  line-height: 1;
  padding: 3px 6px;
  border-radius: 999px;
  white-space: nowrap;
}
.history-mode-chip[data-mode="translate"]  { background: rgba(var(--accent-1-rgb), 0.14); color: rgb(var(--accent-1-rgb)); }
.history-mode-chip[data-mode="transcribe"] { background: rgba(var(--accent-2-rgb), 0.12); color: rgb(var(--accent-2-rgb)); }
.history-mode-chip[data-mode="summary"]    { background: rgba(var(--accent-purple-rgb), 0.14); color: rgb(var(--accent-purple-rgb)); }
.history-mode-chip[data-mode="explain"]    { background: rgba(46, 125, 90, 0.14); color: #2e7d5a; }
.history-mode-chip[data-mode="advice"]     { background: rgba(196, 122, 30, 0.16); color: #a3660f; }
/* Not a reading mode — it marks a capture carrying a scribble and/or a voice
   note. It shares the chip row because it answers the same question the mode
   chips do: what is in this entry. Deliberately the quietest chip of the set
   (neutral tokens, no accent) so it reads as a property of the capture rather
   than competing with what the capture SAYS. */
.history-mode-chip[data-mode="annotated"]  { background: var(--bg-input); color: var(--text-muted); }

/* AI-advice disclaimer on the result screen. */
.advice-disclaimer {
  margin: 0;
  font-size: 10px;
  line-height: 1.35;
  text-align: center;
  color: var(--text-muted);
}
.advice-disclaimer[hidden] { display: none; }
.stage-pane[data-pane="result"] > .advice-disclaimer { position: relative; z-index: 2; }
.stage-pane[data-pane="result"][data-editing] > .advice-disclaimer { display: none; }

/* Camera warm-up shimmer: a soft pulsing lens dot centered in the frame,
   visible until the live stream paints over it. */
.viewfinder-wrap::before {
  content: "";
  position: absolute;
  top: 50%;
  left: 50%;
  width: 54px;
  height: 54px;
  margin: -27px 0 0 -27px;
  border-radius: 50%;
  border: 3px solid rgba(255, 255, 255, 0.75);
  box-shadow: 0 0 0 8px rgba(255, 255, 255, 0.10);
  animation: lens-pulse 1.4s ease-in-out infinite;
}
/* …but only while a stream is actually coming. With no camera it is a lens
   warming up for something that will never arrive, and it pulses behind the
   "unavailable" message. */
#stage[data-camera="off"] .viewfinder-wrap::before { display: none; }
@keyframes lens-pulse {
  0%, 100% { transform: scale(1);    opacity: 0.75; }
  50%      { transform: scale(0.86); opacity: 0.35; }
}
/* The video sits above the shimmer, so once frames arrive it disappears. */
#viewfinder { position: relative; z-index: 1; }   /* transparent until frames arrive */

/* ─────────────────────────────────────────────────────────────────────
   THE SCALE LADDER — last in the file on purpose
   ────────────────────────────────────────────────────────────────────
   This replaced a SHED ladder, and the difference is the whole point.

   The shed model let the chrome keep its size and paid for every lost pixel of
   height out of the camera window, dropping whole elements only at the end. On
   a 390x664 phone — Safari's viewport on the device Franck actually uses — that
   left the window 258px tall: 38.9% of the screen for the one thing the app is
   for, and 61% for the furniture around it. Its third step, 645, also deleted the
   hero artwork outright, which is not an illustration to spend: it is the app's
   logo, and it stays on screen at every height.

   So the chrome SCALES instead. The bands below step the bar, the artwork, the
   wordmark, the seal, the gaps, the padding and the capture row down a few px
   at a time, each where it still has room to give. They are not uniform: 680
   and 640 move all seven; 731 moves only the bar, and sheds the subtitle with
   it; 880 and 820 move five and leave the bar and the row alone. Between them
   they keep the window at roughly HALF the viewport all the way down.

   Measured at 390 wide, window height as a share of the viewport:

     932  470px  50.4%     700  333px  47.6%     600  289px  48.2%
     880  458px  52.1%     681  314px  46.1%     581  270px  46.5%
     844  422px  50.0%     664  325px  48.9%     568  275px  48.4%
     820  410px  50.0%     640  317px  49.5%     540  247px  45.7%
     771  361px  46.8%     620  297px  47.9%     536  243px  45.3%
     732  339px  46.3%

   Swept every 8px from 536 to 936, plus each band edge and the pixel either
   side of it, plus 932/844/760/700/664/620/568/540 — 76 heights in all, and
   every figure in the table above re-measured one by one. The worst case at any height a shipping phone presents (568 and
   up) is 46.1%, at 681. Below that only the two rows under 568 fall under 46,
   bottoming at 45.3% at 536, which is --frame-min-h: the frame stops shrinking
   there, so nothing the ladder does can move that number. Against 38.9% at the
   height that prompted this. No page scrolls at any of those heights; all six
   stage panes clear the frame with no overflow at 664 and 540 on both skins,
   and light and dark measure identically throughout.

   Thresholds sit just ABOVE common device heights rather than on them, and the
   bands are narrow. The shipped ladder had four steps for the whole range —
   731/675/645/542 — so a 390x664 phone took the 675 step and then rode it down
   11px with nothing giving way, which is how it ended up at 38.9%. The step
   below, 645, would not have helped: it worked by deleting the hero. Band WIDTH is the thing to watch, not just
   where the edges fall — the 820 band here exists only because the first cut of
   this ladder left a 110px-wide band whose tail sagged to 45.3% at 771.

   The floors, at 580 and below, are the smallest each element may be and still
   do its job: the side discs 44px — the thumb minimum, and the binding one —
   the shutter 46px because it is the primary control and should stay the
   largest, captions 10px, seal 24px, and the hero band 60px, which renders the
   artwork itself 50px tall. The wordmark and the artwork are NEVER hidden at
   any step. The subtitle is the
   one thing still shed rather than scaled — it repeats the wordmark above it.

   This block is at the END of the file, after every rule it overrides, and that
   is load-bearing twice over. The first version sat beside the .device-frame
   media query, 103 lines BEFORE .hero-section, where `display:none` lost to the
   base `display:flex` on source order. This version is the first to scale the
   capture row, and when it was written above the capture-row redesign its
   `.capture-row { --disc-snap }` steps lost to the plain `.capture-row` clamp
   below — same specificity, later source order — and the shutter stayed 56px at
   every height from 932 to 540. That was caught before it shipped, not in the
   field: the SHED ladder never touched the row at all, so nothing was broken on
   live; 56px was simply the clamp evaluating at a constant 390px width. Both
   were found by measuring, not reading.

   Scoped below 480px because at and above it the frame is the fixed 430x932
   canvas and none of this applies. */
/* The ladder itself lives at the very end of the file, below the capture row it
   is keyed to. There used to be a second one here, with higher thresholds, for
   the skin whose row was taller — both rows are the same now, so one ladder
   serves both and the two can no longer drift apart. */

/* Below --frame-min-h the frame stops shrinking and hangs off the bottom of
   the viewport; the seal and the token count go with it, by design. The scale
   ladder made this edge WORSE on purpose: the shed ladder used to delete the
   hero outright down here, and keeping the artwork costs the 60px band it now
   holds. Measured at 320x430, on both skins (they share the 536 floor and the
   same row): the frame is 106px under its floor, the captions 40px past the
   fold, the shutter's bottom 8px past, and the side discs' bottoms 7px past —
   their tops are still 37px above it, so about 37 of each 44px disc stays
   tappable. Before this change the shutter cleared the fold by 8px and the side
   discs by 11px.

   That is the trade, and it is paid where nothing ships. 320x430 is below every
   shipping phone. At 320x480 — the smallest that ever shipped — everything
   clears: side discs 43px above the fold, shutter 42px, captions 10px. At
   320x568 and 360x640 the whole frame is inside the viewport, the capture row
   clearing the fold by 66px and 74px. Documented rather than laddered away: shedding the captions
   or the artwork would cost every real phone to tidy a viewport nothing has. */
/* ── The + options card on short screens (LIGHT skin) ──
   The card opens UPWARD from the + button, and everything in #stage (z-index 1)
   paints beneath #topbar (z-index 10) — so on a short screen the top of the card
   slid under the bar, its title hidden and untappable. That was already true on
   live at 320x480 (36.4px under) and 360x560 (15.3px); the card inset made
   320x568 worse (6.4px in c268028, 22.4px in 390dcc4), and the redesigned capture
   row brings it back to 13.2px. While the card is open, the stage
   is lifted above the bar: the card then covers the wordmark, as an open
   dropdown should, and every row of it takes taps. It closes on any outside tap,
   as before, and the stage drops back underneath. */
#stage:has(#plus-menu:not([hidden])) {
  z-index: 11;
}

/* ── The + options card on the result/edit screen (LIGHT skin) ──
   With "edit before" on, js/app.js moves the + card onto the result screen,
   where it rises from a + pinned 12px above that screen's bottom. The result
   screen clips its own contents (overflow: hidden keeps the blurred photo
   backdrop inside its rounded corners — that clip is load-bearing), so a card
   taller than the room above the + is simply cut off. On live the light skin
   already lost 3.4-35.4px of the card's title on short phones (35.4px at
   320x480, 13.4px at 390x700). Light's vertical card inset makes that screen
   16px shorter wherever the frame is above its minimum height and both builds
   sit on the same ladder step, so it lost 16px more — measured at 390x700: the
   24px "Opsyon" title cut 29.4px, fully gone, and the first row cut 5.4px.

   Rather than handing back 16px, the card is sized to the room it has: while it
   is open, the result screen becomes a size container (its height comes from
   its parent, never its content, and nothing inside it is position: fixed, so
   containment changes no layout) and the card is capped to that height less
   the + and its gaps, scrolling inside if it must. On tall phones it never
   reaches the cap and is unchanged. */
.stage-pane[data-pane="result"]:has(#plus-menu:not([hidden])) {
  container-type: size;
}
.stage-pane[data-pane="result"] > .plus-wrap .plus-menu {
  /* 100cqh is the screen's CONTENT height (its 18px top and bottom padding
     excluded). The card's top may reach the padding box's top edge less a few
     px: + pinned 12px up, 34px tall, 8px gap — so 100cqh + 36 - 54, less 6px
     of air. */
  max-height: calc(100cqh - 24px);
  overflow-y: auto;
  /* No visible scrollbar — the same treatment as .settings-list. Where the card
     does scroll, a classic scrollbar (desktop Chrome, Windows, Linux) drew a
     straight track down its right side and squared off its rounded corners.
     Touch and wheel scrolling are unaffected, and the half-visible last line
     already says there is more. */
  scrollbar-width: none;
}
.stage-pane[data-pane="result"] > .plus-wrap .plus-menu::-webkit-scrollbar {
  display: none;
}

/* ── The capture row, redesigned (BOTH skins) ──
   Franck: the three buttons read "rough and inelegant" — too big, loosely
   placed, and a shutter whose white-on-purple disc did not work. So on light:

   SIZE — all three are 30% smaller: side discs clamp(64, 18vw, 76) → clamp(44.8,
   12.6vw, 53.2); the shutter clamp(72, 20.5vw, 86) → clamp(50.4, 14.35vw, 60.2).
   The captions stay.

   ALIGNMENT — the row takes its edges from the photo frame above it: it is the
   frame's width, and its first and last discs sit --row-inset inside the frame's
   left and right edges, with the shutter on the centre line. Each column is only
   as wide as its disc; the caption, wider, overhangs it symmetrically.

   THE + — its ink's left edge lies on the frame's left edge, and its ink's bottom
   level with the gallery disc's bottom, rather than floating at mid-height.

   THE SHUTTER — no filled disc: a solid core in the OUTER colour, a clear gap, then
   a ring of clock-face tick marks — longer at the four quarter-
   hours, shorter between, very subtle. The side discs take that same outer colour.
   Dark differs only in ink (css/alt.css): its own outer tone, paler ticks.
   All three keep their drop shadow. */
.capture-row {
  --disc-side: clamp(44.8px, 12.6vw, 53.2px);
  --disc-snap: clamp(50.4px, 14.35vw, 60.2px);
  --row-inset: 26px;          /* disc edge to frame edge; the + sits in this gap */
  --plus-ink-left: 9px;       /* .plus-wrap box edge to the + glyph's ink */
  --plus-ink-bottom: 25px;    /* .plus-wrap top to the + glyph's ink bottom */
  width: calc(100% * var(--vf-share));
  max-width: 500px;           /* the photo frame's own cap */
  justify-content: space-between;
  gap: 0;
  padding: 0 var(--row-inset);
}
.capture-row > [data-cap="snap"]    { width: var(--disc-snap); }
.capture-row > [data-cap="load"],
.capture-row > [data-cap="history"] { width: var(--disc-side); }

/* Captions overhang their narrow columns, so their width is bounded by the
   distance between neighbouring disc centres, less 12px (about 11px stays
   between labels: German at 320 wide, where min-content widens a box); a
   long label takes its second line (the 26px box holds two) rather than
   running into its neighbour. That centre distance is half the frame, less the
   inset and half a side disc. The frame is --vf-share of the stage; 100vw here
   over-estimates it only on the fixed desktop canvas, where 92px fits anyway. */
.capture-row .cap-text {
  /* Never narrower than its longest word: a single word that cannot wrap
     ("précédentes") used to spill out of a narrower box to the right, putting
     the caption up to 3.4px off its disc's centre at 320 wide. */
  min-width: min-content;
  /* A fixed line height, so two lines are exactly 24px in any script: with the
     default one, the two-line Japanese and Korean load captions (25.3px of
     line boxes) had the fallback font's character boxes reaching 27px (scroll
     height 27) in the 26px box. */
  line-height: 12px;
  /* At 320 wide neighbouring disc centres are only 74px apart, so the Kreyòl
     captions (79.1, 72.8 and 69.3px on one line) must take two lines: on one
     line "Chaje yon foto" and "Pran yon foto" would overlap by 1.95px, and the
     other pair sit 3px apart. Balanced breaks keep that tidy — "Foto / pase yo",
     not "Foto pase / yo" with "yo" alone. From about 344 wide only "Chaje yon
     foto" still wraps, and from 359 all three fit on one line. */
  text-wrap: balance;
  width: clamp(60px,
               calc(var(--vf-share) * (100vw - 2 * var(--stage-pad)) / 2
                    - var(--row-inset) - var(--disc-side) / 2 - 12px),
               92px);
}
/* Korean breaks between words, not inside one: at 320-324 wide (640 tall) the
   load caption otherwise split as "사진 불/러오기". Korean only, since Japanese
   and Chinese captions have no spaces to break at. */
:lang(ko) .capture-row .cap-text { word-break: keep-all; }

.load-btn,
.history-btn {
  width: var(--disc-side);
  height: var(--disc-side);
  /* keeps the three discs' centres, and the captions' baselines, on one line */
  margin: calc((var(--disc-snap) - var(--disc-side)) / 2) 0;
  padding: 0;
  background: var(--app-bg-color);
}
.load-icon,
.history-icon { width: 24px; height: 24px; }

.snap-btn {
  position: relative;
  width: var(--disc-snap);
  height: var(--disc-snap);
  background: transparent;
}
.shutter-ring {
  width: 58%;
  height: 58%;
  border: none;
}
.shutter-core {
  width: 100%;
  height: 100%;
  background: var(--app-bg-color);
}
.shutter-ticks {
  display: block;
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
}
.shutter-ticks line {
  stroke: color-mix(in srgb, rgb(var(--accent-1-rgb)), rgb(var(--accent-purple-rgb)));
  stroke-linecap: round;
}
.shutter-ticks .tick-major { stroke-width: 3.2; }
.shutter-ticks .tick-minor { stroke-width: 2.2; opacity: 0.55; }

.capture-row > .plus-wrap {
  left: calc(-1 * var(--plus-ink-left));
  top: calc((var(--disc-snap) + var(--disc-side)) / 2 - var(--plus-ink-bottom));
  transform: none;
}

/* Moved to the very end of the file, below the capture-row redesign. It used
   to sit above it — where `.capture-row { --disc-snap: 53px }` in a media query
   lost on SOURCE ORDER to the plain `.capture-row` clamp further down, same
   specificity. The shutter measured 56px at every height from 932 to 540: in
   that draft the ladder's row steps never once applied. Nothing was broken on
   live, since the shed ladder it replaced never touched the row at all. This
   file documents the same trap twice more — `.settings-popover`'s `bottom`
   above, and the ladder's own `.hero-section { display: none }`. Measured, not
   assumed. */
@media (max-width: 479.98px) and (max-height: 880px) {
  .device-frame {
    --row-drop:       15px;
    --hero-h:        108px;
    --wordmark-size:  29px;
    --logo-h:         42px;
    --logo-img-h:     40px;
    --pane-gap:       18px;
    --stage-pad:      20px;
    --stage-pad-top:  20px;
  }
}
@media (max-width: 479.98px) and (max-height: 820px) {
  .device-frame {
    --row-drop:       14px;
    --hero-h:        102px;
    --wordmark-size:  28px;
    --logo-h:         40px;
    --logo-img-h:     38px;
    --pane-gap:       17px;
    --stage-pad:      19px;
    --stage-pad-top:  18px;
  }
}
@media (max-width: 479.98px) and (max-height: 770px) {
  .device-frame {
    --row-drop:       13px;
    --hero-h:         96px;
    --wordmark-size:  27px;
    --logo-h:         38px;
    --logo-img-h:     36px;
    --pane-gap:       16px;
    --stage-pad:      18px;
    --stage-pad-top:  16px;
    --statusbar-h:    22px;
  }
  .capture-row {
    --disc-side: 47px;
    --disc-snap: 53px;
  }
}
@media (max-width: 479.98px) and (max-height: 731px) {
  /* The subtitle is the one thing that genuinely repeats what is above it, so
     it is the only element still SHED rather than scaled. */
  .subtitle     { display: none; }
  .device-frame { --topbar-h: 60px; }
}
@media (max-width: 479.98px) and (max-height: 680px) {
  .device-frame {
    --row-drop:       12px;
    --hero-h:         84px;
    --topbar-h:       56px;
    --wordmark-size:  25px;
    --logo-h:         34px;
    --logo-img-h:     32px;
    --pane-gap:       15px;
    --stage-pad:      17px;
    --stage-pad-top:  14px;
    --statusbar-h:    20px;
  }
  .capture-row {
    --disc-side: 46px;
    --disc-snap: 51px;
  }
}
@media (max-width: 479.98px) and (max-height: 640px) {
  .device-frame {
    --row-drop:       11px;
    --hero-h:         74px;
    --topbar-h:       54px;
    --wordmark-size:  23px;
    --logo-h:         30px;
    --logo-img-h:     28px;
    --pane-gap:       13px;
    --stage-pad:      16px;
    --stage-pad-top:  12px;
    --stage-gap:       8px;
    --stage-gap-tight: 10px;
  }
  .capture-row {
    --disc-side: 45px;
    --disc-snap: 48px;
    --cap-size:  10.5px;
  }
}
@media (max-width: 479.98px) and (max-height: 600px) {
  .device-frame {
    --row-drop:       11px;
    --hero-h:         68px;
    --topbar-h:       53px;
    --wordmark-size:  22px;
    --logo-h:         28px;
    --logo-img-h:     26px;
    --pane-gap:       12px;
    --statusbar-h:    19px;
  }
  .capture-row {
    --disc-side: 44px;
    --disc-snap: 47px;
    --cap-size:  10px;
  }
}
@media (max-width: 479.98px) and (max-height: 580px) {
  /* The floor. Nothing below this scales further: the side discs are at 44px,
     the minimum a thumb needs, and the shutter is held 2px above them at 46 so
     the primary control stays the biggest; the captions are at the smallest
     type a reader of this app should be given, the seal at the smallest size it
     is still a seal, and the hero band at 60px, which draws the artwork 50px
     tall — the smallest size it is still the logo. Below here the frame stops
     shrinking anyway (--frame-min-h, 536). */
  .device-frame {
    --row-drop:        9px;
    --hero-h:         60px;
    --topbar-h:       52px;
    --logo-h:         26px;
    --logo-img-h:     24px;
    --pane-gap:       11px;
    --stage-pad:      14px;
    --stage-pad-top:  10px;
    --statusbar-h:    18px;
  }
  .capture-row {
    --disc-side: 44px;
    --disc-snap: 46px;
  }
}
