/* ==========================================================================
   PDF2EPUB-web — Apple design language
   FOUNDATION LAYER: tokens, base, typography, primitives.

   Built to /scratchpad/ui/plan/DESIGN.md.  Colour, type, spacing, radii and
   motion are all tokens; nothing below the token blocks hard-codes a colour.
   Provenance tags in the token blocks: [AW] Apple's shipping web CSS ·
   [HIG] published by Apple · [WK] verbatim WebKit constant · [C] community
   measurement · [D] derived here · [A11Y] raised by us to clear a contrast
   floor Apple's own value misses.

   Section map
     0   Fonts
     1   Tokens — light appearance
     2   Tokens — dark appearance
     3   Accessibility escape hatches
     4   Reset & base
     5   Typography
     6   Focus
     7   Icons
     8   Layout shell
     9   Buttons
    10   Icon buttons
    11   Segmented control
    12   Switch
    13   Radio row
    14   Grouped inset list
    15   Text / search / number fields
    16   Select (pull-down)
    17   Cards
    18   Badges & chips
    19   Progress bar
    20   Spinner
    21   Toasts
    22   Empty state
    23   Modal overlay
    24   List table
    25   App header
    26   Counter strip
    27   Utilities
    28   Screens (splash, login, queue, upload, settings, logs)
   ========================================================================== */


/* ==========================================================================
   0. FONTS
   SF Pro is picked up locally from the system stack on Apple hardware and is
   deliberately NOT vendored (its licence forbids redistribution and web
   embedding).  Inter Tight is the self-hosted fallback everywhere else: its
   average advance is +3.2% against SF (Inter proper is +14.6%), so no
   size-adjust is needed.  Both Latin subsets are mandatory — Ó/ó live in
   `latin`, the other sixteen Polish diacritics live in `latin-ext`.
   OFL 1.1; see static/vendor/README.txt.  No ?v= query: this file is served
   by StaticFiles and is never rendered by Jinja, so a {{ v }} here would ship
   literally; style.css itself already carries the cache-busting query.
   ========================================================================== */

@font-face {
    font-family: "Inter Tight";
    font-style: normal;
    font-display: swap;
    font-weight: 100 900;
    src: url("/static/vendor/fonts/inter-tight-latin-wght-normal.woff2") format("woff2-variations");
    unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
        U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
        U+FEFF, U+FFFD;
}

@font-face {
    font-family: "Inter Tight";
    font-style: normal;
    font-display: swap;
    font-weight: 100 900;
    src: url("/static/vendor/fonts/inter-tight-latin-ext-wght-normal.woff2") format("woff2-variations");
    unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304,
        U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB,
        U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}


/* ==========================================================================
   1. TOKENS — LIGHT APPEARANCE
   One accent turquoise (#008387) fills things; #007174 / #00C6CB only ever
   colour text and glyphs.  Ink comes from Apple's web ladder (the iOS semantic
   labels composite below 4.5:1 on white and cannot carry this app's text);
   surfaces come from the iOS grouped ladder, because the grouped inset list
   is the backbone of the settings screen.
   ========================================================================== */

:root {
    color-scheme: light;

    /* ---- accent ------------------------------------------------------- */
    --accent:             #008387;  /* [D] hue 181.6° — the RGB midpoint of
                                       Apple's own published increased-contrast
                                       Mint (#008575, 172.8°) and Teal (#008198,
                                       189.1°).  Apple ships that pair at L≈0.180
                                       precisely so white can sit on it; that is
                                       this ramp's fill level.  The ONLY accent
                                       fill, in BOTH appearances.  white 4.57:1   */
    --accent-hover:       #007F82;  /* [D] −6.6% relative luminance, the same step
                                       Apple takes #0071E3 → #006EDB (−6.3%); the
                                       hover state must darken (§2.1). white 4.82 */
    --accent-active:      #007F82;  /* [D] same value as hover, as Apple does     */
    --link:               #007174;  /* [D] interactive TEXT and glyphs on light,
                                       NEVER a fill — the darker-tint-for-text
                                       rule, exactly as #0066CC was to #0071E3.
                                       5.81:1 on #FFF · 5.21:1 on #F2F2F7         */
    --focus:              #008387;  /* = --accent, as Apple's focus = its fill    */
    --focus-offset:       1px;      /* [AW] --sk-focus-offset                     */
    --focus-offset-box:   3px;      /* [AW] --sk-focus-offset-container           */

    /* ---- surfaces ----------------------------------------------------- */
    --bg-grouped:         #F2F2F7;  /* [HIG] systemGroupedBackground — the page   */
    --surface:            #FFFFFF;  /* [HIG] secondarySystemGroupedBackground     */
    --surface-elevated:   #FFFFFF;  /* toast + overlay only; light has no rung up */
    --live-edge:          rgba(0, 131, 135, .34);  /* the in-flight card, §17 */
    --well:               #F5F5F7;  /* [AW] --sk-fill-tertiary                    */
    --scrim:              rgba(0, 0, 0, .40);          /* [C] iOS modal dim       */

    /* ---- ink ---------------------------------------------------------- */
    --text:               #1D1D1F;  /* [AW] 16.83:1 on --surface                  */
    --text-secondary:     #6E6E73;  /* [AW]  5.07:1 on --surface                  */
    --text-secondary-alt: #424245;  /* [AW] 10.02:1 on --surface                  */
    --text-tertiary:      #86868B;  /* [AW]  3.62:1 — NON-TEXT USE ONLY           */
    --text-secondary-fill:#6E6E73;  /* [AW]  secondary ink for text that sits on
                                       an opaque field or a hover fill, where the
                                       plain secondary loses its margin (§15, §24) */
    --text-on-accent:     #FFFFFF;  /* [D]   4.57:1 on --accent                   */

    /* ---- hairlines & translucent fills --------------------------------- */
    --separator:          rgba(60, 60, 67, .29);       /* [HIG] separator         */
    --hairline-w:         1px;                         /* 0.5px at >=2dppx        */
    --fill-3:             rgba(118, 118, 128, .12);    /* [HIG] tertiary — track  */
    --fill-4:             rgba(116, 116, 128, .08);    /* [HIG] quaternary        */

    /* ---- form controls ------------------------------------------------- */
    --field-fill:         #FFFFFF;  /* opaque by requirement — see §15            */
    --control-border:     #86868B;  /* [A11Y] Apple rests fields at #D2D2D7
                                       (1.68:1) and hovers to #86868B; we rest at
                                       the hover value so every control boundary
                                       clears WCAG 1.4.11's 3:1                   */

    /* ---- status ------------------------------------------------------- */
    --red:       #E30000;  --red-bg:     #FFF7F8;  /* [AW]/[D] 4.67:1             */
    --green:     #008009;  --green-bg:   #F5FFF6;  /* [AW]     5.01:1             */
    --orange:    #B64400;  --orange-bg:  #FFF9F4;  /* [AW]     5.27:1             */
    --info:      #007174;  --info-bg:    #EBF7F8;  /* [D]      5.31:1             */
    --neutral:   #424245;  --neutral-bg: #E8E8ED;  /* [AW]     8.21:1             */
    --dot-pending:    #86868B;
    --dot-processing: #008387;
    --dot-completed:  #008009;
    --dot-failed:     #E30000;

    /* ---- progress ----------------------------------------------------- */
    --progress-track: #E5E5EA;      /* [HIG] systemGray5 light.  3.64:1 against
                                       the fill.  Lifted from #D2D2D7, which still
                                       passes at 3.03:1 but with 1% headroom — a
                                       §1.8 no-headroom pair this change retires.  */
    --progress-fill:  #008387;      /* the one accent fill, both appearances      */

    /* ---- switch ------------------------------------------------------- */
    --switch-off:     rgba(120, 120, 128, .16);  /* [WK] offColor 41/255          */
    --switch-off-hc:  rgba(120, 120, 128, .80);  /* [WK] offHighContrast, nudged
                                                   199->204/255 so the escalation
                                                   actually clears 1.4.11: .78
                                                   composited to 2.95:1 on a white
                                                   card, .80 gives 3.05:1.        */
    --switch-on:      #008387;      /* [HIG] sanctions the app accent over green  */
    --switch-knob:    #FFFFFF;      /* [WK] thumbColor = Color::white             */
    --switch-shadow:  0 2.5px 6px rgba(0, 0, 0, .12);  /* [WK] verbatim           */

    /* ---- material (used exactly once — the header) ---------------------- */
    --glass-filter:    saturate(180%) blur(20px);      /* [AW] verbatim           */
    --glass-bg:        rgba(250, 250, 252, .80);       /* [AW] #FAFAFC @ 80%      */
    --glass-bg-solid:  rgba(250, 250, 252, .92);       /* [AW] no-filter fallback */
    --glass-bg-opaque: #FAFAFC;                        /* reduced-transparency    */
    --glass-hairline:  rgba(0, 0, 0, .16);             /* [AW] local-nav keyline  */

    /* ---- baked glyphs (elements that cannot carry a pseudo-element) ----- */
    --glyph-search:         url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%2386868B' stroke-width='1.75' stroke-linecap='round'%3E%3Ccircle cx='7' cy='7' r='4.5'/%3E%3Cpath d='m10.4 10.4 3.1 3.1'/%3E%3C/svg%3E");
    --glyph-chevron-updown: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%2386868B' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m4.5 6.5 3.5-3.5 3.5 3.5'/%3E%3Cpath d='m4.5 9.5 3.5 3.5 3.5-3.5'/%3E%3C/svg%3E");


    /* ---- CSS mask glyphs -------- same drawings as the sprite in §4 of the
       plan, duplicated here because a CSS mask cannot reference a <symbol>.
       Masks read alpha only, so the colours inside are irrelevant; the paint
       comes from background-color at the use site.  Appearance-independent. */
    --disabled-opacity: .42;
    --mask-spinner:          url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%23fff'%3E%3Crect x='9.1' y='1.7' width='1.8' height='5' rx='.9' opacity='1' transform='rotate(0 10 10)'/%3E%3Crect x='9.1' y='1.7' width='1.8' height='5' rx='.9' opacity='.87' transform='rotate(45 10 10)'/%3E%3Crect x='9.1' y='1.7' width='1.8' height='5' rx='.9' opacity='.75' transform='rotate(90 10 10)'/%3E%3Crect x='9.1' y='1.7' width='1.8' height='5' rx='.9' opacity='.62' transform='rotate(135 10 10)'/%3E%3Crect x='9.1' y='1.7' width='1.8' height='5' rx='.9' opacity='.5' transform='rotate(180 10 10)'/%3E%3Crect x='9.1' y='1.7' width='1.8' height='5' rx='.9' opacity='.37' transform='rotate(225 10 10)'/%3E%3Crect x='9.1' y='1.7' width='1.8' height='5' rx='.9' opacity='.25' transform='rotate(270 10 10)'/%3E%3Crect x='9.1' y='1.7' width='1.8' height='5' rx='.9' opacity='.12' transform='rotate(315 10 10)'/%3E%3C/svg%3E");
    --mask-checkmark:        url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23000' stroke-linecap='round' stroke-linejoin='round' stroke-width='2'%3E%3Cpath d='m3.5 8.5 3 3 6-7'/%3E%3C/svg%3E");
    --mask-warning:          url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23000' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.75'%3E%3Cpath d='M8.87 2.7a1 1 0 0 0-1.74 0L1.7 12.3a1 1 0 0 0 .87 1.5h10.86a1 1 0 0 0 .87-1.5Z'/%3E%3Cpath d='M8 6.3v3.4'/%3E%3Ccircle cx='8' cy='11.7' r='.55' fill='%23000' stroke='none'/%3E%3C/svg%3E");
    --mask-checkmark-circle: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23000' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.75'%3E%3Ccircle cx='8' cy='8' r='6.5'/%3E%3Cpath d='m5 8.2 2.2 2.2L11 5.8'/%3E%3C/svg%3E");
    --mask-info-circle:      url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23000' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.75'%3E%3Ccircle cx='8' cy='8' r='6.5'/%3E%3Cpath d='M8 7.5v4'/%3E%3Ccircle cx='8' cy='4.9' r='.55' fill='%23000' stroke='none'/%3E%3C/svg%3E");

    /* ---- type --------------------------------------------------------- */
    --font-text:    -apple-system, BlinkMacSystemFont, "SF Pro Text", "Inter Tight",
                    "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
    --font-display: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Inter Tight",
                    "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
    --font-mono:    ui-monospace, "SF Mono", SFMono-Regular, Menlo, Monaco,
                    "Cascadia Mono", Consolas, monospace;

    --fs-caption2: .75rem;    --lh-caption2: 1.33337; --tr-caption2: -.010em;  /* 12 */
    --fs-footnote: .8125rem;  --lh-footnote: 1.38460; --tr-footnote: -.006em;  /* 13 */
    --fs-caption:  .875rem;   --lh-caption:  1.42859; --tr-caption:  -.016em;  /* 14 */
    --lh-caption-tight: 1.28577;
    --fs-body:     1.0625rem; --lh-body:     1.47059; --tr-body:     -.022em;  /* 17 */
    --lh-body-tight: 1.23536;
    --fs-title3:   1.1875rem; --lh-title3:   1.21053; --tr-title3:   +.012em;  /* 19 */
    --fs-title2:   1.3125rem; --lh-title2:   1.19048; --tr-title2:   +.011em;  /* 21 */
    --fs-title1:   1.5rem;    --lh-title1:   1.16667; --tr-title1:   +.009em;  /* 24 */
    --fs-large:    1.75rem;   --lh-large:    1.14286; --tr-large:    +.007em;  /* 28 */
    --fs-hero:     2.125rem;  --lh-hero:     1.11765; --tr-hero:     +.004em;  /* 34 */
    --fw-regular: 400;  --fw-semibold: 600;   /* only these two — never 500/700/800 */

    /* ---- space (4-grid, keeping Apple's published 11 and 44) ------------ */
    --sp-1: 2px;  --sp-2: 4px;  --sp-3: 6px;  --sp-4: 8px;  --sp-5: 11px; --sp-6: 12px;
    --sp-7: 16px; --sp-8: 20px; --sp-9: 24px; --sp-10: 32px; --sp-12: 48px;
    --gutter:      22px;   /* [AW] apple.com container padding                    */
    --gutter-sm:   16px;   /* [AW] apple.com below 834px; [HIG] widget margin      */
    --row-inset:   16px;   /* [C]  grouped-list row inset = separator start        */
    --content-max: 980px;  /* [AW] apple.com container max-width                   */
    --hit:         44px;   /* [HIG] published minimum hit region                   */

    /* ---- safe areas ----  Named once so every consumer reads the same
       fallback.  They are 0px everywhere except a notched device with
       viewport-fit=cover on the viewport meta, which index.html now sets. */
    --safe-t: env(safe-area-inset-top, 0px);
    --safe-b: env(safe-area-inset-bottom, 0px);
    --safe-l: env(safe-area-inset-left, 0px);
    --safe-r: env(safe-area-inset-right, 0px);

    /* ---- radii -------------------------------------------------------- */
    --r-pill:  980px;  /* [AW] apple.com capsule                                   */
    --r-card:  12px;   /* [AW] free-standing card                                  */
    --r-block: 10px;   /* [C]  UITableView .insetGrouped container                 */
    --r-field: 8px;    /* [AW] field / .button-block                               */
    --r-seg:   8px;    /* [C]  segmented track                                     */
    --r-thumb: 7px;    /* [C]  segmented thumb — deliberately not concentric (6px) */
    --r-chip:  5px;    /* [AW] .badge-reduced                                      */
    --r-bar:   4px;    /* [WK] barCornerRadius                                     */

    /* ---- motion --------------------------------------------------------
       One travel distance, one press scale, a duration per PURPOSE (never
       per component), and Emil's three curves verbatim.  §3 neutralises the
       TOKENS under prefers-reduced-motion rather than blanket-zeroing every
       transition, which is the only way a scoped !important (§11a) can never
       leak past the setting.                                                */
    --travel:      8px;   /* every entrance that travels moves exactly this
                             far, once.  After §29e that is the toast alone:
                             the two list entrances are pure crossfades,
                             because both re-fired on a tab return.          */
    --press-scale: .97;   /* [E] subtle press band is .95-.98                 */
    --press-scale-lg: .99;/* scale() is proportional, and .97 on the 936px
                             .upload-zone is 14px of travel PER EDGE — nearly
                             twice --travel, on the largest surface in the app.
                             [E]'s .95-.98 band and [AD]'s literal scale(.97)
                             are written for buttons.  Big surfaces get this
                             one instead: ~4.7px per edge, still felt.        */

    --dur-press-down:    .10s;  /* 100ms [AD] §1's literal press-DOWN value.
                                   A token, not a magic number, so §3 can
                                   reach it like every other duration.        */
    --dur-press:         .16s;  /* 160ms press RELEASE  ([E] band 100-160)    */
    --dur-colour:        .15s;  /* 150ms colour-only state swaps             */
    --dur-exit:          .18s;  /* 180ms something leaving — exits snap       */
    --dur-progress:      .20s;  /* 200ms determinate progress, linear        */
    --dur-move:          .16s;  /* 160ms on-screen movement (the seg thumb).
                                   Was 220ms: --ease-in-out holds almost still
                                   through its first fifth, and at 220ms that
                                   dead zone is long enough to read as lag on
                                   the tap the thumb is ANSWERING (measured:
                                   4px of 282 travelled by 30ms).  The curve is
                                   [E]'s table's answer for on-screen movement
                                   and is not forked; the duration is what
                                   comes down, which is [AD] §1's own remedy.  */
    --dur-enter:         .24s;  /* 240ms something arriving                  */
    --dur-overlay:       .26s;  /* 260ms modal + scrim ([E] band 200-500)     */
    --dur-switch:        .30s;  /* [WK] knob travel + track crossfade        */
    --dur-switch-squash: .26s;  /* [WK] knob 27->35px while held             */

    /* Emil's three, verbatim — never approximated, never hand-rolled. */
    --ease-out:    cubic-bezier(0.23, 1, 0.32, 1);        /* entering/exiting */
    --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);       /* moving on screen */
    /* `ease` (the built-in) for colour and `linear` for progress are Emil's
       table's own answers for those two cases, so neither gets a token. */

    /* ---- kept from the first build, with their provenance ---- */
    --ease-fade:     cubic-bezier(.32, .08, .24, 1);      /* [AW] the #1 curve.
                        Stays on .fade-in and NOWHERE else.                   */
    --ease-switch:   cubic-bezier(.32, .72, .00, 1);      /* [D]  fitted to [WK]
                        thumbMoveSpring k=438.649 c=31.4159 m=1 -> zeta=0.75.
                        A measured fit to the real spring beats a generic
                        strong curve, so this one is not replaced.            */
    --switch-crossfade: linear(0, .043, .1562, .3164, .5, .6836, .8438, .957, 1);
                       /* [D] exact sampling of [WK] easeInOut(p) = -2p^3 + 3p^2   */

    /* RETIRED: --stagger 40ms.  Nothing staggers any more: the one staggered
       list (the staged file rows) lost its ramp when the ramp turned out to
       reset rather than clamp past its cap, and re-fire on every tab return.
       RETIRED: --dur-toast .36s.  It was justified by [E]'s Sonner note, but
       Sonner pairs its slower `ease` with a translate of the toast's OWN
       HEIGHT — that travel is what the extra time buys.  Over 8px the same
       360ms is a slow pure fade, which is the thing the review skill flags,
       and it was symmetric, against this sheet's own "exits snap".  The toast
       now enters on --dur-enter and leaves on --dur-exit (§20).
       RETIRED: --ease-seg: cubic-bezier(.40,0,.20,1) was the Material standard
       curve with no Apple provenance — the weak-easing family the review skill
       flags on sight.  The thumb moves on --ease-in-out now.
       RETIRED: --dur-seg .26s, split into --dur-move (thumb) and
       --dur-switch-squash (knob) so retuning one cannot move the other.
       RETIRED: --dur-fast / --dur-fade, folded into the purpose table above. */

    /* ---- layers ------------------------------------------------------- */
    --z-sticky: 10; --z-overlay: 30; --z-toast: 40;

    --header-h: 60px;  /* [D] 44px segmented track + 2 x 8px block padding.
                          Redefined on phone in §25 — the toast offset reads it.  */
}

@media (min-resolution: 2dppx) {
    :root { --hairline-w: .5px; }   /* a UIKit hairline is one device pixel */
}


/* ==========================================================================
   2. TOKENS — DARK APPEARANCE
   Designed, not inverted: in light the page is grey and the card is white; in
   dark the page is black and the card is LIGHTER than the page.  Two selectors
   with an identical body, so a [data-theme] toggle would cost no CSS at all.
   ========================================================================== */

@media (prefers-color-scheme: dark) {
    :root:not([data-theme="light"]) {
        color-scheme: dark;

        --accent:             #008387;  /* [D] identical to light — Apple keeps
                                           --sk-fill-blue the same in its dark
                                           theme, and §0.1 forbids a second
                                           accent fill.  white 4.57:1             */
        --accent-hover:       #007F82;
        --accent-active:      #007F82;
        --link:               #00C6CB;  /* [D] hue 181.5°, L 0.447.  TEXT AND
                                           GLYPHS ONLY, never a fill.  8.05:1 on
                                           #1C1C1E · 9.94:1 on #000 · 6.60:1 on
                                           #2C2C2E                                */
        --focus:              #00C6CB;  /* [A11Y] no longer theme-independent —
                                           see §1.10.  The old #0071E3 measured
                                           2.97:1 on #2C2C2E and FAILED 1.4.11
                                           there; this is 6.60:1, 9.94 on #000,
                                           8.05 on #1C1C1E                        */

        --bg-grouped:         #000000;  /* [HIG] systemGroupedBackground dark      */
        --surface:            #1C1C1E;  /* [HIG] secondarySystemGrouped dark       */
        --surface-elevated:   #2C2C2E;  /* [C]   toast + overlay ONLY              */
        --live-edge:          rgba(0, 198, 203, .46);
        --well:               #1D1D1F;  /* [AW] --sk-fill-tertiary dark            */
        --scrim:              rgba(0, 0, 0, .60);

        --text:               #F5F5F7;  /* [AW] 15.63:1 on --surface               */
        --text-secondary:     #86868B;  /* [AW] 4.70:1 on #1C1C1E, 3.85:1 on
                                           #2C2C2E — never on --surface-elevated   */
        --text-secondary-alt: #D2D2D7;  /* [AW] 11.29:1 on #1C1C1E — the elevated-
                                           surface secondary when one is needed    */
        --text-tertiary:      #6E6E73;  /* [AW] NON-TEXT USE ONLY (3.35:1)         */
        --text-secondary-fill:#A1A1A6;  /* [A11Y] #86868B is 4.70:1 on --surface
                                           but only 3.85:1 on --field-fill and
                                           3.84:1 on a hovered row; this is 5.42   */
        --text-on-accent:     #FFFFFF;

        --separator:          rgba(84, 84, 88, .60);      /* [HIG] separator dark  */
        --fill-3:             rgba(118, 118, 128, .24);
        --fill-4:             rgba(118, 118, 128, .18);

        --field-fill:         #2C2C2E;  /* opaque; luminance 0.0253                */
        --control-border:     #86868B;  /* [A11Y] 3.85:1 on the field fill         */

        --red:     #FF3037;  --red-bg:     #330000;  /* [AW] 5.02:1 */
        --green:   #30D158;  --green-bg:   #002B03;  /* [HIG] systemGreen dark, 7.71:1 */
        --orange:  #FF791B;  --orange-bg:  #290D00;  /* [AW] 6.95:1 */
        --info:    #00C6CB;  --info-bg:    #002124;  /* [D]  8.00:1 */
        --neutral: #D2D2D7;  --neutral-bg: #333336;  /* [AW] 8.36:1 */
        --dot-pending: #86868B;  --dot-processing: #00C6CB;
        --dot-completed: #30D158; --dot-failed: #FF3037;

        --progress-track: rgba(255, 255, 255, .06);  /* [A11Y] 3.15:1 vs the fill —
                                        Apple's own dark track measures 1.47:1     */
        --progress-fill:  #008387;                   /* the SAME accent fill       */

        --switch-off:    rgba(120, 120, 128, .32);   /* [WK] offDarkColor 82/255   */
        --switch-off-hc: rgba(120, 120, 128, .90);   /* [WK] 230/255               */
        --switch-on:     #008387;
        --switch-knob:   #FFFFFF;

        --glass-bg:       rgba(22, 22, 23, .80);     /* [AW] #161617 @ 80%         */
        --glass-bg-solid: rgba(22, 22, 23, .88);
        --glass-bg-opaque: #161617;
        --glass-hairline: rgba(255, 255, 255, .24);
        --disabled-opacity: .36;

        --glyph-search:         url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%2386868B' stroke-width='1.75' stroke-linecap='round'%3E%3Ccircle cx='7' cy='7' r='4.5'/%3E%3Cpath d='m10.4 10.4 3.1 3.1'/%3E%3C/svg%3E");
        --glyph-chevron-updown: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%2386868B' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m4.5 6.5 3.5-3.5 3.5 3.5'/%3E%3Cpath d='m4.5 9.5 3.5 3.5 3.5-3.5'/%3E%3C/svg%3E");
    }
}

/* Same body again, keyed on the attribute, so a future toggle is CSS-only.
   The HIG calls an app-level appearance switch on its own an anti-pattern, so
   the app ships without one; this block is the hook, not a feature. */
:root[data-theme="dark"] {
    color-scheme: dark;
    --accent: #008387; --accent-hover: #007F82; --accent-active: #007F82;
    --link: #00C6CB; --focus: #00C6CB;
    --bg-grouped: #000000; --surface: #1C1C1E; --surface-elevated: #2C2C2E;
    --live-edge: rgba(0, 198, 203, .46);
    --well: #1D1D1F; --scrim: rgba(0, 0, 0, .60);
    --text: #F5F5F7; --text-secondary: #86868B; --text-secondary-alt: #D2D2D7;
    --text-tertiary: #6E6E73; --text-secondary-fill: #A1A1A6; --text-on-accent: #FFFFFF;
    --separator: rgba(84, 84, 88, .60);
    --fill-3: rgba(118, 118, 128, .24); --fill-4: rgba(118, 118, 128, .18);
    --field-fill: #2C2C2E; --control-border: #86868B;
    --red: #FF3037; --red-bg: #330000; --green: #30D158; --green-bg: #002B03;
    --orange: #FF791B; --orange-bg: #290D00; --info: #00C6CB; --info-bg: #002124;
    --neutral: #D2D2D7; --neutral-bg: #333336;
    --dot-pending: #86868B; --dot-processing: #00C6CB;
    --dot-completed: #30D158; --dot-failed: #FF3037;
    --progress-track: rgba(255, 255, 255, .06); --progress-fill: #008387;
    --switch-off: rgba(120, 120, 128, .32); --switch-off-hc: rgba(120, 120, 128, .90);
    --switch-on: #008387; --switch-knob: #FFFFFF;
    --glass-bg: rgba(22, 22, 23, .80); --glass-bg-solid: rgba(22, 22, 23, .88);
    --glass-bg-opaque: #161617;
    --glass-hairline: rgba(255, 255, 255, .24);
    --disabled-opacity: .36;
    --glyph-search:         url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%2386868B' stroke-width='1.75' stroke-linecap='round'%3E%3Ccircle cx='7' cy='7' r='4.5'/%3E%3Cpath d='m10.4 10.4 3.1 3.1'/%3E%3C/svg%3E");
    --glyph-chevron-updown: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%2386868B' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m4.5 6.5 3.5-3.5 3.5 3.5'/%3E%3Cpath d='m4.5 9.5 3.5 3.5 3.5-3.5'/%3E%3C/svg%3E");
}

/* Explicit light override, so [data-theme="light"] wins over the media query. */
:root[data-theme="light"] { color-scheme: light; }


/* ==========================================================================
   3. ACCESSIBILITY ESCAPE HATCHES
   ========================================================================== */

@media (prefers-contrast: more) {
    :root {
        --text-secondary: #424245;
        --text-secondary-fill: #424245;
        --text-tertiary: #6E6E73;      /* glyphs only, but they escalate too */
        --separator: rgba(60, 60, 67, .60);
        --control-border: #6E6E73;
        --switch-off: var(--switch-off-hc);
        --fill-3: rgba(118, 118, 128, .24);
    }
}
/* [data-theme="dark"] is listed explicitly: the dark palette is declared both
   under the media query and under the attribute, so the hatch has to reach
   both or the attribute path would ship the dark palette without it. */
@media (prefers-contrast: more) and (prefers-color-scheme: dark) {
    :root:not([data-theme="light"]) {
        --text-secondary: #D2D2D7;
        --text-secondary-fill: #D2D2D7;
        --text-tertiary: #AEAEB2;
        --separator: rgba(84, 84, 88, .90);
        --control-border: #AEAEB2;
        --switch-off: var(--switch-off-hc);
    }
}
@media (prefers-contrast: more) {
    :root[data-theme="dark"] {
        --text-secondary: #D2D2D7;
        --text-secondary-fill: #D2D2D7;
        --text-tertiary: #AEAEB2;
        --separator: rgba(84, 84, 88, .90);
        --control-border: #AEAEB2;
        --switch-off: var(--switch-off-hc);
    }
}

/* prefers-reduced-transparency lives in §25, immediately AFTER the @supports
   block that turns the blur on: both selectors are (0,0,1,0), so source order
   is the only thing that can win, and declaring the hatch here would make it
   dead code. */

/* Reduced motion means GENTLER, not zero: Apple's own guidance is to keep the
   opacity and colour changes that aid comprehension and remove the travel.
   So the TOKENS are neutralised — --travel to 0, --press-scale to 1, the
   durations shortened — and every transition in the sheet, all of which are
   token-driven, follows.  The blanket `transition-duration: .01ms !important`
   that used to live here was replaced for a second reason: §11a has to use
   !important to reach Alpine's inline transition styles, and a blanket rule
   at specificity (0,0,0,0) loses to it. */
@media (prefers-reduced-motion: reduce) {
    :root {
        --travel: 0px;        /* every entrance collapses to a pure crossfade */
        --press-scale: 1;     /* the scale goes; the instant colour swap stays */
        --press-scale-lg: 1;  /* ...including the large-surface one                */
        --dur-progress: 0s;   /* the bar jumps to its value — a creeping bar is
                                 exactly the vestibular motion this setting is
                                 for, and the numeral above still carries it  */
        --dur-press-down: .10s;
        --dur-press: .12s;  --dur-colour: .12s;  --dur-exit: .12s;
        --dur-move:  .12s;  --dur-enter:  .18s;  --dur-overlay: .18s;
        --dur-switch: .12s; --dur-switch-squash: .12s;
    }

    *,
    *::before,
    *::after {
        animation-duration: .01ms !important;
        animation-iteration-count: 1 !important;
        scroll-behavior: auto !important;
    }

    /* .fade-in survives, and needs !important to say so: the blanket
       animation-duration above is itself !important, so an ordinary rule
       could not exempt it and the claim would be false.  It is opacity-only,
       and an opacity crossfade is precisely what this setting asks you to
       keep — Apple's own guidance is gentler, not zero. */
    .fade-in { animation-duration: var(--dur-enter) !important; }

    .progress-bar-fill { animation: none !important; }

    /* The uploading box loses the scale and keeps the fade.
       animation-name needs !important: §23's `animation:` SHORTHAND sits
       later in the file at the same specificity and resets the name back to
       box-in.  It is safe here — §29a's !important lives inside
       `no-preference`, so the two can never meet. */
    .uploading-box {
        animation-name: box-in-reduced !important;
        animation-duration: var(--dur-overlay) !important;
    }

    /* The spinner does NOT freeze: a frozen eight-blade wheel is a worse
       loading signal than the animation it replaces.  Apple's own renderer
       stops the motion and pulses opacity between .3 and .6 instead
       (RenderThemeCocoa reducedMotionProgressAnimation{Min,Max}Opacity). */
    .spinner {
        animation: sp-pulse 2s steps(1, end) infinite !important;
        rotate: none;
    }

    /* The switch knob's travel IS the control's meaning, not decoration:
       shorten it, never remove it. */
    .checkbox-label input[type="checkbox"]::after { transition-duration: var(--dur-move); }

    /* .settings-saved is the one element whose motion Alpine writes inline
       from template modifiers, so the tokens cannot reach it.  The fade
       stays (it aids comprehension); the scale goes. */
    .settings-saved {
        transform: none !important;
        transition-duration: var(--dur-exit) !important;
    }
}
@keyframes sp-pulse { 0%, 100% { opacity: .3 } 50% { opacity: .6 } }
@keyframes box-in-reduced { from { opacity: 0; } }


/* ==========================================================================
   4. RESET & BASE
   ========================================================================== */

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

html {
    font-size: 16px;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
    /* Non-negotiable: only the wght axis of the fallback is loaded, and a faux
       bold SF reads as wrong before anyone can name why.  Apple sets this too. */
    font-synthesis: none;

    /* ---- the mobile-native baseline (§29 has the rest) ----
       · the translucent grey flash the browser paints over every tapped
         element is the single loudest "this is a website" signal, and it
         fights the :active feedback in §29.  It may only be removed because
         §29 gives every tappable surface its own press state;
       · without text-size-adjust iOS inflates text in landscape;
       · overscroll-behavior-y stops Android's pull-to-refresh and the iOS
         whole-page rubber band from hijacking an app's own scrolling.  The
         -y axis only: nothing here scrolls horizontally. */
    -webkit-tap-highlight-color: transparent;
    -webkit-text-size-adjust: 100%;
    overscroll-behavior-y: none;
}

body {
    font-family: var(--font-text);
    font-size: var(--fs-body);
    line-height: var(--lh-body);
    letter-spacing: var(--tr-body);
    color: var(--text);
    background: var(--bg-grouped);
    min-height: 100dvh;
    overscroll-behavior-y: none;
}

img, svg { display: block; max-width: 100%; }

/* Controls inherit the type stack; they never inherit the UA's. */
button, input, select, textarea {
    font-family: inherit;
    color: inherit;
}

/* A control is a control, not text.  touch-action: manipulation drops iOS's
   double-tap-zoom wait so `click` fires on the frame the finger lifts;
   user-select stops a long press selecting a button's label; touch-callout
   stops iOS popping the share/save sheet over the <svg> inside .upload-zone.
   CONTENT is deliberately absent from this list: .logs-textarea,
   .job-filename, .job-error and the API-key hint URLs stay selectable,
   because people copy those.  `user-select: none` on body would be a defect.
   [role="button"] currently matches nothing — the template's roles are tab,
   tabpanel, status and progressbar — and is kept as a forward guard so a
   div-turned-button lands here by default rather than by remembering to. */
button,
a,
[role="button"],
select,
.checkbox-label,
.radio-label {
    touch-action: manipulation;
    -webkit-user-select: none;
            user-select: none;
    -webkit-touch-callout: none;
}

/* The toast IS tappable (app.js dismisses it on click) but it carries error
   text the user may need to copy, so it gets the tap fix and nothing else. */
.toast { touch-action: manipulation; }

::selection { background: color-mix(in srgb, var(--accent) 24%, transparent); }

/* WCAG 1.4.3 applies to placeholder text, so it cannot use --text-tertiary
   (3.62:1 light / 3.35:1 dark — declared NON-TEXT USE ONLY in §1).  Nor can
   it use the plain secondary in dark: #86868B measures 4.70:1 on --surface
   but the field is --field-fill #2C2C2E, where it is only 3.85:1.  Measured
   on the real field: 5.07:1 light, 5.42:1 dark. */
::placeholder { color: var(--text-secondary-fill); opacity: 1; }


/* ==========================================================================
   5. TYPOGRAPHY
   Apple's own web scale.  letter-spacing INHERITS, so every size sets its own
   tracking token — 13px text left at --tr-body would render ~3.7x too tight.
   Tracking goes negative as size drops and positive as it grows, which is what
   makes the scale read as SF rather than as "a sans-serif at several sizes".
   ========================================================================== */

/* No .t-* utility layer: every size is set on the element that needs it. */

h1 { font-family: var(--font-display); font-size: var(--fs-title1); line-height: var(--lh-title1); letter-spacing: var(--tr-title1); font-weight: var(--fw-semibold); }
h2 { font-family: var(--font-display); font-size: var(--fs-title3); line-height: var(--lh-title3); letter-spacing: var(--tr-title3); font-weight: var(--fw-semibold); }
h3 { font-size: var(--fs-body); line-height: var(--lh-body-tight); letter-spacing: var(--tr-body); font-weight: var(--fw-semibold); }

strong, b { font-weight: var(--fw-semibold); }

code, kbd, samp, pre { font-family: var(--font-mono); }


/* ==========================================================================
   6. FOCUS
   Apple's published spec verbatim: a real outline, never a box-shadow ring,
   and `outline: none` never appears without a :focus-visible rule beside it.
   ========================================================================== */

*:focus { outline: none; }

*:focus-visible {
    outline: 2px solid var(--focus);
    outline-offset: var(--focus-offset);
}

.btn:focus-visible,
.tab-btn:focus-visible,
.provider-btn:focus-visible,
.job-card:focus-visible,
.upload-zone:focus-visible {
    outline-offset: var(--focus-offset-box);
}

/* Inside a clipped or scrolling container the 3px offset is cut off by
   overflow, so the ring stays at 1px there and remains whole. */
.models-list .btn:focus-visible,
.inset-list .btn:focus-visible,
.file-list .btn:focus-visible,
.model-row:focus-visible { outline-offset: var(--focus-offset); }

.btn:active { outline: none; }

/* WCAG 2.4.11 Focus Not Obscured.  The header is sticky, so scroll-into-view
   (Shift+Tab especially) parks the focused control flush with the viewport
   top — underneath the bar.  scroll-padding on the scrolling element keeps
   the whole ring clear of it; --header-h is already 60px / 112px per width.
   :where() so this never raises the specificity of anything it touches. */
html {
    scroll-padding-block-start: calc(var(--header-h) + var(--safe-t) + var(--sp-4));
    scroll-padding-block-end: var(--sp-4);
}


/* ==========================================================================
   7. ICONS
   Inline <svg><use href="#icon-…"></svg> against the sprite at the top of
   templates/index.html.  Always currentColor; size is set by the context so
   the stroke's optical weight tracks the adjacent text.
   ========================================================================== */

.icon-sprite { display: none; }

.icon {
    display: inline-block;
    width: 1em;
    height: 1em;
    vertical-align: -.125em;
    flex: none;
    fill: none;
    stroke: currentColor;
    stroke-linecap: round;
    stroke-linejoin: round;
    pointer-events: none;
}

.icon-20 { width: 20px; height: 20px; }
.icon-28 { width: 28px; height: 28px; }


/* ==========================================================================
   8. LAYOUT SHELL
   ========================================================================== */

.container {
    width: 100%;
    max-width: var(--content-max);
    margin-inline: auto;
    padding-inline: max(var(--gutter), var(--safe-l)) max(var(--gutter), var(--safe-r));
}

@media (max-width: 833px) {
    .container {
        padding-inline: max(var(--gutter-sm), var(--safe-l)) max(var(--gutter-sm), var(--safe-r));
    }
}


/* ==========================================================================
   9. BUTTONS
   Apple's .button is a content-box pill: padding is added to the line box, so
   the height is round(font x line-height) + 2*padV + 2*border.  There is NO
   transition on .btn — Apple's own rule has none and hover is an instant
   colour swap; an eased button is the loudest non-Apple signal there is.

     sizes     .btn-sm (26px) · .btn (38px) · .btn-lg (46px) · .btn-block
     variants  .btn-primary / .btn-success  filled turquoise
               .btn-secondary               tinted (accent label + hairline)
               .btn-plain                   monochrome (ink label, hairline)
               .btn-ghost / .link-btn       plain
               .btn-danger / .btn-ghost-danger  destructive — red label, never
                                                a fill and never an accent tint
   ========================================================================== */

.btn {
    position: relative;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: var(--sp-3);
    box-sizing: content-box;
    min-width: 60px;
    padding: 9px 16px;
    border: 1px solid transparent;
    border-radius: var(--r-pill);
    background: none;
    font-family: var(--font-text);
    font-size: var(--fs-caption);
    line-height: var(--lh-caption-tight);
    letter-spacing: var(--tr-caption);
    font-weight: var(--fw-semibold);
    text-align: center;
    text-decoration: none;
    white-space: nowrap;
    cursor: pointer;
}

/* 44x44 is a HIT REGION, not a visual size (§2.0).  The pad must never leave
   its own row, so every row holding a sub-44px button reserves 44px of block
   space for it — see .job-actions, .model-row, .api-key-input, .app-header. */
.btn::after {
    content: "";
    position: absolute;
    inset: 50% 0 auto;
    translate: 0 -50%;
    height: var(--hit);
}

.btn:disabled {
    opacity: var(--disabled-opacity);
    pointer-events: none;
}

/* ---- sizes ---- */
.btn-sm {
    min-width: 45px;
    padding: 4px 11px;
    font-size: var(--fs-caption2);
    line-height: var(--lh-caption2);
    letter-spacing: var(--tr-caption2);
}

.btn-lg {
    min-width: 70px;
    padding: 12px 22px;
    font-size: var(--fs-body);
    line-height: 1.17648;
    letter-spacing: var(--tr-body);
}

/* Apple's own .button-block switches to border-box.  Load-bearing: content-box
   + width:100% + 16px padding + 1px borders would be parent + 34px and would
   overflow the 390px login and settings columns. */
.btn-block {
    display: flex;
    box-sizing: border-box;
    width: 100%;
    min-width: 0;
    border-radius: var(--r-field);
}

/* ---- variants ---- */
.btn-primary,
.btn-success {
    background: var(--accent);
    color: var(--text-on-accent);
    border-color: transparent;
}
@media (hover: hover) and (pointer: fine) {
    .btn-primary:hover:not(:disabled),
    .btn-success:hover:not(:disabled) { background: var(--accent-hover); }
}
.btn-primary:active:not(:disabled),
.btn-success:active:not(:disabled) { background: var(--accent-active); }

.btn-secondary {
    background: transparent;
    color: var(--link);
    border-color: var(--link);
}
@media (hover: hover) and (pointer: fine) {
    .btn-secondary:hover:not(:disabled) {
        background: var(--accent-hover);
        color: var(--text-on-accent);
        border-color: transparent;
    }
}
.btn-secondary:active:not(:disabled) {
    background: var(--accent-active);
    color: var(--text-on-accent);
    border-color: transparent;
}

/* Monochrome secondary.  A toolbar gets exactly ONE tinted control; every
   other action in it is ink on a hairline, which is what stops a settings
   screen turning into a row of accent buttons. */
.btn-plain {
    background: transparent;
    color: var(--text);
    /* --control-border, not --separator: this is the boundary of an
       interactive control, so WCAG 1.4.11 wants 3:1, and --separator
       composites to 1.6:1 on white.  Same reasoning as §15's fields. */
    border-color: var(--control-border);
}
@media (hover: hover) and (pointer: fine) {
    .btn-plain:hover:not(:disabled) { background: var(--fill-4); }
}
.btn-plain:active:not(:disabled) { background: var(--fill-3); }

.btn-ghost {
    background: transparent;
    color: var(--link);
    border-color: transparent;
}
@media (hover: hover) and (pointer: fine) {
    .btn-ghost:hover:not(:disabled) { background: var(--fill-4); }
}
.btn-ghost:active:not(:disabled) { background: var(--fill-3); }

/* Destructive follows the HIG literally: red TEXT on an otherwise plain
   button, never a red fill, never the default, always paired with a Cancel
   (deleteJob() already routes through confirm()).  #E30000 on --surface is
   4.92:1 — keep destructive buttons on a card, not on --bg-grouped (4.41:1). */
.btn-danger,
.btn-ghost-danger {
    background: transparent;
    color: var(--red);
    border-color: transparent;
}
@media (hover: hover) and (pointer: fine) {
    .btn-danger:hover:not(:disabled),
    .btn-ghost-danger:hover:not(:disabled) { background: var(--red-bg); }
}
.btn-danger:active:not(:disabled),
.btn-ghost-danger:active:not(:disabled) { background: var(--red-bg); }

/* There is no third treatment for an action that removes something.  Anuluj
   ends a conversion that is already running and Usuń deletes a job, so both
   are destructive and both are .btn-ghost-danger.  The orange variant that
   used to live here was a fourth accent with no rule behind it. */

/* Plain inline link inside running text.  No hit pad: a 44px band over a
   paragraph would swallow pointer events on the sentence around it. */
.link-btn {
    display: inline;
    padding: 0;
    border: 0;
    background: none;
    color: var(--link);
    font: inherit;
    letter-spacing: inherit;
    text-decoration: underline;
    text-underline-offset: .15em;
    cursor: pointer;
}
@media (hover: hover) and (pointer: fine) {
    .link-btn:hover { text-decoration-thickness: 2px; }
}
.link-btn:disabled { opacity: var(--disabled-opacity); pointer-events: none; }


/* ==========================================================================
   10. ICON BUTTON — .file-remove-btn
   28x28 visual, 44x44 hit region.  aria-label is mandatory: the glyph is
   never the accessible name.
   ========================================================================== */

.file-remove-btn {
    position: relative;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 28px;
    height: 28px;
    padding: 0;
    border: 0;
    border-radius: var(--r-pill);
    background: transparent;
    color: var(--text-tertiary);
    cursor: pointer;
}

.file-remove-btn::after {
    content: "";
    position: absolute;
    inset: 50% 50% auto auto;
    translate: 50% -50%;
    width: var(--hit);
    height: var(--hit);
}

@media (hover: hover) and (pointer: fine) {
    .file-remove-btn:hover {
        background: var(--fill-4);
        color: var(--text-secondary);
    }
}

.file-remove-btn:disabled { opacity: var(--disabled-opacity); pointer-events: none; }

.file-remove-btn .icon { width: 16px; height: 16px; }


/* ==========================================================================
   11. SEGMENTED CONTROL — .tab-bar / .tab-btn, .provider-switch / .provider-btn
   The HIG sanctions a segmented control for switching views from a TOOLBAR,
   which is exactly where the tab bar sits.  ARIA stays tablist/tab: the
   semantics of "switch the panel below" are right whatever the visual idiom.

   The thumb slides with zero JavaScript: Alpine sets .active / .provider-btn-
   active and :has() positions the thumb.  Set --seg-count on the track.
   The optional <span class="seg-thumb"> is the sliding thumb; without it the
   selected segment paints its own thumb (identical, just no slide).
   ========================================================================== */

.tab-bar,
.provider-switch {
    position: relative;
    display: inline-flex;
    align-items: stretch;
    isolation: isolate;
    width: 100%;
    min-height: var(--hit);          /* contains the segments' 44px hit pads */
    padding: 2px;
    border-radius: var(--r-seg);
    background: var(--fill-3);
}
.tab-bar { --seg-count: 4; }
.provider-switch { --seg-count: 2; max-width: 360px; }

.seg-thumb {
    position: absolute;
    inset: 2px auto 2px 2px;
    z-index: 0;
    width: calc((100% - 4px) / var(--seg-count));
    border-radius: var(--r-thumb);
    background: var(--surface);
    box-shadow: 0 0 5px rgba(0, 0, 0, .16);   /* [C] the one control shadow */
    will-change: transform, scale;
    /* `scale` is its own property, applied before `transform`, so the press
       squash (§29b) composes with the four translateX() rules below without
       any of them having to know about it — and without driving a child's
       transform from a variable on the parent, which [E] forbids. */
    transition: transform var(--dur-move)       var(--ease-in-out),
                scale     var(--dur-press)      var(--ease-out);
}

.tab-btn,
.provider-btn {
    position: relative;
    z-index: 1;
    flex: 1 1 0;                      /* segments are equal width, per the HIG */
    min-width: 0;
    min-height: 28px;
    padding: 0 12px;
    border: 0;
    border-radius: var(--r-thumb);
    background: none;
    font-family: var(--font-text);
    font-size: var(--fs-caption);
    line-height: 28px;
    letter-spacing: var(--tr-caption);
    font-weight: var(--fw-regular);
    color: var(--text-secondary-alt);   /* 8.35:1 light / 9.11:1 dark on the track */
    cursor: pointer;
}

/* The ellipsis lives on an INNER span, never on the segment itself: an
   element's own `overflow: hidden` clips its ::after — including for hit
   testing — and the ::after below is the 44px hit pad.  Clipping it here is
   what silently shrank the app's primary navigation to a 40px target. */
.seg-label {
    display: block;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.tab-btn::after,
.provider-btn::after {
    content: "";
    position: absolute;
    inset: 50% 0 auto;
    translate: 0 -50%;
    height: var(--hit);
}

/* Hover is a background fill, never label opacity: dimming the label drops it
   to 3.12:1 on the composited track, and WCAG applies to every state. */
@media (hover: hover) and (pointer: fine) {
    .tab-btn:hover:not(.active),
    .provider-btn:hover:not(.provider-btn-active) { background: var(--fill-4); }
}

.tab-btn.active,
.provider-btn-active {
    font-weight: var(--fw-semibold);
    color: var(--text);                 /* 16.83:1 light / 15.63:1 dark */
    background: var(--surface);
    box-shadow: 0 0 5px rgba(0, 0, 0, .16);
}

/* When a real sliding thumb is present it paints the surface instead. */
.tab-bar:has(> .seg-thumb) .tab-btn.active,
.provider-switch:has(> .seg-thumb) .provider-btn-active {
    background: none;
    box-shadow: none;
}

.tab-btn:disabled,
.provider-btn:disabled { opacity: .3; pointer-events: none; }

/* A hairline appears only between two adjacent UNSELECTED segments. */
.tab-btn + .tab-btn::before,
.provider-btn + .provider-btn::before {
    content: "";
    position: absolute;
    left: 0;
    top: 20%;
    bottom: 20%;
    width: 1px;
    background: var(--separator);
    transition: opacity var(--dur-move) var(--ease-in-out);
}
.tab-btn.active::before,
.tab-btn.active + .tab-btn::before,
.provider-btn-active::before,
.provider-btn-active + .provider-btn::before { opacity: 0; }

/* Thumb travel.  Keyed on the tab ids, because focusAdjacentTab() resolves
   tabs through getElementById rather than DOM sibling order. */
.tab-bar:has(#tab-queue.active)    .seg-thumb { transform: translateX(0%); }
.tab-bar:has(#tab-upload.active)   .seg-thumb { transform: translateX(100%); }
.tab-bar:has(#tab-settings.active) .seg-thumb { transform: translateX(200%); }
.tab-bar:has(#tab-logs.active)     .seg-thumb { transform: translateX(300%); }

/* Default first, :has() override second.  :first-of-type, NOT :first-child —
   once <span class="seg-thumb"> is the first child, :first-child can never
   match a .provider-btn and the thumb would be stuck on the right forever. */
.provider-switch .seg-thumb { transform: translateX(100%); }
.provider-switch:has(.provider-btn:first-of-type.provider-btn-active) .seg-thumb {
    transform: translateX(0%);
}

@supports not selector(:has(*)) {
    .seg-thumb { display: none; }
}


/* ==========================================================================
   12. SWITCH — the real <input type="checkbox"> inside .checkbox-label
   No wrapper span, no hidden input, no JS: x-model is untouched and
   Playwright's .check() / .uncheck() keep working, because the input is a
   real, visible, actionable 51x31 control.  Three WebKit details almost
   nobody implements: the knob widens by 8px while held, the track crossfades
   along smoothstep (-2t^3+3t^2), and the knob travels on a zeta=0.75 spring.
   ========================================================================== */

.checkbox-label {
    position: relative;
    display: flex;
    align-items: center;
    gap: var(--sp-3);
    min-height: var(--hit);
    padding-inline: var(--row-inset);
    font-size: var(--fs-body);
    line-height: var(--lh-body-tight);
    letter-spacing: var(--tr-body);
    font-weight: var(--fw-regular);
    color: var(--text);
    cursor: pointer;
}

/* The separator starts at the leading edge of the TEXT and runs flush to the
   container's trailing edge.  Drawn as a pseudo-element because a grouped row
   has no inner wrapper to hang a border on. */
.checkbox-label + .checkbox-label::before,
.radio-label + .radio-label::before {
    content: "";
    position: absolute;
    inset-inline: var(--row-inset) 0;
    top: 0;
    height: var(--hairline-w);
    background: var(--separator);
}

.checkbox-label input[type="checkbox"] {
    appearance: none;
    -webkit-appearance: none;
    position: relative;
    flex: none;
    /* The row is label-left / switch-right whatever order the markup uses:
       the label text is an anonymous flex item and cannot be ordered, so the
       switch is the one that moves. */
    order: 2;
    width: 51px;                       /* [WK] logicalSwitchWidth  */
    height: 31px;                      /* [WK] logicalSwitchHeight */
    margin: 0 0 0 auto;
    border: 0;
    border-radius: 15.5px;
    background: var(--switch-off);
    cursor: pointer;
    transition: background-color var(--dur-switch) var(--switch-crossfade);
}

.checkbox-label input[type="checkbox"]:checked { background: var(--switch-on); }

.checkbox-label input[type="checkbox"]::after {
    content: "";
    position: absolute;
    top: 2px;                          /* [WK] thumbMargin = 2 */
    left: 2px;
    width: 27px;
    height: 27px;
    border-radius: 13.5px;
    background: var(--switch-knob);
    box-shadow: var(--switch-shadow);
    transition: transform var(--dur-switch)        var(--ease-switch),
                width     var(--dur-switch-squash) var(--ease-switch);
}
.checkbox-label input[type="checkbox"]:checked::after        { transform: translateX(20px); }
.checkbox-label input[type="checkbox"]:active::after         { width: 35px; }
.checkbox-label input[type="checkbox"]:checked:active::after { transform: translateX(12px); }

.checkbox-label input[type="checkbox"]:disabled { opacity: .5; cursor: not-allowed; }
.checkbox-label:has(input:disabled) { cursor: not-allowed; color: var(--text-secondary); }


/* ==========================================================================
   13. RADIO ROW — .radio-group / .radio-label
   Apple's list-selection idiom: the row is the control and the selected row
   is marked with a checkmark in the accent, not with a bullet in a circle.
   ========================================================================== */

.radio-label {
    position: relative;
    display: flex;
    align-items: center;
    gap: var(--sp-6);
    min-height: var(--hit);
    padding-inline: var(--row-inset);
    font-size: var(--fs-body);
    line-height: var(--lh-body-tight);
    letter-spacing: var(--tr-body);
    font-weight: var(--fw-regular);
    color: var(--text);
    cursor: pointer;
}

.radio-label input[type="radio"] {
    appearance: none;
    -webkit-appearance: none;
    flex: none;
    order: 2;                          /* the checkmark sits at the row's end */
    margin: 0 0 0 auto;
    width: 20px;
    height: 20px;
    border: 0;
    border-radius: 0;
    background: transparent;
    cursor: pointer;
}

.radio-label input[type="radio"]:checked {
    background-color: var(--accent);
    -webkit-mask: var(--mask-checkmark) center / 17px 17px no-repeat;
            mask: var(--mask-checkmark) center / 17px 17px no-repeat;
}

.radio-label input[type="radio"]:disabled { opacity: var(--disabled-opacity); cursor: not-allowed; }
.radio-label.is-unavailable { color: var(--text-secondary); }


/* ==========================================================================
   14. GROUPED INSET LIST — .settings-card > h2 + .inset-list + .field-hint
   The single most Apple thing here, and the most commonly botched.  Three
   rules are not negotiable:
     · the separator sits on .row-inner, so it starts at the leading edge of
       the TEXT and runs flush to the container's trailing edge.  A full-bleed
       separator is the number-one grouped-list mistake;
     · the last row has no separator;
     · round the CONTAINER and clip the rows — never round a row.  A row is
       flush with its container, so the concentric-radius rule does not apply.
   The group header and the caption live OUTSIDE the box, both 13px regular
   secondary.  Headers are 400, not 600: iOS Settings group headers are
   regular-weight secondaryLabel, and semibold at footnote size beside 17px
   regular row labels inverts the hierarchy.
   ========================================================================== */

.inset-list {
    background: var(--surface);
    border-radius: var(--r-block);
    overflow: hidden;
}

.group-header {
    display: block;
    padding: 0 var(--row-inset) var(--sp-3);
    font-size: var(--fs-footnote);
    line-height: var(--lh-footnote);
    letter-spacing: var(--tr-footnote);
    font-weight: var(--fw-regular);
    color: var(--text-secondary);
}

.field-group {
    min-height: var(--hit);
    padding-inline-start: var(--row-inset);
}

.row-inner {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--sp-7);
    min-height: var(--hit);
    padding-inline-end: var(--row-inset);
    padding-block: 10px;
    border-bottom: var(--hairline-w) solid var(--separator);
}
.field-group:last-child > .row-inner { border-bottom: 0; }

.row-label {
    font-size: var(--fs-body);
    line-height: var(--lh-body-tight);
    letter-spacing: var(--tr-body);
    font-weight: var(--fw-regular);
    color: var(--text);
}
/* One caption per group, at the bottom, in footnote grey — not a hint under
   every field.  A caption is only removed when it carried no information. */
.field-hint {
    display: block;
    padding: var(--sp-3) var(--row-inset) 0;
    font-size: var(--fs-footnote);
    line-height: var(--lh-footnote);
    letter-spacing: var(--tr-footnote);
    font-weight: var(--fw-regular);
    color: var(--text-secondary);
}


/* ==========================================================================
   15. TEXT / SEARCH / NUMBER FIELDS
   Every form control is OPAQUE (--field-fill) plus a --control-border
   hairline.  Two reasons, one policy: a borderless control measures 1.15:1
   against the page and fails WCAG 1.4.11, and the suite's native-control test
   flattens alpha when it reads backgroundColor — a translucent --fill-3 is
   measured as if it were opaque rgb(118,118,128), luminance 0.184.
   --control-border rests at Apple's HOVER value (#86868B, 3.62:1) because its
   resting #D2D2D7 is 1.68:1 on white.
   ========================================================================== */

input[type="text"],
input[type="password"],
input[type="number"],
input[type="search"],
input[type="email"],
input[type="url"],
select,
textarea {
    width: 100%;
    height: var(--hit);
    padding: 0 var(--sp-6);
    border: 1px solid var(--control-border);
    border-radius: var(--r-field);
    background-color: var(--field-fill);
    font-family: var(--font-text);
    font-size: var(--fs-body);
    line-height: 1;
    letter-spacing: var(--tr-body);
    font-weight: var(--fw-regular);
    color: var(--text);
}

textarea {
    height: auto;
    padding: var(--sp-6);
    line-height: var(--lh-body);
    resize: vertical;
}

@media (hover: hover) and (pointer: fine) {
    input:hover:not(:disabled):not(:focus),
    select:hover:not(:disabled):not(:focus),
    textarea:hover:not(:disabled):not(:focus) { border-color: var(--text-secondary); }
}

input:focus-visible,
select:focus-visible,
textarea:focus-visible { border-color: var(--accent); }

input:user-invalid,
textarea:user-invalid { border-color: var(--red); }

input:disabled,
select:disabled,
textarea:disabled { opacity: var(--disabled-opacity); pointer-events: none; }

/* Search field: the same box as every other control, plus Apple's own search
   affordances — a 1em magnifier inset .36em from the leading edge ([WK]
   searchFieldDecorationEmMargin / EmSize) and text inset .55em after it. */
input[type="search"] {
    -webkit-appearance: none;
    appearance: none;
    padding-inline-start: calc(.36em + 1em + .55em);
    background-image: var(--glyph-search);
    background-repeat: no-repeat;
    background-position: .36em center;
    background-size: 1em 1em;
}
input[type="search"]::-webkit-search-decoration,
input[type="search"]::-webkit-search-cancel-button { -webkit-appearance: none; }

/* Number field: the trailing control of a grouped row. */
input[type="number"] {
    width: 6ch;
    min-width: 6ch;
    text-align: end;
    font-variant-numeric: tabular-nums;
}
input[type="number"]::-webkit-outer-spin-button,
input[type="number"]::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
input[type="number"] { -moz-appearance: textfield; }

label {
    display: block;
    font-size: var(--fs-body);
    line-height: var(--lh-body-tight);
    letter-spacing: var(--tr-body);
    font-weight: var(--fw-regular);
    color: var(--text);
}


/* ==========================================================================
   16. SELECT — styled as an Apple pull-down button
   A native <select>, so x-model and @change keep firing; color-scheme on
   :root makes the dropdown panel itself match the appearance.
   ========================================================================== */

select {
    -webkit-appearance: none;
    appearance: none;
    padding-inline: var(--sp-6) 34px;
    background-image: var(--glyph-chevron-updown);
    background-repeat: no-repeat;
    background-position: right 10px center;
    background-size: 16px 16px;
    cursor: pointer;
}
select:disabled { cursor: not-allowed; }


/* ==========================================================================
   17. CARDS — .job-card, .login-card
   No shadow anywhere.  Separation comes from the --bg-grouped page behind
   and the gap between cards; in dark the card is LIGHTER than the page,
   which is the elevation cue.  Exactly one card is allowed to differ — the
   one with a book going through it.
   ========================================================================== */

.job-card,
.login-card {
    background: var(--surface);
    border: 0;
    border-radius: var(--r-card);
    box-shadow: none;
    padding: var(--sp-7);
}

/* The one memorable surface in the app: a book being converted.  ALL of the
   boldness in this stylesheet is spent here, and on nothing else.
   Three devices, none of them a shadow and none of them a gradient:
     · a hairline in the accent — the only coloured edge in the app, and it is
       present in BOTH appearances, so the emphasis does not change meaning
       between them (an earlier revision used elevation in dark and a hairline
       in light: two devices for one intent, which read as an accident);
     · the largest numeral in the app (34px, §19) — bigger than the wordmark;
     · 24px of padding against every other card's 16px, at EVERY width;
     · roughly twice the height of any settled card, and always first.
   A surface step was tried and REJECTED: the only rung above --surface in
   dark is #2C2C2E, where this app's --text-secondary measures 3.85:1, and
   the card is full of secondary text (.job-size, .eta-badge, .model-badge).
   test_text_contrast_meets_wcag_aa[dark] fails the moment you add it back.
   Only one job can ever be live (the worker is single-threaded), so exactly
   one card is reordered.  Do not extend `order` to anything else. */
/* The border BOX is reserved on every card, transparent until the card goes
   live, so the accent hairline can fade in (§29e) instead of appearing from
   nowhere — and so the card does not shift by a hairline when it does. */
.job-card { border: var(--hairline-w) solid transparent; }
.job-card.is-live {
    order: -1;
    padding: var(--sp-9);
    border-color: var(--live-edge);
}

@supports (corner-shape: squircle) {
    .job-card, .login-card, .upload-zone, .toast, .uploading-box {
        corner-shape: squircle;
        border-radius: calc(var(--r-card) * .85);
    }
    .inset-list, .file-list, .models-list, .radio-group, .queue-summary {
        corner-shape: squircle;
        border-radius: calc(var(--r-block) * .85);
    }
}


/* ==========================================================================
   18. BADGES & CHIPS — .badge + .badge-{pending,processing,completed,failed,
   cancelled}, .eta-badge, .eta-badge-dim, .elapsed-badge, .key-badge-*
   Apple's .badge-reduced geometry.  Sentence case, never ALL CAPS with
   tracking — that is a named tell, and the Polish labels the store emits are
   already sentence case.  No animation: a pulsing badge drops its contrast.
   ========================================================================== */

.badge,
.eta-badge,
.eta-badge-dim,
.elapsed-badge,
.key-badge {
    display: inline-flex;
    align-items: center;
    gap: var(--sp-2);
    padding: 4px 6px;
    border-radius: var(--r-chip);
    font-size: var(--fs-caption2);
    line-height: var(--lh-caption2);
    letter-spacing: var(--tr-caption2);
    font-weight: var(--fw-semibold);
    text-transform: none;
    white-space: nowrap;
    animation: none;
}

.badge-pending,
.eta-badge-dim,
.elapsed-badge { color: var(--neutral); background: var(--neutral-bg); }

.badge-processing,
.eta-badge { color: var(--info); background: var(--info-bg); }

.badge-completed,
.key-badge-ok { color: var(--green); background: var(--green-bg); }

.badge-failed { color: var(--red); background: var(--red-bg); }

.badge-cancelled,
.key-badge-missing { color: var(--orange); background: var(--orange-bg); }

/* A model identifier is metadata, not status, so it is plain secondary text
   rather than a chip. */
.model-badge {
    font-size: var(--fs-caption);
    line-height: var(--lh-caption-tight);
    letter-spacing: var(--tr-caption);
    font-weight: var(--fw-regular);
    color: var(--text-secondary);
}


/* ==========================================================================
   19. PROGRESS BAR — .progress-bar-track + .progress-bar-fill
   [WK] barBlockSize 8, barCornerRadius 4.
   The fill is a FULL-WIDTH rect scaled on its X axis, exactly the way
   UIProgressView draws it — the track's own overflow: hidden and --r-bar
   supply the caps, so they stay circular instead of squashing under the
   scale.  transform skips layout and paint; the `width` this used to animate
   triggered all three, every SSE tick.
   The tween is 200ms LINEAR.  Emil's table sends constant motion and
   progress to linear, and an eased bar would overshoot the numeral sitting
   directly above it.  The original "no transition at all" note assumed a
   faster SSE cadence than this app produces (one progress event per PAGE,
   seconds apart on a real book); if the bar is ever seen lagging the
   numeral, drop --dur-progress to 120ms — do not ease it.
   scaleX(0) at 0% is a VALUE, not a scale(0) entrance.
   No shimmer, no stripes, no gradient: animation-name must compute to `none`
   in every mode.
   ========================================================================== */

.progress-bar-track {
    width: 100%;
    height: 8px;
    border-radius: var(--r-bar);
    background: var(--progress-track);
    overflow: hidden;
}

.progress-bar-fill {
    width: 100%;
    height: 100%;
    border-radius: 0;              /* the track clips the caps */
    transform-origin: left center;
    background: var(--progress-fill);
    animation: none;
    transition: transform var(--dur-progress) linear;
}

/* The largest numeral in the app — larger than the wordmark, which is the
   point: on the queue screen the eye should land on the book in flight. */
.progress-pct {
    font-family: var(--font-display);
    font-size: var(--fs-hero);
    line-height: var(--lh-hero);
    letter-spacing: var(--tr-hero);
    font-weight: var(--fw-regular);
    font-variant-numeric: tabular-nums;
    color: var(--text);
}


/* ==========================================================================
   20. SPINNER — .spinner
   Eight tapered blades at descending opacity, stepping once per eighth of a
   second, one revolution per second: UIActivityIndicatorView, not the
   rotating border-arc (that is a Bootstrap idiom).  Drawn as a mask so the
   element keeps zero children — <span class="spinner"> sits inside buttons
   and must stay empty — and so the colour is currentColor with no extra
   rules: white inside a filled button, secondary grey on the splash.
   The -webkit- twins are required: Safari shipped unprefixed mask-image only
   in 15.4, and without them the spinner is a solid square.
   ========================================================================== */

.spinner {
    display: inline-block;
    flex: none;
    width: 20px;                        /* [C] UIActivityIndicatorView .medium */
    height: 20px;
    background-color: currentColor;
    -webkit-mask-image: var(--mask-spinner);
            mask-image: var(--mask-spinner);
    -webkit-mask-size: 100% 100%;
            mask-size: 100% 100%;
    -webkit-mask-repeat: no-repeat;
            mask-repeat: no-repeat;
    animation: sp-rot 1s steps(8) infinite;
}

@keyframes sp-rot { to { rotate: 360deg; } }

.spinner-sm { width: 16px; height: 16px; }
.spinner-lg { width: 37px; height: 37px; }   /* [C] .large */


/* ==========================================================================
   21. TOASTS — #toast-container + .toast / .toast-{error,success,info}
   The CONTAINER is what is fixed-positioned, not the toast: app.js can show
   three at once and per-toast fixed positioning would stack them on top of
   one another.  Opaque, never glass (§0.5).  The message text is --text and
   the TYPE is carried by the glyph, not by colouring the string — which is
   what Apple's own alerts do, and what makes the dark toast work (a red
   string on #2C2C2E is 3.80:1; the same red as a graphic needs 3:1).
   ========================================================================== */

#toast-container {
    position: fixed;
    top: calc(var(--header-h) + var(--safe-t) + var(--sp-4));
    right: max(var(--gutter), var(--safe-r));
    z-index: var(--z-toast);
    display: flex;
    flex-direction: column;
    align-items: flex-end;
    gap: var(--sp-3);
    max-width: min(420px, calc(100vw - 2 * var(--gutter)));
    pointer-events: none;
}

.toast {
    display: flex;
    align-items: flex-start;
    gap: var(--sp-3);
    padding: var(--sp-6) var(--sp-7);
    border: var(--hairline-w) solid var(--separator);
    border-radius: var(--r-card);
    background: var(--surface-elevated);
    font-size: var(--fs-caption);
    line-height: var(--lh-caption);
    letter-spacing: var(--tr-caption);
    font-weight: var(--fw-regular);
    color: var(--text);
    word-break: break-word;
    pointer-events: auto;
    cursor: pointer;

    /* A TRANSITION, not a keyframe.  showToast() can fire three times in one
       tick (the contrast test does exactly that); a transition retargets
       from the current value, a keyframe restarts from zero.
       IN BUDGET, and asymmetric.  An earlier revision ran this at 360ms on
       the built-in `ease`, on [E]'s Sonner note — but Sonner pairs that
       slower curve with a translate of the toast's OWN HEIGHT, and it is the
       travel that buys the time.  Over --travel (8px) the same 360ms is a
       slow pure fade, which is the thing the review skill flags, and running
       it symmetrically contradicted this sheet's own "exits snap".  So: it
       arrives on --dur-enter and leaves on --dur-exit (below), both on
       --ease-out, and both stay inside the 500ms app.js allows between
       .toast-fade and removal, so the exit always completes.
       It enters and LEAVES through the top edge — the container is top-right
       on a desktop and top-full-width on a phone, so -8px is the right
       direction in both. */
    opacity: 1;
    transform: translateY(0);
    transition: opacity   var(--dur-enter) var(--ease-out),
                transform var(--dur-enter) var(--ease-out);

    @starting-style {
        opacity: 0;
        transform: translateY(calc(-1 * var(--travel)));
    }
}

.toast::before {
    content: "";
    flex: none;
    width: 17px;
    height: 17px;
    margin-top: 1px;
    background-color: var(--text-secondary);
    -webkit-mask: var(--toast-glyph, var(--mask-info-circle)) center / contain no-repeat;
            mask: var(--toast-glyph, var(--mask-info-circle)) center / contain no-repeat;
}

.toast-error   { --toast-glyph: var(--mask-warning); }
.toast-success { --toast-glyph: var(--mask-checkmark-circle); }
.toast-info    { --toast-glyph: var(--mask-info-circle); }
.toast-error::before   { background-color: var(--red); }
.toast-success::before { background-color: var(--green); }
.toast-info::before    { background-color: var(--info); }

/* Never a bounce, never a slide across the viewport.  app.js adds
   .toast-fade 500ms before it removes the node; the toast leaves along the
   path it arrived on. */
.toast-fade {
    opacity: 0;
    transform: translateY(calc(-1 * var(--travel)));
    transition-duration: var(--dur-exit);   /* exits snap */
}

@media (max-width: 833px) {
    #toast-container {
        right: max(var(--gutter-sm), var(--safe-r));
        left: max(var(--gutter-sm), var(--safe-l));
        max-width: none;
    }
    .toast { width: 100%; }
}


/* ==========================================================================
   22. EMPTY STATE — .empty-state
   Apple's ContentUnavailableView: a glyph, a Title-3 line, a Body line.
   No box, no border, no illustration.
   ========================================================================== */

.empty-state {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--sp-4);
    padding-block: var(--sp-12);
    text-align: center;
}

.empty-state .icon,
.empty-state > svg {
    width: 28px;
    height: 28px;
    color: var(--text-tertiary);
    font-size: 28px;
    line-height: 1;
}

.empty-state-title {
    font-family: var(--font-display);
    font-size: var(--fs-title3);
    line-height: var(--lh-title3);
    letter-spacing: var(--tr-title3);
    font-weight: var(--fw-semibold);
    color: var(--text);
}

/* The action is not a fourth line of text: it gets its own breathing room. */
.empty-state .btn { margin-top: var(--sp-4); }

.empty-state p,
.empty-state-body {
    max-width: 34ch;
    font-size: var(--fs-body);
    line-height: var(--lh-body);
    letter-spacing: var(--tr-body);
    color: var(--text-secondary);
    text-wrap: balance;
}


/* ==========================================================================
   23. MODAL OVERLAY — .uploading-overlay / .uploading-box
   The second and last floating layer.  No shadow: depth is the elevated
   surface plus the scrim.  Body text is --text, NOT --text-secondary, which
   measures only 3.85:1 on the dark elevated surface.
   ========================================================================== */

.uploading-overlay {
    position: fixed;
    inset: 0;
    z-index: var(--z-overlay);
    display: flex;
    align-items: center;
    justify-content: center;
    padding: calc(var(--gutter-sm) + var(--safe-t)) calc(var(--gutter-sm) + var(--safe-r))
             calc(var(--gutter-sm) + var(--safe-b)) calc(var(--gutter-sm) + var(--safe-l));
    background: var(--scrim);
}

.uploading-box {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--sp-7);
    padding: var(--sp-9);
    border-radius: var(--r-card);
    background: var(--surface-elevated);
    box-shadow: none;
    font-size: var(--fs-body);
    line-height: var(--lh-body);
    letter-spacing: var(--tr-body);
    color: var(--text);
    text-align: center;

    /* The ONE place a keyframe is the right tool.  A CSS animation replays
       whenever the element goes display:none -> rendered, which is exactly
       what x-show does, so it fires on every upload with zero JS — and this
       overlay is up precisely while the main thread is busy POSTing files,
       where a CSS animation keeps running and a rAF one drops frames.  The
       interruptibility objection to keyframes does not apply: `uploading` is
       one boolean guarding one request, so it is never re-triggered rapidly.
       A modal is the stated exemption from origin-awareness — it is not
       anchored to a trigger, so it scales from its own centre.
       scale(.96), never scale(0): nothing in the real world appears from
       nothing.  Exit is the scrim's opacity fade alone; a box scaling away
       would compete with the job cards arriving behind it. */
    animation: box-in var(--dur-overlay) var(--ease-out) both;
}

@keyframes box-in {
    from { opacity: 0; transform: scale(.96); }
}

.uploading-box .spinner { width: 37px; height: 37px; color: var(--text-secondary-alt); }


/* ==========================================================================
   24. LIST TABLE — .models-list / .models-table-header / .model-row
   A macOS list, not a stack of cards, and a BOUNDED one: /api/models is
   uncapped and OpenRouter returns hundreds of rows.  `overflow: hidden auto`
   does two things at once — it clips the corners AND makes .models-list the
   nearest scrollport, so the column header's `position: sticky; top: 0`
   actually sticks instead of scrolling off with the page.
   ========================================================================== */

.models-list {
    background: var(--surface);
    border-radius: var(--r-block);
    max-height: min(60svh, 560px);
    overflow: hidden auto;
    overscroll-behavior: contain;
}

.models-table-header {
    position: sticky;
    top: 0;
    z-index: 1;
    display: grid;
    grid-template-columns: minmax(0, 1fr) 150px 190px;
    align-items: center;
    gap: var(--sp-7);
    padding: var(--sp-4) var(--row-inset);
    padding-inline-start: calc(var(--row-inset) + 20px);   /* selection gutter */
    border-bottom: var(--hairline-w) solid var(--separator);
    background: var(--surface);
    font-size: var(--fs-footnote);
    line-height: var(--lh-footnote);
    letter-spacing: var(--tr-footnote);
    font-weight: var(--fw-regular);
    text-transform: none;
    color: var(--text-secondary);
}

.model-row {
    position: relative;
    display: grid;
    grid-template-columns: minmax(0, 1fr) 150px 190px;
    align-items: center;
    gap: var(--sp-7);
    min-height: var(--hit);
    padding-inline: calc(var(--row-inset) + 20px) var(--row-inset);  /* gutter */
    padding-block: var(--sp-4);
    border: 0;
}

/* Text-inset, exactly like .api-key-row and the switch/radio rows twenty
   pixels below it — a full-bleed rule in a grouped list is the one mistake
   that reads as "not Apple" from across the room.  The rule starts at the
   model name, i.e. after the 36px selection gutter. */
.model-row::after {
    content: "";
    position: absolute;
    inset-inline: calc(var(--row-inset) + 20px) 0;
    bottom: 0;
    height: var(--hairline-w);
    background: var(--separator);
}
.model-row:last-child::after { content: none; }
@media (hover: hover) and (pointer: fine) {
    .model-row:hover { background: var(--fill-4); }
}

/* The checkmark is the ONLY selection marker.  An earlier revision also
   tinted the row and swapped the button label for the word "Wybrany" —
   three signals for one state, and the tint dropped the row's secondary
   text to 4.43:1 in dark. */

/* The whole row is the control: a transparent button stretched over it.
   It carries the accessible name, the focus ring and the hit region, and it
   is why the model table has no per-row pill. */
.model-select {
    position: absolute;
    inset: 0;
    width: 100%;
    min-width: 0;
    padding: 0;
    border: 0;
    border-radius: 0;
    background: none;
}
.model-select::after { content: none; }   /* the row is already >= 44px */

/* The ring is drawn INSIDE the row.  .models-list is a scrollport with
   `overflow: hidden auto`, so any positive offset on a full-width row has
   its left and right sides clipped away and the user gets two accent rules
   instead of a focus indicator.  Specificity matches §6's .models-list .btn
   rule and wins on source order. */
.models-list .model-select:focus-visible { outline-offset: -3px; }

.model-row-active .mt-name { position: relative; }
.model-row-active .mt-name::before {
    content: "";
    position: absolute;
    left: -20px;
    top: .35em;
    width: 14px;
    height: 14px;
    background-color: var(--accent);
    -webkit-mask: var(--mask-checkmark) center / contain no-repeat;
            mask: var(--mask-checkmark) center / contain no-repeat;
}

.mt-name {
    min-width: 0;
    font-size: var(--fs-caption);
    line-height: var(--lh-caption-tight);
    letter-spacing: var(--tr-caption);
    font-weight: var(--fw-semibold);
    color: var(--text);
}

/* Monospace is correct here: a model identifier is code, not a data label. */
.model-id {
    display: block;
    margin-top: 1px;
    font-family: var(--font-mono);
    font-size: var(--fs-caption2);
    line-height: var(--lh-caption2);
    letter-spacing: 0;
    font-weight: var(--fw-regular);
    color: var(--text-secondary-fill);
    word-break: break-all;
}

/* --text-secondary-fill, not --text-secondary: the whole row is a control
   now, and its hover fill lifts dark rows to rgb(44,44,48), where #86868B
   drops to 3.84:1.  WCAG applies to every state, hover included. */
.mt-cost,
.mt-prices {
    text-align: end;
    font-variant-numeric: tabular-nums;
    font-size: var(--fs-caption2);
    line-height: var(--lh-caption2);
    letter-spacing: var(--tr-caption2);
    color: var(--text-secondary-fill);
}
.mt-cost strong { color: var(--text); font-weight: var(--fw-semibold); }

.price-free { color: var(--green); }
.price-est { color: var(--text); }
/* Never --text-tertiary: these carry meaning. */
.price-usd,
.price-variable,
.price-unknown { color: var(--text-secondary-fill); font-style: normal; }

@media (max-width: 899px) {
    .models-table-header { display: none; }  /* it labels columns that are gone */
    .model-row {
        grid-template-columns: minmax(0, 1fr);
        gap: var(--sp-2) var(--sp-7);
        padding-block: var(--sp-6);
    }
    .mt-name    { grid-column: 1 / -1; }
    .mt-cost    { grid-column: 1; text-align: start; }
    .mt-prices  { grid-column: 1; text-align: start; }
}


/* ==========================================================================
   25. APP HEADER — .app-header
   The ONE blurred element in the stylesheet.  Stacking backdrop-filters
   double-blurs, costs a repaint each and renders differently in Safari, so
   toasts, overlays and cards are opaque.  saturate(180%) is mandatory: blur
   alone averages what is behind toward grey and reads as frosted plastic.
   The @supports query tests BOTH spellings — Safari shipped backdrop-filter
   unprefixed only in 18.0, and testing the unprefixed name alone would drop
   every Safari <= 17 to the opaque fallback.
   ========================================================================== */

.app-header {
    position: sticky;
    top: 0;
    z-index: var(--z-sticky);
    padding-top: var(--safe-t);   /* the material runs under the notch; the
                                     controls inside it never do */
    background: var(--glass-bg-solid);
    border-bottom: var(--hairline-w) solid var(--glass-hairline);
}

@supports ((-webkit-backdrop-filter: saturate(180%) blur(20px))
        or (backdrop-filter: saturate(180%) blur(20px))) {
    .app-header {
        -webkit-backdrop-filter: var(--glass-filter);
                backdrop-filter: var(--glass-filter);
        background: var(--glass-bg);
    }
}

/* MUST stay after the @supports block above: same specificity (0,0,1,0), so
   whichever is written last wins.  Turning translucency off is the whole
   point of the OS setting, and the surface has to go fully opaque with it —
   a blurred bar the user cannot switch off is the thing the setting exists
   to prevent. */
@media (prefers-reduced-transparency: reduce) {
    .app-header {
        -webkit-backdrop-filter: none;
                backdrop-filter: none;
        background: var(--glass-bg-opaque);
    }
}

/* The hairline is present by default and is REMOVED at the top of the page,
   not the other way round: written the other way, Safari < 26 and Firefox
   would have no header edge at all. */
@supports (animation-timeline: scroll()) {
    .app-header {
        animation: hdr-edge linear both;
        animation-timeline: scroll(root block);
        animation-range: 0 8px;
    }
    @keyframes hdr-edge { from { border-bottom-color: transparent; } }
}

.app-header-inner {
    display: flex;
    align-items: center;
    gap: var(--sp-7);
    min-height: var(--header-h);
    padding-block: var(--sp-4);
}

/* Flat --text at Title-3, no gradient.  The arrow stays the real glyph it
   already is: this is a product name, not a button label. */
.app-brand {
    flex: none;
    font-family: var(--font-display);
    font-size: var(--fs-title3);
    line-height: var(--lh-title3);
    letter-spacing: var(--tr-title3);
    font-weight: var(--fw-semibold);
    color: var(--text);
    background: none;
    -webkit-text-fill-color: currentColor;
}

.app-header .tab-bar {
    position: absolute;
    left: 50%;
    translate: -50%;
    width: auto;
    min-width: 380px;
}

.logout-btn { flex: none; margin-inline-start: auto; }

@media (max-width: 833px) {
    :root { --header-h: 112px; }   /* [D] 8 + 44 + 8 + 44 + 8 */

    .app-header-inner { flex-wrap: wrap; row-gap: var(--sp-4); }
    .app-brand { font-size: var(--fs-body); line-height: var(--lh-body-tight); letter-spacing: var(--tr-body); }
    /* `relative`, not `static`: .seg-thumb is absolutely positioned against
       .tab-bar, and a static track would hand it the sticky header as its
       containing block — the thumb would climb out over the wordmark. */
    .app-header .tab-bar {
        position: relative;
        left: auto;
        translate: none;
        order: 3;
        flex-basis: 100%;
        width: 100%;
        min-width: 0;
    }
    .app-header .tab-btn { padding-inline: var(--sp-4); font-size: var(--fs-footnote); letter-spacing: var(--tr-footnote); }
}


/* ==========================================================================
   26. COUNTER STRIP — .queue-summary / .summary-card
   One container with vertical hairlines, but NOT four peers: Oczekujące and
   Przetwarzane are the live queue (and Przetwarzane is 0 or 1 — the worker is
   single-threaded), while Ukończone and Błędy are lifetime tallies that only
   ever grow.  Two tiers say so.  The status dot is a ::before, so the label's
   DOM is untouched.
   ========================================================================== */

.queue-summary {
    display: grid;
    grid-template-columns: repeat(4, 1fr);
    background: var(--surface);
    border-radius: var(--r-block);
    overflow: hidden;
}

.summary-card {
    display: flex;
    flex-direction: column;
    gap: var(--sp-1);
    padding: var(--sp-7);
}
.summary-card + .summary-card { border-inline-start: var(--hairline-w) solid var(--separator); }

.summary-card .count {
    font-family: var(--font-display);
    font-size: var(--fs-title1);
    line-height: var(--lh-title1);
    letter-spacing: var(--tr-title1);
    font-weight: var(--fw-regular);
    font-variant-numeric: tabular-nums;
    color: var(--text);
}

.summary-card .label {
    display: flex;
    align-items: center;
    gap: var(--sp-3);
    font-size: var(--fs-caption2);
    line-height: var(--lh-caption2);
    letter-spacing: var(--tr-caption2);
    font-weight: var(--fw-regular);
    color: var(--text-secondary);
}

.summary-card .label::before {
    content: "";
    flex: none;
    width: 6px;
    height: 6px;
    border-radius: 50%;
}
.summary-card.pending    .label::before { background: var(--dot-pending); }
.summary-card.processing .label::before { background: var(--dot-processing); }
.summary-card.completed  .label::before { background: var(--dot-completed); }
.summary-card.failed     .label::before { background: var(--dot-failed); }

/* Tier 2: the lifetime tallies read as reference, not as the live queue. */
.summary-card.completed .count,
.summary-card.failed .count {
    font-size: var(--fs-title3);
    line-height: var(--lh-title3);
    letter-spacing: var(--tr-title3);
    color: var(--text-secondary);
}

.summary-caption {
    padding: var(--sp-3) var(--row-inset) 0;
    font-size: var(--fs-footnote);
    line-height: var(--lh-footnote);
    letter-spacing: var(--tr-footnote);
    color: var(--text-secondary);
}


/* ==========================================================================
   27. UTILITIES
   ========================================================================== */

.hidden { display: none !important; }

.sr-only {
    position: absolute;
    width: 1px; height: 1px;
    padding: 0; margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    border: 0;
}

/* Opacity only.  A translateY entrance is a decorative animation and a
   listed tell; this exists to stop a screen swap flashing, nothing more.
   --ease-fade is Apple's own shipping curve and it lives here and NOWHERE
   else.  On the tab panels this keyframe is switched off in §29a: those
   already carry Alpine's x-transition.opacity, and two crossfades for one
   event is one too many.  The class stays on them — it is a frozen hook. */
.fade-in { animation: fade-in var(--dur-enter) var(--ease-fade); }
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }

/* ==========================================================================
   28. SCREENS
   ------------------------------------------------------------------------
   Composition only: flow, gaps, grids, widths, and the few per-screen
   decisions that belong to a screen rather than to a component.  Colour,
   radius, type and state live in sections 9-27 and are never re-specified
   here.  Each screen is laid out on the primitives above; where a screen
   needed a primitive that did not exist (a stat pair, a state slot, a
   console) it is defined here and nowhere else.
   ========================================================================== */

.main-content { padding-top: var(--sp-9); padding-bottom: calc(var(--sp-12) + var(--safe-b)); }


/* --------------------------------------------------------------------------
   28.1 SPLASH
   Nothing but the wheel: the splash must never flash anything a logged-in
   user could read as a login screen.
   -------------------------------------------------------------------------- */

.splash {
    display: flex;
    align-items: center;
    justify-content: center;
    min-height: 100dvh;
    color: var(--text-secondary);
}


/* --------------------------------------------------------------------------
   28.2 LOGIN
   One card on the grouped background.  Form content is left-aligned, which
   is what Apple does; centred labels above centred fields are a web idiom.
   -------------------------------------------------------------------------- */

.login-wrapper {
    display: flex;
    align-items: center;
    justify-content: center;
    min-height: 100dvh;
    padding: calc(var(--gutter-sm) + var(--safe-t)) calc(var(--gutter-sm) + var(--safe-r))
             calc(var(--gutter-sm) + var(--safe-b)) calc(var(--gutter-sm) + var(--safe-l));
}

.login-card {
    width: min(360px, 100%);
    padding: var(--sp-10) var(--sp-9);
    text-align: start;
}

.login-card .logo {
    font-family: var(--font-display);
    font-size: var(--fs-title1);
    line-height: var(--lh-title1);
    letter-spacing: var(--tr-title1);
    font-weight: var(--fw-semibold);
    color: var(--text);
}

.login-card .subtitle {
    margin-bottom: var(--sp-9);
    font-size: var(--fs-caption);
    line-height: var(--lh-caption);
    letter-spacing: var(--tr-caption);
    color: var(--text-secondary);
}

/* The triangle is a SIBLING of .login-error, because x-text on the message
   would erase any child it had.  Alpine writes textContent = '' rather than
   removing the node, so the row would otherwise paint a permanent warning
   above a clean login card; :empty is exactly what an emptied x-text leaves. */
.login-error-row {
    display: flex;
    align-items: flex-start;
    gap: var(--sp-3);
    /* Reserved, never collapsed: a row that appears on a wrong code grew the
       card 32px and slid the password field the user is still looking at out
       from under the cursor.  The ICON is what hides. */
    min-height: calc(var(--fs-caption) * var(--lh-caption));
    margin-bottom: var(--sp-6);
    font-size: var(--fs-caption);
    line-height: var(--lh-caption);
    letter-spacing: var(--tr-caption);
    color: var(--red);
}
.login-error-row:has(> .login-error:empty) > .icon { visibility: hidden; }

/* Same guard as §11's thumb: without :has() the triangle would be permanently
   visible above a clean card, so it is dropped and the message carries alone. */
@supports not selector(:has(*)) {
    .login-error-row > .icon { display: none; }
}

.login-card .input-group { margin-bottom: var(--sp-6); }


/* --------------------------------------------------------------------------
   28.3 QUEUE
   -------------------------------------------------------------------------- */

.jobs-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--sp-7);
    min-height: var(--hit);
    margin-block: var(--sp-9) var(--sp-4);
}
.jobs-header h2 {
    font-size: var(--fs-title3);
    line-height: var(--lh-title3);
    letter-spacing: var(--tr-title3);
}

/* A flex column, so .job-card.is-live can be pulled to the top with order. */
.job-list { display: flex; flex-direction: column; gap: var(--sp-6); }

.job-card-top {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: var(--sp-6);
    flex-wrap: wrap;
}

.job-filename {
    flex: 1 1 auto;
    min-width: 0;
    font-size: var(--fs-body);
    line-height: var(--lh-body-tight);
    letter-spacing: var(--tr-body);
    font-weight: var(--fw-semibold);
    color: var(--text);
    word-break: break-word;
}

.job-meta { display: flex; align-items: center; gap: var(--sp-6); flex: none; flex-wrap: wrap; }

.job-size {
    font-size: var(--fs-caption);
    line-height: var(--lh-caption-tight);
    letter-spacing: var(--tr-caption);
    color: var(--text-secondary);
    font-variant-numeric: tabular-nums;
}
.job-time { min-width: 62px; text-align: end; }

/* ---- the in-flight card: the one place boldness is spent ---- */
.job-progress-section { margin-top: var(--sp-7); }

.progress-eta-row {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: var(--sp-6);
    margin-bottom: var(--sp-5);
}
/* The percentage owns the leading edge and pushes the time chips to the
   trailing one — where iOS puts a remaining-time readout.  The auto margin
   is on the percentage, not on a chip: either chip can be x-show'd away,
   and a hidden element still matches an adjacent-sibling selector. */
.progress-eta-row .progress-pct { margin-inline-end: auto; }

/* Facts are separated by space, never by a middle dot. */
.progress-detail-row {
    display: flex;
    align-items: baseline;
    flex-wrap: wrap;
    gap: var(--sp-2) var(--sp-9);
    margin-top: var(--sp-6);
    font-size: var(--fs-caption);
    line-height: var(--lh-caption);
    letter-spacing: var(--tr-caption);
    color: var(--text-secondary);
}
.progress-message { font-style: normal; color: var(--text-secondary); }
.progress-detail-row .model-badge { margin-inline-start: auto; }

.job-pending-info {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: var(--sp-3) var(--sp-9);
    margin-top: var(--sp-6);
    font-size: var(--fs-caption);
    line-height: var(--lh-caption);
    letter-spacing: var(--tr-caption);
    color: var(--text-secondary);
}
.queue-position { color: var(--text); font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; }

/* Inline `label: value` pairs, not a four-up grid: formatCost() emits
   "$0.1832 (~0.71 PLN)", which no 4-column grid holds at 390px. */
.job-stats {
    display: flex;
    flex-wrap: wrap;
    gap: var(--sp-3) var(--sp-8);
    margin-top: var(--sp-6);
    font-size: var(--fs-caption);
    line-height: var(--lh-caption);
    letter-spacing: var(--tr-caption);
    color: var(--text-secondary);
}
.job-stats .stat { display: inline-flex; align-items: baseline; gap: var(--sp-2); }
.job-stats .stat-label { color: var(--text-secondary); }
.job-stats .stat-value {
    color: var(--text);
    font-weight: var(--fw-semibold);
    font-variant-numeric: tabular-nums;
}

/* min-height keeps every button's 44px hit pad inside this row (§2.0). */
.job-actions {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: var(--sp-4);
    min-height: var(--hit);
    margin-top: var(--sp-4);
}

/* A plain button's LABEL, not its box, is what the eye aligns on, so the
   first one in an action row is pulled flush with the card's content edge —
   the same trick Apple uses on text-style buttons.  A negative margin alone
   is not enough: .btn also centres inside a 60px (45px at -sm) min-width, so
   the leading padding goes AND the content left-aligns; -1px covers the
   transparent border. */
.job-actions > .btn-ghost:first-child,
.logs-actions > .btn-ghost:first-child {
    justify-content: flex-start;
    padding-inline-start: 0;
    margin-inline-start: -1px;
}

.job-error {
    display: flex;
    align-items: flex-start;
    gap: var(--sp-3);
    margin-top: var(--sp-6);
    padding: var(--sp-5) var(--sp-6);
    border-radius: var(--r-field);
    background: var(--red-bg);
    font-size: var(--fs-caption);
    line-height: var(--lh-caption);
    letter-spacing: var(--tr-caption);
    color: var(--red);
    word-break: break-word;
}

.jobs-more { display: flex; justify-content: center; min-height: var(--hit); margin-top: var(--sp-7); }

/* Every warning row is an icon plus a block of text; the glyph sits on the
   first line rather than centred against a paragraph. */
.job-error > .icon,
.upload-warning > .icon,
.provider-warning > .icon,
.login-error-row > .icon,
.app-load-error > .icon { flex: none; margin-top: .12em; }


/* --------------------------------------------------------------------------
   28.4 UPLOAD
   -------------------------------------------------------------------------- */

/* A real <button>, so Enter and Space open the chooser.  It carries the same
   --control-border hairline as every other control: --well against
   --bg-grouped is 1.05:1 and would leave an interactive boundary invisible. */
.upload-zone {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--sp-2);
    width: 100%;
    padding: var(--sp-12) var(--sp-9);
    border: var(--hairline-w) solid var(--control-border);
    border-radius: var(--r-card);
    background: var(--well);
    color: var(--text);
    text-align: center;
    cursor: pointer;
}
@media (hover: hover) and (pointer: fine) {
    .upload-zone:hover { background: var(--fill-4); }
}

/* An outline, not a dashed border: a 2px border on a box with none at rest
   would reflow the page on every dragover. */
.upload-zone.dragover {
    outline: 2px solid var(--accent);
    outline-offset: -2px;
    background: var(--info-bg);
}

.upload-zone > .icon { margin-bottom: var(--sp-4); color: var(--text-tertiary); }
.upload-zone .main-text {
    font-family: var(--font-display);
    font-size: var(--fs-title3);
    line-height: var(--lh-title3);
    letter-spacing: var(--tr-title3);
    font-weight: var(--fw-semibold);
    color: var(--text);
}
.upload-zone .sub-text {
    font-size: var(--fs-body);
    line-height: var(--lh-body);
    letter-spacing: var(--tr-body);
    font-weight: var(--fw-regular);
    color: var(--text-secondary);
}
.upload-limits {
    margin-top: var(--sp-3);
    font-size: var(--fs-footnote);
    line-height: var(--lh-footnote);
    letter-spacing: var(--tr-footnote);
    font-weight: var(--fw-regular);
    color: var(--text-secondary);
}

.upload-effective {
    margin-top: var(--sp-7);
    font-size: var(--fs-body);
    line-height: var(--lh-body);
    letter-spacing: var(--tr-body);
    color: var(--text-secondary);
    text-align: start;
}
.upload-effective strong { color: var(--text); }

.upload-warning,
.provider-warning {
    display: flex;
    align-items: flex-start;
    gap: var(--sp-3);
    margin-top: var(--sp-4);
    padding: var(--sp-5) var(--sp-6);
    border-radius: var(--r-field);
    background: var(--orange-bg);
    font-size: var(--fs-caption);
    line-height: var(--lh-caption);
    letter-spacing: var(--tr-caption);
    color: var(--orange);
    text-align: start;
}
.provider-warning { margin-block: 0 var(--sp-4); }

/* A grouped inset list: the container is rounded and clips, never the row. */
.file-list {
    margin-top: var(--sp-8);
    background: var(--surface);
    border-radius: var(--r-block);
    overflow: hidden;
}
.file-item {
    position: relative;
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: var(--sp-2) var(--sp-6);
    min-height: var(--hit);
    padding: var(--sp-4) var(--row-inset);
}
.file-item + .file-item::before {
    content: "";
    position: absolute;
    inset-inline: var(--row-inset) 0;
    top: 0;
    height: var(--hairline-w);
    background: var(--separator);
}
.file-item-info { display: flex; align-items: center; gap: var(--sp-4); flex: 1 1 auto; min-width: 0; }

/* accept=".pdf" makes every row a PDF, so a document glyph on every row
   carries no information.  The leading slot carries STATE instead — empty
   when the file is fine — and keeps its width either way so the names stay
   on one vertical line. */
.file-item-icon {
    flex: none;
    display: inline-flex;
    width: 1em;
    height: 1em;
    color: var(--red);
}

.file-item-name {
    min-width: 0;
    font-size: var(--fs-body);
    line-height: var(--lh-body-tight);
    letter-spacing: var(--tr-body);
    color: var(--text);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}
.file-item-size {
    flex: none;
    margin-inline-start: auto;
    font-size: var(--fs-caption);
    line-height: var(--lh-caption-tight);
    letter-spacing: var(--tr-caption);
    color: var(--text-secondary);
    font-variant-numeric: tabular-nums;
}
.file-item-error {
    order: 3;
    flex-basis: 100%;
    padding-inline-start: calc(1em + var(--sp-4));
    font-size: var(--fs-footnote);
    line-height: var(--lh-footnote);
    letter-spacing: var(--tr-footnote);
    color: var(--red);
}

.upload-actions { display: flex; justify-content: flex-end; min-height: var(--hit); margin-top: var(--sp-9); }


/* --------------------------------------------------------------------------
   28.5 SETTINGS — grouped inset lists
   The card is a background-less WRAPPER: the header sits above the box, the
   .inset-list is the box, and one caption sits below it.  Half this screen
   saves itself (provider, key, model) and half waits for Zapisz; a hairline
   rule separates the two halves and the captions say which is which.
   -------------------------------------------------------------------------- */

.settings-grid {
    display: grid;
    gap: var(--sp-10);
    max-width: var(--content-max);
}

.settings-rule {
    border: 0;
    border-top: var(--hairline-w) solid var(--separator);
    margin: 0;
}

/* ---- API-key rows: grouped rows, not cards ---- */
.api-key-row {
    position: relative;
    padding: var(--sp-6) var(--row-inset);
}
.api-key-row::after {
    content: "";
    position: absolute;
    inset-inline: var(--row-inset) 0;
    bottom: 0;
    height: var(--hairline-w);
    background: var(--separator);
}
.inset-list > .api-key-row:last-child::after { content: none; }

/* The provider in use is marked with an accent GLYPH, not a tinted row.  The
   6% accent wash that used to live here (then blue, now turquoise) lifted the
   dark row to rgb(26,33,42),
   where the row's own red "Usuń" fell to 4.43:1 and its caption to 4.48:1 —
   a tint behind body text has to earn its place against WCAG 1.4.3, and this
   one could not.  A coloured glyph is non-text (1.4.11, 3:1; it measures
   5.5:1) and is the same marker the model list uses. */
.api-key-row-active .api-key-glyph { color: var(--link); }

.api-key-head {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: var(--sp-3) var(--sp-4);
    margin-bottom: var(--sp-4);
    font-size: var(--fs-body);
    line-height: var(--lh-body-tight);
    letter-spacing: var(--tr-body);
}
.api-key-glyph { color: var(--text-secondary); }

.api-key-input {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: var(--sp-4);
    min-height: var(--hit);
}
.api-key-input input { flex: 1 1 220px; min-width: 0; width: auto; }

/* A caption that lives INSIDE a row is already inside the row's inset. */
.field-hint-inline { padding-inline: 0; }

/* ---- model picker ---- */
.model-toolbar {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: var(--sp-4) var(--sp-6);
    min-height: var(--hit);
    margin-bottom: var(--sp-4);
    padding-inline: var(--row-inset);
}
.model-current-label {
    font-size: var(--fs-caption);
    line-height: var(--lh-caption-tight);
    letter-spacing: var(--tr-caption);
    color: var(--text-secondary);
    white-space: nowrap;
}
.model-current {
    font-size: var(--fs-caption);
    line-height: var(--lh-caption-tight);
    letter-spacing: var(--tr-caption);
    color: var(--text);
}
/* Grows into the space the toolbar has and wraps to its own line when there
   is none, so the bar is one line on a laptop and three on a phone without a
   breakpoint deciding it. */
.model-search { flex: 1 1 180px; min-width: 160px; width: auto; margin-inline-start: auto; }
.ml-auto { margin-inline-start: auto; }

/* ---- the Zapisz bar ---- */
.settings-actions {
    display: flex;
    align-items: center;
    gap: var(--sp-6);
    min-height: var(--hit);
    margin-top: var(--sp-10);
}
.settings-saved {
    display: inline-flex;
    align-items: center;
    gap: var(--sp-3);
    font-size: var(--fs-caption);
    line-height: var(--lh-caption);
    letter-spacing: var(--tr-caption);
    color: var(--green);
}
.settings-actions-hint {
    margin-top: var(--sp-3);
    font-size: var(--fs-footnote);
    line-height: var(--lh-footnote);
    letter-spacing: var(--tr-footnote);
    color: var(--text-secondary);
}
.soon { color: var(--text-secondary); font-style: normal; }


/* --------------------------------------------------------------------------
   28.6 LOGS
   A console that tails a running job.  The box is a FIXED height, not a
   min-height: appendJobLog() + scrollLogs() pin the textarea to the bottom
   over SSE, and a growing box would reflow the page, move the copy button
   out from under the cursor and fight the scroll position app.js manages.
   Monospace is right here — this is machine output, i.e. code — which is a
   different case from the small data labels that use tabular SF instead.
   -------------------------------------------------------------------------- */

.logs-panel { display: flex; flex-direction: column; gap: var(--sp-7); }

.logs-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    flex-wrap: wrap;
    gap: var(--sp-6);
    min-height: var(--hit);
}
.logs-header h2 { font-size: var(--fs-title3); line-height: var(--lh-title3); letter-spacing: var(--tr-title3); }
/* min-width: 0 is load-bearing.  A flex item defaults to min-width: auto and
   a <select>'s min-content width is its widest <option> — a job filename —
   so without this the control refuses to shrink and pushes the whole page
   into a horizontal scroll on a phone.  The 420px cap is a max, not a size. */
.logs-select {
    width: auto;
    min-width: 0;
    max-width: min(420px, 100%);
    flex: 1 1 260px;
}

.logs-textarea {
    width: 100%;
    height: 480px;
    padding: var(--sp-7);
    border-radius: var(--r-card);
    font-family: var(--font-mono);
    font-size: var(--fs-footnote);
    line-height: 1.55;
    letter-spacing: 0;
    color: var(--text-secondary-alt);
    white-space: pre;
    overflow: auto;
    overscroll-behavior: contain;   /* scrolling to the end of a log must not
                                       chain to the page behind it */
    resize: none;
}

.logs-actions { display: flex; align-items: center; justify-content: flex-end; min-height: var(--hit); margin-top: var(--sp-4); }


/* --------------------------------------------------------------------------
   28.7 APP FAILED TO LOAD (Alpine / app.js missing)
   -------------------------------------------------------------------------- */

.app-load-error {
    position: fixed;
    top: calc(var(--sp-12) + var(--safe-t));
    left: max(var(--gutter-sm), var(--safe-l));
    right: max(var(--gutter-sm), var(--safe-r));
    z-index: var(--z-toast);
    display: flex;
    align-items: flex-start;
    gap: var(--sp-3);
    max-width: 520px;
    margin-inline: auto;
    padding: var(--sp-7) var(--sp-8);
    border-radius: var(--r-card);
    background: var(--red-bg);
    font-size: var(--fs-caption);
    line-height: var(--lh-caption);
    letter-spacing: var(--tr-caption);
    color: var(--red);
    text-align: start;
    /* Hidden unless nothing removed it within a few seconds. */
    visibility: hidden;
    animation: reveal-late 0s linear 6s forwards;
}
.app-load-error-visible { visibility: visible; animation: none; }
@keyframes reveal-late { to { visibility: visible; } }


/* --------------------------------------------------------------------------
   28.8 RESPONSIVE
   Only sizes and wrapping change between widths — never the words.  The
   collapses below are content-driven and each one fires where its own
   content stops fitting, which is not where the page chrome changes: the
   833px breakpoint is apple.com's container breakpoint and governs gutters,
   the header and the type step, nothing else.
   -------------------------------------------------------------------------- */

/* A four-up counter strip needs ~150px a column for "Przetwarzane". */
@media (max-width: 639px) {
    .queue-summary { grid-template-columns: repeat(2, 1fr); }
    .summary-card:nth-child(n + 3) { border-block-start: var(--hairline-w) solid var(--separator); }
    .summary-card:nth-child(odd) { border-inline-start: 0; }

    /* The live card KEEPS its 24px: the padding step is one of the four
       devices that make it the hero, and a phone is where the queue is
       most often read. */
    .job-card { padding: var(--sp-7); }
    .job-card.is-live { padding: var(--sp-9); }
    .job-card-top { gap: var(--sp-2) var(--sp-6); }
    .job-filename { flex-basis: 100%; }
    .job-meta { flex: 1 1 100%; justify-content: flex-start; }
    .job-time { min-width: 0; margin-inline-start: auto; }

    /* The two primary, filled actions become block buttons; the ghost ones
       do not — a full-width button with no fill is a floating label. */
    .upload-actions,
    .settings-actions { justify-content: stretch; }
    .upload-actions .btn,
    .settings-actions .btn-primary {
        display: flex;
        box-sizing: border-box;
        width: 100%;
        min-width: 0;
        border-radius: var(--r-field);
    }
    .settings-actions { flex-wrap: wrap; }
}

/* "Dostawca" + a 360px segmented control need ~500px before the control
   drops to its own line inside the row. */
@media (max-width: 559px) {
    .row-inner-stack {
        flex-direction: column;
        align-items: stretch;
        gap: var(--sp-4);
    }
    .row-inner-stack .provider-switch { max-width: none; }
}

/* Below ~360px the two provider names side by side no longer fit on one line
   each, and their combined min-content was pushing the settings column six
   pixels past the viewport.  They wrap rather than truncate: "Google Gem…"
   is a worse answer than two short lines, and truncating a provider name is
   not something Apple would do to a segmented control. */
@media (max-width: 359px) {
    .provider-btn {
        display: flex;
        align-items: center;
        justify-content: center;
        padding-inline: var(--sp-3);
        line-height: var(--lh-caption-tight);
    }
    .provider-btn .seg-label {
        white-space: normal;
        overflow: visible;
        text-overflow: clip;
    }
    .settings-card { min-width: 0; }
}

@media (max-width: 833px) {
    .login-card { padding: var(--sp-9) var(--sp-8); }
    .upload-zone { padding: var(--sp-10) var(--sp-7); }
    .settings-grid { gap: var(--sp-9); }

    .logs-textarea { height: 320px; padding: var(--sp-6); font-size: var(--fs-caption2); }
    /* Centred at its own width, not stretched: a ghost button has no fill, so
       a full-width one is a centred label floating in empty space. */
    .logs-actions { justify-content: center; }
}


/* ==========================================================================
   29. MOTION
   Everything in this section is a state or an entrance that belongs to no
   single component.  Component-owned motion lives with its component: the
   segmented thumb in §11, the switch in §12, the progress fill in §19, the
   toast in §21, the uploading box in §23, the in-flight hairline in §17.

   Three rules govern the whole sheet and are worth stating once:
     · transform and opacity only.  They skip layout and paint;
       width/height/padding/top/left trigger all three.  The two places this
       sheet still changes a colour — a row press and the drop zone — are
       colour transitions, which the easing table sends to the built-in
       `ease` on purpose;
     · the press phase SNAPS and the recovery EASES.  The system must
       acknowledge on the frame the finger lands; it may take its time
       coming back.  Symmetric press timing is a finding;
     · every duration and every curve comes from the token block in §1.  No
       value in this section was invented here.
   -------------------------------------------------------------------------- */

/* --------------------------------------------------------------------------
   29a. Alpine's inline transitions, pulled onto the tokens
   x-transition writes `transition-duration: .15s; transition-timing-function:
   cubic-bezier(0.4, 0, 0.2, 1)` INLINE on the element — the Material standard
   curve, i.e. precisely the one never to invent.  Inline styles can only be
   reached with !important.
   The override is scoped inside `prefers-reduced-motion: no-preference` for
   one specific reason: a naked !important on transition-duration would
   outrank any reduced-motion rule (a class is (0,0,1,0), the old blanket `*`
   was (0,0,0,0)), and reduced motion would silently stop working.  That is
   also why §3 neutralises the TOKENS rather than zeroing durations.
   -------------------------------------------------------------------------- */
@media (prefers-reduced-motion: no-preference) {
    [role="tabpanel"],
    .uploading-overlay,
    .settings-saved { transition-timing-function: var(--ease-out) !important; }

    /* Only the CURVE is corrected.  No duration is overridden here, and the
       reason is that an !important duration can only be symmetric: it reaches
       Alpine's leave phase as well as its enter phase.
       Alpine's own defaults are 150ms in and 75ms out — already inside
       budget and already asymmetric in the right direction — and forcing a
       single number onto both made every exit in the app SLOWER.
       It mattered most on the tab panels.  The panels are siblings in normal
       flow, not stacked: while one fades out the other is already laid out
       BELOW it, off-screen (measured: panel-upload at y=1218 in a 900px
       viewport while panel-queue fades at y=85).  So a tab switch is not a
       crossfade at all — it is a fade to blank, then a cut — and every ms
       added to the leave lengthens the blank window on the app's most
       repeated, arrow-key-reachable action.  75ms is the right number and
       Alpine already ships it.
       .settings-saved was always absent for the same reason; it takes its
       durations from x-transition modifiers in the template (240ms in,
       180ms out — exits snap). */
}

/* A2.  The tab panels carry BOTH x-transition.opacity and .fade-in: two
   crossfades for one event.  Alpine's is kept (retuned above) and the
   duplicate keyframe is switched off.  Switching tabs is core navigation
   seen tens of times a session and is reachable by arrow key, so a single
   180ms opacity crossfade is the ceiling — no slide, no direction-aware
   transition, nothing the keyboard path would feel gated by. */
[role="tabpanel"].fade-in { animation: none; }

/* --------------------------------------------------------------------------
   29b. Press feedback — A1
   CSS :active IS pointer-down: the UA sets it the instant the pointer goes
   down, for mouse and touch alike, and clears it on up or on drag-away —
   which gives cancel-by-dragging-away for free.  A pointerdown listener
   would be a JS reimplementation of a state the platform already publishes,
   so app.js is untouched.
   Down is a literal 100ms (Apple's own value); up is --dur-press (160ms),
   the top of the 100-160ms press band.  Only `transform` transitions: the
   background swap on .btn stays instant, because Apple's own web button
   swaps its colour on the same frame and an eased button colour is the
   loudest non-Apple signal there is.
   -------------------------------------------------------------------------- */
.btn,
.tab-btn,
.provider-btn,
.file-remove-btn {
    transition: transform var(--dur-press) var(--ease-out);
}
/* .upload-zone's transform sits in its own rule in §29d, which also carries
   the drop-state colours. */

.btn:active:not(:disabled),
.tab-btn:active,
.provider-btn:active,
.file-remove-btn:active:not(:disabled),
.upload-zone:active {
    transform: scale(var(--press-scale));
    transition-duration: var(--dur-press-down);
}

/* scale() is proportional, so one scalar cannot serve a 94px button and a
   936px drop zone: .97 there is 14px of travel per edge, nearly twice
   --travel, on the largest surface on the screen.  The band the skills give
   is written for buttons; big surfaces press on --press-scale-lg instead. */
.upload-zone:active { transform: scale(var(--press-scale-lg)); }

/* A segment's label sits ON TOP of .seg-thumb, so scaling the label alone
   shrinks the text inside a rigid pill — the one press in the app where the
   thing that looks pressable and the thing that moves were different
   objects.  The thumb squashes with it, on the same 100ms.
   Scoped to the ACTIVE segment: pressing an inactive one has no thumb behind
   it (activation happens on click, i.e. after the press), and squashing a
   thumb sitting somewhere else would be a lie. */
.tab-bar:has(> .tab-btn.active:active) > .seg-thumb,
.provider-switch:has(> .provider-btn-active:active) > .seg-thumb {
    scale: var(--press-scale);
    transition: transform var(--dur-move)       var(--ease-in-out),
                scale     var(--dur-press-down) var(--ease-out);
}

/* .model-select is NOT in that list although it is a pressable control: it
   is a transparent button stretched over the whole row, so scaling it would
   scale nothing the user can see, and the row it covers already answers the
   press with a fill (29c).  One press, one acknowledgement. */

/* Two controls are deliberately excluded from the scale.
   .link-btn is an inline link inside running text — scaling it would shift
   the sentence around it — so it gets the touch equivalent of its hover
   instead.  Thickening the rule costs no contrast; dimming it would
   (--link at 60% over white measures 2.6:1).
   .checkbox-label / .radio-label are full-width grouped rows, and iOS
   highlights a row with a fill, never a scale — see 29c. */
.link-btn:active { text-decoration-thickness: 2px; }

/* --------------------------------------------------------------------------
   29c. Grouped-row press — A14
   How iOS highlights a list row, and the reason those rows get no scale.
   Asymmetric in the same direction as everything else: the fill lands
   instantly, it fades back over 150ms.
   :active propagates to ancestors, so .model-row catches the press on the
   .model-select stretched across it without a :has().
   -------------------------------------------------------------------------- */
.model-row,
.checkbox-label,
.radio-label {
    transition: background-color var(--dur-colour) ease;
}
.radio-label:active:not(:has(input:disabled)),
/* An earlier revision tried to spare the SWITCH row this tint, on the theory
   that the knob widening 27->35px is already that press's feedback, and to
   keep it only for a press on the row's label.  That distinction is not
   expressible: activating a <label> puts :active on the control it labels,
   so `:has(input:active)` is true wherever on the 936px row the press lands,
   and `:not(:has(input:active))` could never match — measured, the row stayed
   at rgba(0,0,0,0) for a press 911px from the switch, while the .radio-label
   beside it (no such guard) tinted correctly.  With the tap highlight removed
   (§5) that left the largest grouped list in the app with no feedback at the
   point of contact.  Both grouped-row types now behave the same way. */
.checkbox-label:active:not(:has(input:disabled)) {
    background-color: var(--fill-4);
    transition-duration: 0s;
}

/* .model-row presses one rung DEEPER than the others.  Its hover is already
   --fill-4, so re-using --fill-4 for the press made the press a no-op with a
   pointer — measured identical rgba(116,116,128,.08) at rest-hover-press —
   and choosing a model is the app's one primary action on that screen.
   (This is also why .model-select is not in the press-scale list above: the
   row under it answers the press, and now it really does.) */
.model-row:active {
    background-color: var(--fill-3);
    transition-duration: 0s;
}

/* --------------------------------------------------------------------------
   29d. The drop zone accepting a file — A8
   ON is instant, deliberately: the user is mid-gesture with a file over the
   zone, the acknowledgement has to be on the event, and any easing here
   reads as latency.  OFF eases over 150ms.
   Colour only — the rule already uses an outline rather than a border
   precisely so nothing reflows on dragover.
   -------------------------------------------------------------------------- */
/* outline-color is deliberately NOT in this list.  It bought nothing —
   outline-style is not transitionable, so dropping .dragover removes the
   outline in one frame and the colour tween is never painted — while costing
   the focus ring: listing it made :focus-visible ease from the inherited
   currentColor to the accent over 150ms, i.e. a near-black (light) or
   near-white (dark) flash on the one signal keyboard users depend on.
   Measured: rgb(29,29,31) -> rgb(12,88,91) -> rgb(0,131,135) over ~150ms.
   §29g's own rule is that a focus ring is present on the frame focus lands. */
.upload-zone {
    transition: transform        var(--dur-press)  var(--ease-out),
                background-color var(--dur-colour) ease;
}
.upload-zone.dragover { transition-duration: 0s; }

/* The press scale never fights the drop state: you cannot be pressing a
   button and dropping a file onto it at the same moment. */
.upload-zone.dragover:active { transform: none; }

/* --------------------------------------------------------------------------
   29e. Entrances — A4 (a job card) and A5 (a file row)
   @starting-style, so an Alpine x-for insert animates with zero JavaScript.
   Measured in the browser: it fires for a newly inserted keyed node, and it
   does NOT re-fire on the 30s array-identity tick, on an SSE mergeJobs()
   refresh, or on a status change — so a live card never flickers.

   IT DOES RE-FIRE WHEN AN ANCESTOR GOES display:none -> RENDERED, which is
   exactly what x-show does to a tab panel.  That measurement is why A4 and
   A5 are deliberately NOT the same recipe:

     - A JOB CARD gets NO entrance.  It had a fade, and the fade carried no
       information in its dominant case: app.js calls openTab('queue') after
       a successful upload, so a new card ALWAYS arrives on a panel reveal,
       and on a reveal @starting-style fires for every card at once — six
       cards fading identically says nothing about which one is new.  What it
       did instead was multiply with the panel's own crossfade: measured, the
       panel was at 0.37 while its cards were at 0.52 (composite 0.19) and
       the counter strip beside them sat at 1.00, so the list ghosted behind
       the rest of its own panel on the app's most repeated action.  The
       panel crossfade narrates a tab return; the counter strip and the
       status badge narrate a new job.  CSS cannot tell the two apart, and of
       the two, the tab return is the one that happens.
     - A FILE ROW keeps the fade, and loses the rise and the stagger.  Its
       dominant case really is "the user just dropped these" — picking files
       happens on the tab you are already looking at — so the fade earns its
       place.  The travel and the stagger did not: the banner above the list
       sends a user with no API key to Ustawienia and back, and on the way
       back the whole staged list re-developed row by row.  The stagger was
       also wrong on its own terms.  It was capped at four rows, and a cap
       written as three :nth-of-type delays does not clamp — it RESETS, so
       row 5 onwards ran at 0ms and overtook rows 2-4.  Measured on a
       six-file drop at 102ms: opacity 0.89, 0.64, 0.06, 0.00, 0.89, 0.89 —
       rows 1, 5 and 6 together, a blank gap where row 4 should be.  A ramp
       that wraps back to zero reads as a rendering glitch, not as a list
       arriving, which is the opposite of what the stagger was for.
   -------------------------------------------------------------------------- */
.job-card {
    /* A13 rides this rule and this curve: the accent hairline fades up from
       transparent as the card becomes the in-flight one.  There is no
       opacity transition here any more — see above; without a matching
       @starting-style it would be dead weight. */
    transition: border-color var(--dur-enter) var(--ease-out);
}
.file-item {
    opacity: 1;
    transition: opacity var(--dur-enter) var(--ease-out);
}

@starting-style {
    .file-item { opacity: 0; }
}

/* --------------------------------------------------------------------------
   29f. The settings-saved confirmation — A11
   Alpine's own x-transition does the opacity and the scale(.96) -> 1; the
   template names 240ms in and 180ms out (exits snap) and §29a corrects the
   curve.  scale(.96), never scale(0).
   The checkmark deliberately does NOT draw itself in: the only purpose word
   available for a self-drawing tick is delight, delight is a rare /
   first-time budget, and saving settings is occasional.
   -------------------------------------------------------------------------- */

/* --------------------------------------------------------------------------
   29g. Deliberately NOT animated
   The full reasoning, with the gate question that killed each one, is in
   DESIGN.md §30.4 — it is not repeated here.  What a reader of THIS FILE
   needs is the short list, so an omission is not "fixed" by accident:
     a job card LEAVING (Alpine removes an x-for node synchronously, so CSS
     has nothing left to transition — a stated asymmetry, not an oversight);
     badges (animation-name must compute to `none`, §18, and the suite
     asserts it); the counter strip; the models table; focus rings (present
     on the frame focus lands); the header's backdrop-filter; any transform
     on :hover, anywhere; skeletons; the empty state; the login screen; the
     wordmark; page/route transitions.
   -------------------------------------------------------------------------- */


/* ==========================================================================
   30. TOUCH INPUT SIZING
   Last in the file on purpose: it has to win over §28.8's phone block, which
   drops the log box to 12px.
   iOS Safari zooms the page when focus lands on a field under 16px and does
   not zoom back out on blur.  .logs-textarea is the only field in the app
   below 16px — every other one is already 17px (--fs-body).  The CAUSE is
   fixed here; user-scalable and maximum-scale stay untouched, because
   disabling zoom is an accessibility failure, not a fix.
   ========================================================================== */

@media (pointer: coarse) {
    .logs-textarea { font-size: 16px; }
}
