/* The design token contract. Component rules draw from these rather than
   restating literals: a value that expresses depth, roundness, timing, or
   surface tint belongs here, and only a dimension specific to one element
   (a 40px grab handle, a 26px tab glyph) stays inline.

   Seeded from the values the stylesheet already used, so introducing the scales
   changed no rendered pixel. Retune a scale here and every surface follows —
   which is the whole point, and also the trap: a value nudged here moves things
   far from where you are looking. */
:root {
    color-scheme: light dark;
    --bg: #1c1e24;
    --surface: #282a2d;
    --surface-2: #33363d;
    --ink: #e9e6df;
    --muted: #9aa0aa;
    --accent: #e5a00d;
    --accent-dim: #b9800c;
    --border: #34373d;

    /* Elevation. Five tiers, keyed to the z-index ladder documented in the Modal
       section (pinned controls 30, the topbar 35, tab bar 40, trays 50, dialogs 55,
       viewer 60, toast 80, tooltip 100, and the progress overlay at 100 — except
       inside a tray, where it is deliberately demoted to 5 so it covers the tray
       rather than the screen). A surface's tier MUST agree with where it sits on
       that ladder, or depth and stacking tell the reader different stories.

       The topbar's 35 is the one rung that exists to clear a specific neighbour
       rather than to name a class of surface. It is not the bar that needs it: the
       bar never overlaps anything. It is the overflow menu inside it, which opens
       downward over the content region where .gallery-head is pinned at 30 — and
       because `backdrop-filter` makes the header a stacking context, the menu's own
       z-index is resolved against the header and cannot reach the page. Raising the
       header is the only thing that lifts the menu. Desktop-only in effect, so it
       never shares a screen with the tab bar or a tray above it.

       DesignTokenContractTest::testElevationAgreesWithTheStackingLadder asserts
       it, because picking a shadow by eye is exactly how the two drift apart.

       Know what that test does and does not cover before trusting it. It pins the
       tiers in order against each other — a dialog above a panel, a tooltip above
       a dialog, the progress box level with a dialog — but it never reads a
       z-index out of this file; the ladder above is transcribed into the test's
       own assertions rather than derived from it. Change a z-index without
       changing a shadow and nothing fails. That is a deliberate limit, not an
       oversight: a shadow and the z-index it should agree with almost never sit
       on the same element (.modal stacks, .modal__panel casts), so the pairs
       would have to be hand-written anyway, and only four surfaces carry both.
       Moving something on the ladder means re-reading the tiers by hand.

       Two layers each: a tight contact shadow that reads as the edge lifting off
       the page, and a wide ambient one that reads as the distance. A single
       large blur has no contact edge and comes out as fog against a background
       this dark. */
    --elev-1: 0 1px 2px rgba(0, 0, 0, 0.28), 0 2px 8px rgba(0, 0, 0, 0.22);
    --elev-2: 0 2px 4px rgba(0, 0, 0, 0.32), 0 6px 16px rgba(0, 0, 0, 0.28);
    --elev-3: 0 4px 8px rgba(0, 0, 0, 0.36), 0 12px 32px rgba(0, 0, 0, 0.34);
    --elev-4: 0 6px 12px rgba(0, 0, 0, 0.40), 0 20px 48px rgba(0, 0, 0, 0.42);
    --elev-5: 0 8px 16px rgba(0, 0, 0, 0.44), 0 24px 60px rgba(0, 0, 0, 0.48);

    /* Corner radii, seeded from the six values already in the file: full-screen
       images (6), controls (8), cards and rows (10), panels and dialogs (12),
       the top corners of a tray (16), and pills. */
    --radius-xs: 6px;
    --radius-sm: 8px;
    --radius-md: 10px;
    --radius-lg: 12px;
    --radius-xl: 16px;
    --radius-pill: 999px;

    /* Space between stacked elements. Five steps, and the last dimension family
       in this file to become a scale — depth, roundness and timing were
       consolidated long before the space between things was, which is why the
       settings screen accumulated nine different vertical values before anyone
       noticed they had no common origin.

       Read them as a nesting rather than a list. The rhythm is what tells a
       reader where one question ends and the next begins, so the steps have to
       stay distinguishable from each other at a glance:

         --space-2xs   a control and its own text  (checkbox to its label)
         --space-xs    the parts of one field      (label, control, hint)
         --space-sm    rows of a list of controls
         --space-md    one field to the next
         --space-lg    one section to the next, and one page block to the next

       Seeded from values the file already used, so nothing about a field's
       internal rhythm moved when they landed. One value changed: section to
       section went from 18px to 28px, which is .panel's own padding — the space
       between two panels now equals the space inside their edges, and the
       sections read as separate cards instead of a stack that happens to have
       borders.

       These govern the space BETWEEN stacked elements. Padding inside a
       component is that component's own dimension and does not come from here;
       .panel's 28px and a control's 9px 12px are not on this scale even where
       the number coincides.

       The mechanism matters as much as the values. A container states one of
       these as its `gap` and its children carry no vertical margins at all. A
       gap applies only BETWEEN children, so there is no first-child or last-child
       case to special-case — which is the bug this scale replaced. `.field:first-
       of-type { margin-top: 0 }` read as "the first field" and meant "the first
       <div>", so a section that opened with a paragraph gave its first control
       no space at all. FormRhythmTest asserts that selector never comes back. */
    --space-2xs: 4px;
    --space-xs: 6px;
    --space-sm: 10px;
    --space-md: 20px;
    --space-lg: 28px;

    /* Motion. Four durations, consolidated from the seven hand-picked values the
       file used to carry. `--dur-exit` is deliberately the fast one: leaving
       should feel quicker than arriving, and an exit that lingers reads as the
       interface being slow to obey. */
    --dur-fast: 0.12s;
    --dur-base: 0.18s;
    --dur-slow: 0.3s;
    --dur-fade: 0.45s;
    --dur-exit: var(--dur-fast);

    /* Progress tempos, deliberately NOT on the duration scale above. That scale
       sizes feedback on an action; these are the pulse of a thing that is
       waiting, tuned to read as patient rather than urgent, and nothing about
       them should follow when the scale is retuned.

       They are tokens rather than literals for one reason: the reduced-motion
       rule at the end of this file collapses every animation in the application
       and then exempts these two, so each tempo is named twice. A number written
       twice is a number that will disagree with itself. */
    --tempo-spin: 0.8s;
    --tempo-shimmer: 1.3s;

    /* Standard for state changes that reverse (hover on, hover off); entrance
       decelerates into place; exit accelerates away. */
    --ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
    --ease-entrance: cubic-bezier(0, 0, 0.2, 1);
    --ease-exit: cubic-bezier(0.4, 0, 1, 1);

    /* Translucency, for chrome that floats over content. Two tints: the lighter
       one for bars content passes *behind* (header, pinned controls, tab bar),
       the heavier one for the backdrop behind a dialog or tray, which has to
       push the whole page back.

       The tint alone carries legibility, not the blur. Blur redistributes
       luminance but guarantees no contrast ratio: a large flat white region on a
       minimalist poster blurs to itself, so the worst case behind a tab label is
       simply white. --chrome-tint's alpha is therefore solved for, not chosen by
       eye — 0.85 is where --ink reaches 8.35:1 and --accent 4.64:1 against that
       white, both clear of AA. Saturation is lifted with the blur so the greys
       behind do not go flat and lifeless.

       --muted does NOT survive that test: at 154,160,170 it lands on 3.95:1,
       under the 4.5 body text needs, and raising the tint far enough to rescue it
       (0.90+) leaves a bar too opaque to be worth calling glass. Text on chrome
       takes --muted-on-chrome instead, which is the same grey lifted until it
       reaches 5.63:1 there. Anything muted sitting on a glass surface must use
       it; --muted stays correct everywhere else, where the surface behind is
       known.

       Every rule using these MUST have a matching entry in the
       `@supports not (backdrop-filter: ...)` block at the end of this file. */
    --chrome-tint: rgba(28, 30, 36, 0.85);
    --chrome-blur: blur(18px) saturate(140%);
    --muted-on-chrome: #b9bfc9;
    --backdrop-tint: rgba(9, 10, 13, 0.62);
    --backdrop-blur: blur(6px) saturate(110%);

}

* {
    box-sizing: border-box;
    /* No blue/grey native tap flash on touch — it clashes with the theme; taps
       get their own themed :active feedback instead. */
    -webkit-tap-highlight-color: transparent;
}

/* Flat, deliberately, and this is the second attempt at it.

   A graded page was tried — a faint warm radial from above the top edge over a
   vertical fall — and it cost more than it bought. The gallery's pinned controls
   sit on this background and must be opaque, so they have to match it exactly or
   they read as a rectangle laid on top of the page. Against a flat fill that is
   free. Against a gradient it is not: a flat bar is visibly a different shade,
   and painting the bar the same gradient does not fix it either, because
   `background-attachment: fixed` is unreliable on a `position: sticky` element —
   browsers composite sticky elements into their own layer, and the background
   then resolves against that layer rather than the viewport, squeezing the whole
   gradient into the bar's height.

   Both were tried on a real screen and both looked wrong. The grade was never
   worth that: it was decoration, and the pinned bar is the frame the gallery is
   read through. So the page is one colour, the bar is the same colour, and the
   seam does not exist to be chased.

   --bg is also carried by ManifestController's background_color and theme_color,
   the theme-color meta tag, the inline header logo, logo.svg and favicon.svg. One
   flat value keeps all six in agreement with no arithmetic. */
body {
    margin: 0;
    font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
    line-height: 1.6;
    background: var(--bg);
    color: var(--ink);
}

/* Glass, so the page passes behind the header rather than stopping at a flat
   band. The hairline stays: it is what gives the translucent bar an edge, and
   without one the header dissolves into the content on a light poster.

   Not `position: sticky`. The header scrolls away as it always has — glassing it
   is about the page showing through as content passes under it on its way out,
   and about matching the pinned controls below it, which do stay. Pinning the
   header too would be a behaviour change, and nothing asked for one. */
/* `position: relative; z-index: 35` is the header's, but it is the overflow menu
   that needs it, and the reason is worth stating because the obvious fix does not
   work.

   `backdrop-filter` does two things to this element. The documented one is that it
   becomes a containing block for fixed descendants — that is what teleported the
   phone tray. The second is that it becomes a STACKING CONTEXT, and that one is
   what traps the menu: every z-index inside the header is resolved against the
   header, not the page. So the panel's own z-index can be any number at all and
   the whole header still paints where an unpositioned element paints, which is
   below .gallery-head's pinned 30. The menu came out behind the gallery's controls
   with a perfectly correct-looking z-index on it.

   Raising the header is what actually lifts the menu. `relative` keeps it in flow,
   so the header still scrolls away with the page and nothing moves; it is here
   only because z-index does nothing to a statically positioned element.

   Safe against the pinned controls it now outranks: the header sits above them in
   flow with the container's padding between, and .gallery-head only sticks once
   the header has scrolled entirely off the top, so the two boxes never share
   space. HeaderOverflowMenuTest asserts the comparison this rule exists to win. */
.topbar {
    position: relative;
    z-index: 35;
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 14px 24px;
    background: var(--chrome-tint);
    -webkit-backdrop-filter: var(--chrome-blur);
    backdrop-filter: var(--chrome-blur);
    border-bottom: 1px solid var(--border);
}

/* Desktop follows the project's landing page (getmarquee.now): the bar spans
   the viewport, but its contents line up with the 960px content column. The
   gutter is padding rather than a max-width wrapper so the markup stays as it
   is: the `max()` collapses to a plain 24px once the viewport is narrower than
   the column, which is what `.container` does there too. Deliberately a
   `min-width` query — the complement of the `@media (max-width: 640px)` block at
   the END of this file.

   This block used to also restate `background: var(--bg)`, because the base rule
   raised the phone header onto --surface and the desktop bar is specified as
   page-coloured chrome rather than a panel. --chrome-tint is --bg carrying an
   alpha, so the base rule now gives both widths the page colour and the override
   has nothing left to say. Do not reintroduce a background here: it would land
   on top of the glass and turn the desktop header opaque, which is the one thing
   this arrangement is for. */
@media (min-width: 641px) {
    .topbar {
        padding-inline: max(24px, calc((100% - 960px) / 2 + 24px));
    }
}

.brand {
    display: inline-flex;
    align-items: center;
    gap: 12px;
    font-size: 1.35rem;
    font-weight: 700;
    letter-spacing: -0.01em;
    color: var(--accent);
    text-decoration: none;
}

.brand__logo {
    display: block;
    width: 36px;
    height: 36px;
    flex: 0 0 auto;
}

.topbar a {
    color: var(--muted-on-chrome);
    text-decoration: none;
}

.topnav {
    display: flex;
    align-items: center;
}

/* The secondary actions, in the header. Ghost buttons rather than filled ones:
   five bordered controls would read as a toolbar bolted to the header, and the
   header is specified as page-coloured chrome. The surface arrives on hover. */
.topnav__desktop {
    display: flex;
    align-items: center;
    gap: 4px;
}

.topnav__desktop .nav-item {
    display: inline-flex;
    align-items: center;
    gap: 8px;
    padding: 8px 10px;
    border-color: transparent;
    background: none;
    color: var(--muted-on-chrome);
    font-size: 0.92rem;
    white-space: nowrap;
}

/* .topbar a is (0,1,1) and would otherwise beat .btn's own colour, so the hover
   and focus states are stated at matching weight. */
.topbar .topnav__desktop .nav-item:hover,
.topbar .topnav__desktop .nav-item:focus-visible {
    background: var(--surface-2);
    color: var(--ink);
}

/* The destination already being viewed. Rendered as a span rather than a link,
   so this only has to look settled rather than clickable — the accent echoes how
   .tab--active marks the current category. */
.topbar .topnav__desktop .nav-item--current,
.topbar .topnav__desktop .nav-item--current:hover {
    background: var(--surface);
    border-color: var(--border);
    color: var(--accent);
    cursor: default;
}

.nav-ico {
    display: inline-flex;
    align-items: center;
    flex: 0 0 auto;
}

/* The header's overflow menu: Settings, Support Development and Log out, behind
   one control. The bar's contents are aligned to the 960px content column and
   share it with a brand of unbounded width, so six labelled actions plus the
   connection status did not reliably fit — see _nav_macros.html.twig for which
   links went behind it and why. */
.navmenu {
    position: relative;
    display: inline-flex;
}

/* Icon-only at every width, so it needs the square padding the bar's items only
   take in the narrow band rather than the label-shaped padding they wear beside
   it. */
.topnav__desktop .navmenu__trigger {
    padding: 8px;
    cursor: pointer;
}

/* Absolute, never fixed. `.topbar` carries `backdrop-filter`, which makes it a
   containing block for fixed-position descendants — the rule that cost the phone
   tray a teleport to <body> (see partials/_menu.html.twig). A fixed panel here
   would resolve against the header's own box instead of the viewport and land in
   the wrong place; an absolute one resolves against .navmenu and is unaffected.

   The z-index here only orders the panel against its siblings inside the header.
   What lifts it over the gallery's pinned controls is the z-index on .topbar —
   `backdrop-filter` makes the header a stacking context, so nothing in here is
   resolved against the page. Raising this number does nothing on its own; see the
   .topbar rule. */
.navmenu__panel {
    position: absolute;
    top: calc(100% + 6px);
    right: 0;
    z-index: 35;
    display: flex;
    flex-direction: column;
    gap: 2px;
    min-width: 200px;
    padding: 6px;
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: var(--radius-lg);
    /* The header-menu tier: a popover with shape and edges, so it casts downward
       like the dialogs it sits just below on the ladder. */
    box-shadow: var(--elev-3);
}

/* Rows, not a row. The panel is a vertical list like the phone tray, which is why
   it borrows .menu__body's full-label presentation rather than restating it — see
   the .menu__body rules below. What it does not inherit is the tray's spacing, the
   tray being a full-screen surface and this a 200px popover. */
.navmenu__panel .nav-item {
    display: flex;
    width: 100%;
    justify-content: flex-start;
    gap: 10px;
    padding: 9px 10px;
    border-color: transparent;
    background: none;
    color: var(--ink);
    font-size: 0.92rem;
    text-align: left;
    white-space: nowrap;
}

.topbar .navmenu__panel .nav-item:hover,
.topbar .navmenu__panel .nav-item:focus-visible {
    background: var(--surface-2);
    color: var(--ink);
}

.topbar .navmenu__panel .nav-item--current,
.topbar .navmenu__panel .nav-item--current:hover {
    background: var(--surface-2);
    color: var(--accent);
    cursor: default;
}

/* The Plex connection, carried in the header as a state rather than a
   destination.

   Deliberately not a .nav-item. Everything beside it is a ghost button with a
   glyph, and wearing that shape made a reading look like a sixth place to go —
   which is the whole thing this replaced. So: no button chrome, no glyph, no
   border, a size down, and a hairline holding it apart from the actions. It is
   still a link, because disconnecting lives behind it and nothing else points
   there, but it should read as a label that happens to be clickable rather than
   a control competing with the four beside it. */
.conn-status {
    display: inline-flex;
    align-items: center;
    gap: 8px;
    min-height: 32px;
    margin-left: 6px;
    padding: 0 2px 0 14px;
    border-left: 1px solid var(--border);
    font-size: 0.85rem;
    white-space: nowrap;
}

/* .topbar a is (0,1,1) and would otherwise beat this, so the resting and hover
   colours are stated at matching weight — the same reason .nav-item does. */
.topbar .conn-status {
    color: var(--muted-on-chrome);
}

.topbar a.conn-status:hover .conn-status__name,
.topbar a.conn-status:focus-visible .conn-status__name {
    color: var(--ink);
}

/* Already on that screen: nothing to travel to, so it only has to look settled.
   No accent — this is a status, and tinting it would read as an alert. */
.conn-status--current {
    cursor: default;
}

/* The indicator itself. The halo is what makes it a status light rather than a
   bullet, and it is what survives the narrow band once the name drops out.
   Colour is never the only signal: the accessible name says "connected" or "not
   connected" outright, and the tooltip repeats it. */
.conn-dot {
    width: 8px;
    height: 8px;
    flex: 0 0 auto;
    border-radius: 50%;
}

/* The two halos below are the file's only `box-shadow` outside the elevation
   scale, and deliberately so: a spread-only ring at zero offset is a status
   light's glow, not depth. Reaching for --elev-* here would put the dot on the
   layering ladder, where it does not belong. */
.conn-dot--ok {
    background: #3fb950;
    box-shadow: 0 0 0 3px rgba(63, 185, 80, 0.18);
}

/* Amber rather than red: disconnected is a state to resolve, not a failure that
   has just happened, and the gate is already sending the user to fix it. */
.conn-dot--off {
    background: #d29922;
    box-shadow: 0 0 0 3px rgba(210, 153, 34, 0.18);
}

/* The tray is a vertical list, so the hairline turns to sit above it — a left
   border on a full-width row would point at nothing.

   Colour and text-decoration are stated here rather than inherited, and that is
   the whole reason this rule grew. The tray used to be markup inside <header>, so
   `.topbar a` reached it and gave every link in it the muted colour and no
   underline. The tray is teleported to <body> now — it had to be, because the
   glass header became a containing block for the fixed tray inside it — and the
   moment it moved, this link fell out of that selector's reach and reverted to
   the browser's default: blue and underlined, in the middle of a themed tray.
   Nothing else in the tray was affected, because every other link there is a
   .btn and carries its own colour; this is the one bare anchor.

   --muted rather than --muted-on-chrome: the tray is an opaque surface, so the
   lifted grey the glass bars need would just read as brighter than the rows
   above it. */
.menu__body .conn-status {
    margin: 6px 0 0;
    padding: 12px 0 2px;
    border-left: 0;
    border-top: 1px solid var(--border);
    color: var(--muted);
    text-decoration: none;
}

.menu__body a.conn-status:hover .conn-status__name,
.menu__body a.conn-status:focus-visible .conn-status__name {
    color: var(--ink);
}

/* In the narrow band the name goes with the nav labels and the dot stands alone,
   which is the reason the dot carries the halo. The tooltip and the accessible
   name still say which state it is in. */
@media (min-width: 641px) and (max-width: 900px) {
    .topnav__desktop .conn-status__name {
        display: none;
    }

    .topnav__desktop .conn-status {
        padding-right: 0;
        gap: 0;
    }
}

/* Two labels are rendered for every nav item; which one shows depends on where
   it is. The header is a single row sharing its width with a brand of unbounded
   length (SITE_TITLE is user-configurable), so it takes the short form; the tray
   has a full-width row per item and takes the full name. */
.nav-label {
    display: none;
}

.nav-label--short {
    display: inline;
}

.menu__body .nav-label {
    display: inline;
}

.menu__body .nav-label--short {
    display: none;
}

/* Between the phone breakpoint and the width where labelled actions still fit
   beside the brand, the header keeps the icons and drops the labels. Even the
   short forms come to ~656px with the brand, against the 652px a 700px viewport
   leaves inside the content column. The custom tooltip names each action here —
   see data-tooltip-collapsed in app.js, which is what keeps it silent once a
   label is back on screen.

   The child combinator confines this to the bar. It is not tidiness: the overflow
   panel is a descendant of .topnav__desktop, so a descendant selector here would
   strip the labels off the menu's rows in this band — and that menu is a list of
   full names at every width, exactly as the phone tray is. The ⋯ control itself is
   excluded for the same reason it needs no exclusion in spirit: it is icon-only
   already and has no label to drop. */
@media (min-width: 641px) and (max-width: 900px) {
    .topnav__desktop > .nav-item .nav-label {
        display: none;
    }

    .topnav__desktop > .nav-item {
        padding: 8px;
    }
}

/* Mobile menu button: hidden on desktop, revealed on narrow screens (see the
   max-width media query below). The desktop keeps the plain Log out link. */
.menu-btn {
    display: none;
    align-items: center;
    justify-content: center;
    padding: 6px;
    border: none;
    background: none;
    color: var(--ink);
    cursor: pointer;
}

.container {
    max-width: 960px;
    margin: 0 auto;
    padding: 32px 24px;
}

.footer {
    max-width: 960px;
    margin: 24px auto 0;
    padding: 20px 24px 32px;
    color: var(--muted);
    font-size: 0.8rem;
    text-align: center;
}

/* The product name in either footer links to the project site. Both are muted
   credit lines, so the link inherits their color instead of reading as a
   default browser link. */
.footer a,
.menu__footer a {
    color: inherit;
    text-decoration: none;
}

.footer a:hover,
.menu__footer a:hover {
    text-decoration: underline;
}

.footer a:focus-visible,
.menu__footer a:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
    /* Not on the radius scale: this rounds the focus ring around a run of text,
       not a surface. The scale's smallest step would bow it visibly. */
    border-radius: 2px;
}

/* Poster provider credit, rendered in both footers from one partial. */
.attribution__label {
    margin: 0 0 10px;
}

.attribution__logos {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    align-items: center;
    gap: 22px;
    margin-bottom: 14px;
}

.attribution__logos a {
    display: inline-flex;
    align-items: center;
    opacity: 0.75;
    transition: opacity var(--dur-base) var(--ease-standard);
}

.attribution__logos a:hover {
    opacity: 1;
    text-decoration: none;
}

/* The four marks have very different aspect ratios (TMDB is a wide wordmark at
   7.6:1, TheTVDB is nearly square at 1.9:1, fanart.tv and TVmaze sit between at
   5:1 and 3.2:1), so each gets the height that makes them read as one row.
   Width follows from the intrinsic ratio. */
.attribution__logo {
    display: block;
    width: auto;
}

.attribution__logo--tmdb {
    height: 15px;
}

.attribution__logo--tvdb {
    height: 32px;
}

.attribution__logo--fanart {
    height: 28px;
}

.attribution__logo--tvmaze {
    height: 22px;
}

/* Optical correction, not a spacing change: the gap either side of TVmaze is the
   same 22px as every other, and it does not look it.

   The mark opens with a full-height bracket rule — about 1.6px wide once scaled —
   followed by internal space before the "T". Measured on the asset, the
   letterforms start 16 of 253 columns in, which is ~4.4px at this height. The eye
   reads a logo as starting where its letters start, so those 4.4px are counted as
   part of the gap rather than part of the mark. fanart.tv makes it worse by
   ending in light grey ".TV": two faint edges facing each other, and a
   metrically-correct gap that reads wide.

   So the run-in is subtracted back. Anything that changes the TVmaze height must
   rescale this with it — the offset is a proportion of the mark, not a constant.
   Do not "fix" it by restoring a uniform gap; uniform is what looked wrong. */
.attribution__logos a:has(.attribution__logo--tvmaze) {
    margin-inline-start: -4px;
}

.panel {
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: var(--radius-lg);
    box-shadow: var(--elev-2);
    padding: 28px;
}

.panel--narrow {
    max-width: 380px;
    margin: 8vh auto 0;
}

h1 {
    margin-top: 0;
    letter-spacing: -0.02em;
}

.form {
    display: grid;
    gap: 6px;
}

.form label {
    font-size: 0.85rem;
    color: var(--muted);
    margin-top: 10px;
}

.form input {
    padding: 10px 12px;
    border-radius: var(--radius-sm);
    border: 1px solid var(--border);
    background: var(--bg);
    color: var(--ink);
}

.form button {
    margin-top: 18px;
    padding: 10px 16px;
    border: none;
    border-radius: var(--radius-sm);
    background: var(--accent);
    color: #1a1300;
    font-weight: 600;
    cursor: pointer;
}

/* Form controls, in one vocabulary.

   The rules above dress the connection panel's single field. These are the
   application's actual form controls, arriving with the settings screen and
   defined here rather than beside it, so the next form does not invent a second
   set. Everything below draws from the token contract: a control added later
   needs no new values.

   Fields are stacked, never side by side. A settings form is read top to bottom
   and answered one question at a time; columns buy density nobody asked for and
   cost a reflow rule at every width. */
/* A field owns the space between its own parts and nothing else. The space
   between one field and the next is the section's, stated once as its gap — see
   .form-section.

   There is deliberately no rule here suppressing the first field's spacing. The
   one that used to sit below this is the reason the scale exists: `.field:first-
   of-type { margin-top: 0 }` reads as "the first field" and means "the first
   <div> among its siblings", so it worked in five sections that opened with their
   heading and silently failed in the one that opened with a paragraph, leaving
   its first checkbox touching the prose above it. A gap has no such case. */
.field {
    display: grid;
    gap: var(--space-xs);
}

.field__label {
    font-size: 0.9rem;
    color: var(--ink);
    font-weight: 500;
}

/* Both the explanation under a field and the standalone notes in a section, so
   the two read as the same voice. */
.field__hint {
    margin: 0;
    font-size: 0.85rem;
    color: var(--muted);
    /* Wide enough to be scanned, short enough to be read. */
    max-width: 60ch;
}

/* Never the only signal that a field was refused: the message says what is
   wrong, aria-describedby ties it to the control, and the control carries
   aria-invalid. The colour is the third telling, not the first. */
.field__error {
    margin: 0;
    font-size: 0.85rem;
    color: #ea9885;
}

.field__row {
    display: flex;
    align-items: center;
    gap: 8px;
}

.field__unit {
    color: var(--muted);
    font-size: 0.9rem;
}

.input {
    padding: 9px 12px;
    border: 1px solid var(--border);
    border-radius: var(--radius-sm);
    background: var(--bg);
    color: var(--ink);
    font: inherit;
    /* Fills its field by default; a number gets a width that suits the value
       rather than the column, below. */
    width: 100%;
    max-width: 420px;
    transition: border-color var(--dur-base) var(--ease-standard),
                background var(--dur-base) var(--ease-standard);
}

@media (hover: hover) {
    .input:hover {
        border-color: #4b515b;
    }
}

.input:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
}

/* Sized to the number it holds — a three-character field stretched across the
   panel reads as a field expecting a sentence. */
.input--number {
    width: 7ch;
}

.select {
    /* The native arrow is drawn by the platform in the platform's colour, which
       against this surface is often near-invisible; supplying our own keeps it
       on the ink colour at every platform. */
    appearance: none;
    padding-right: 34px;
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23e9e6df' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
    background-repeat: no-repeat;
    background-position: right 10px center;
    background-size: 16px;
    cursor: pointer;
}

.field--check {
    /* The label is the control's own text, so the stacked label/control spacing
       above does not apply. */
    gap: var(--space-2xs);
}

.checkbox {
    display: flex;
    align-items: center;
    gap: var(--space-sm);
    cursor: pointer;
}

/* A native checkbox, tinted. Its checked state is the platform's tick — a mark
   rather than a fill — so it survives a reader who cannot separate the accent
   from the surface, and it keeps the keyboard behaviour a div dressed as a
   checkbox has to reimplement. */
.checkbox__input {
    width: 18px;
    height: 18px;
    accent-color: var(--accent);
    cursor: pointer;
    /* Flex would otherwise shrink the box beside a long label. */
    flex: none;
}

.checkbox__input:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
}

.checkbox__label {
    color: var(--ink);
}

/* Many checkboxes at once — what to import, the library exclusions. Columns here
   rather than in .field because the entries are short and a one-per-row list of a
   dozen libraries is mostly whitespace.

   The column gap is the one literal left in this rule. It sets how far apart two
   columns of short labels sit, which is a property of this list and not of the
   page's vertical rhythm; the scale governs stacking. */
.checkbox-list {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
    gap: var(--space-sm) 18px;
}

/* A long form reads as several short ones. Each group is a panel, titled, so the
   eye can skip to the question it came for instead of scanning one column of
   fourteen fields.

   The section is the container that states the space between its questions.
   Every child — the heading, an intro paragraph, a field, a grouped fieldset — is
   one grid item, so all of them are spaced identically and none of them carries a
   margin of its own. That is the whole rule: a child that sets a vertical margin
   here is adding to a gap that is already correct. */
.form-section {
    display: grid;
    gap: var(--space-md);
}

.form-section__title {
    margin: 0;
    font-size: 1.05rem;
    letter-spacing: -0.01em;
}

/* The settings screen and the form inside it, both stacks like .form-section and
   spaced the same way — by a gap, with no margins on the children. The larger
   step here is what separates a section from a section; at --space-md the panels
   read as one long strip rather than as the several short forms they are.

   --space-lg is .panel's own padding, so the space between two panels equals the
   space inside their edges. That is not a coincidence to be tidied away later; it
   is why 28px was the value chosen. */
.settings,
.settings__form {
    display: grid;
    gap: var(--space-lg);
}

/* The children of those two stacks. A browser's default margins on <p> and <h1>
   would otherwise add to the gap, unevenly, since the defaults are em-based and
   these elements are not all the same size. */
.settings > *,
.settings__form > * {
    margin-top: 0;
    margin-bottom: 0;
}

/* .stats is shared with the gallery, where its own margins are correct and must
   stay. It is declared further down this file, so at equal specificity it would
   win against the reset above and put 8px and 16px back on the screen's intro
   line. Named here rather than weakened there. */
.settings > .stats {
    margin: 0;
}

.settings__stale {
    margin: 0;
    padding-left: 20px;
    color: var(--muted);
    font-size: 0.85rem;
}

/* Flashes and inline notices — "Signed in to Plex.", an import that Plex would
   not take, the obsolete-PLEX_TOKEN warning.

   Each severity is one hue used three ways — a glyph, a wash behind, and a
   hairline around — plus the ink for the text. --alert-hue and --alert-ink carry
   them, so a severity is two declarations below rather than five colours to keep
   in agreement, and a new severity is one rule plus its glyph.

   The glyph is what stops these reading as filler. A message identified only by a
   tinted background is a rectangle slightly different from the rectangle above
   it; a mark gives the eye somewhere to land. It also survives being wrong about
   colour — someone who cannot separate the green from the amber still gets a
   distinct shape, and the wording says which it is either way.

   The border is uniform. A heavier bar down the leading edge was tried and cut:
   with the glyph already marking the severity it was a second announcement of the
   same thing, and it broke the box's outline on the one side the eye starts from.

   The glyph is a masked data-URI rather than markup, and that is what keeps this
   to one file. .alert is used on a bare <p> in four templates and on a <div> of
   several <p>s for the obsolete-PLEX_TOKEN notice; adding an <svg> would mean
   touching every call site and giving the multi-paragraph variant a wrapper it
   does not have. `mask-image` also means the glyph is painted with
   `background: currentColor`, so it takes --alert-ink automatically and cannot
   drift from the text beside it.

   Absolutely positioned, not a flex item. Making .alert a flex row would turn
   those sibling <p>s into columns; taking the glyph out of flow leaves every
   variant laid out exactly as it was, and costs only the left padding that makes
   room for it.

   Elevation is deliberately the lowest tier. A flash sits in the page's flow,
   above it but not over it; --elev-2 or higher would have it float like a panel
   it is not. */
.alert {
    --alert-hue: 228, 120, 95;
    --alert-ink: #ea9885;
    /* Drawn at a 24 viewBox with a 1.9 stroke — heavier than the 1.7 the nav and
       tab glyphs use, because this renders at 19px where 1.7 thins to a
       hairline. Same reasoning as .sort__btn .icon. */
    --alert-glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.9' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='9'/%3E%3Cpath d='M12 7.5v5'/%3E%3Cpath d='M12 16.5h.01'/%3E%3C/svg%3E");
    position: relative;
    padding: 13px 15px 13px 44px;
    border: 1px solid rgba(var(--alert-hue), 0.34);
    border-radius: var(--radius-md);
    background: linear-gradient(
        100deg,
        rgba(var(--alert-hue), 0.17),
        rgba(var(--alert-hue), 0.08)
    );
    box-shadow: var(--elev-1);
    color: var(--alert-ink);
    font-weight: 500;
}

.alert::before {
    content: '';
    position: absolute;
    left: 15px;
    /* Pinned to the first line rather than centred on the box: the multi-paragraph
       notice is tall, and a glyph floating in the middle of it points at nothing.
       Aligned against the cap height of the first line, not its box. */
    top: 14px;
    width: 19px;
    height: 19px;
    background: currentColor;
    -webkit-mask: var(--alert-glyph) center / contain no-repeat;
    mask: var(--alert-glyph) center / contain no-repeat;
    pointer-events: none;
}

/* Success flashes fade themselves out (see app.js). The transition is on the
   alert itself rather than a wrapper so the space it occupied collapses with it. */
.alert--fading {
    opacity: 0;
    transition: opacity var(--dur-fade) var(--ease-standard);
}

.alert--success {
    --alert-hue: 116, 192, 132;
    --alert-ink: #74c084;
    --alert-glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.9' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='9'/%3E%3Cpath d='m8.2 12.3 2.6 2.6 5-5.4'/%3E%3C/svg%3E");
}

/* Amber: the operation did what it was asked locally but could not finish —
   a poster replaced on disk that Plex would not take. Neither green nor red is
   true of that, and the difference matters: the new poster is stored and on
   screen, so the message is about Plex, not about the change.

   A triangle rather than the default circle, so the three severities differ in
   silhouette and not only in hue. */
.alert--warning {
    --alert-hue: 228, 181, 95;
    --alert-ink: #e4b55f;
    --alert-glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.9' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10.3 4.3 2.6 17.5a2 2 0 0 0 1.7 3h15.4a2 2 0 0 0 1.7-3L13.7 4.3a2 2 0 0 0-3.4 0Z'/%3E%3Cpath d='M12 9.5v4'/%3E%3Cpath d='M12 17.2h.01'/%3E%3C/svg%3E");
}

/* The Plex connection screen. A single panel with one action, so the spacing is
   set here rather than inherited from the form-heavy layouts elsewhere: a bare
   button sitting directly under a paragraph reads as an afterthought without it.
   The obsolete-PLEX_TOKEN notice is a multi-paragraph alert, which the compact
   single-line padding on .alert is not built for. */
.plex-connection > :first-child {
    margin-top: 0;
}

.plex-connection form,
.plex-connection > .btn {
    margin-top: 20px;
}

/* The left padding is not decoration — it is the room .alert::before stands in.
   This rule used to say `padding: 16px 18px`, and the shorthand quietly reset
   .alert's 44px left padding to 18px while the glyph stayed absolutely
   positioned on top of it, so the first line of every notice on this screen ran
   underneath the icon.

   Written long-hand on purpose. A four-value shorthand states the left padding
   out loud, where `16px 18px` looks complete and is not. The glyph is nudged to
   match: .alert pins it 1px below its own 13px top padding, and this variant
   pads 16px, so both the offset and the 10px gap to the text survive the larger
   box. */
.plex-connection__obsolete {
    margin-top: 20px;
    padding: 16px 18px 16px 47px;
}

.plex-connection__obsolete::before {
    top: 17px;
    left: 18px;
}

.plex-connection__obsolete p {
    margin: 0 0 10px;
}

.plex-connection__obsolete p:last-child {
    margin-bottom: 0;
}

.plex-connection__obsolete code {
    color: inherit;
}

[x-cloak] {
    display: none !important;
}

/* Buttons.

   The base button carried no transition and no hover state at all until the
   surfaces work: every button in the application snapped between appearances, or
   more often had no second appearance to snap to. .card__actions .btn had its
   own hover rules, which is why the poster overlay felt responsive and nothing
   else did.

   Feedback is stated here once, for every caller. Hover is scoped to
   `@media (hover: hover)` — on a touch device the hover state sticks after a tap
   and leaves a button looking pressed until something else is touched. Press
   feedback is not scoped: it is the only feedback a touch device gets, and it is
   what replaces the native tap flash the `*` rule at the top of this file turns
   off. */
.btn {
    display: inline-block;
    padding: 8px 14px;
    border: 1px solid var(--border);
    border-radius: var(--radius-sm);
    background: var(--surface);
    color: var(--ink);
    font: inherit;
    text-decoration: none;
    cursor: pointer;
    transition: background var(--dur-base) var(--ease-standard),
                border-color var(--dur-base) var(--ease-standard),
                color var(--dur-base) var(--ease-standard),
                box-shadow var(--dur-base) var(--ease-standard),
                transform var(--dur-fast) var(--ease-standard);
}

/* The off state is excluded here rather than undone below. A disabled rule
   placed after these would win by specificity accident, and every hover rule
   added later would have to be re-checked against it; excluding it in the
   selector means no hover state is ever declared for a control that cannot be
   used. Suppressing this is the substance of the off state — a fill colour says
   "not now", but feedback under the pointer is the promise that pressing will do
   something, and an inert control that brightens is worse than one that never
   reacted at all. */
@media (hover: hover) {
    .btn:not(:disabled):not([aria-disabled='true']):hover {
        background: var(--surface-2);
        border-color: #4b515b;
    }

    .btn--accent:not(:disabled):not([aria-disabled='true']):hover {
        background: #f0ac1c;
        border-color: #f0ac1c;
    }

    .btn--danger:not(:disabled):not([aria-disabled='true']):hover {
        background: rgba(228, 120, 95, 0.14);
        border-color: rgba(228, 120, 95, 0.7);
    }
}

/* Distinct from hover, and on every device. `translateY` rather than a colour
   change alone: displacement is what reads as a press, and one pixel is enough
   at this size. Transform does not participate in layout, so nothing beside the
   button moves.

   The off state is excluded, and here that is load-bearing rather than tidy: a
   natively disabled button never matches `:active`, but this application marks
   its off controls with `aria-disabled`, and to the browser those are ordinary
   enabled buttons. Without the exclusion a dead button visibly depresses under a
   press — and nothing reveals it until someone presses. */
.btn:not(:disabled):not([aria-disabled='true']):active {
    transform: translateY(1px);
}

/* Deliberately not excluded. An `aria-disabled` control keeps its place in the
   tab order — that is the point of using it — so a keyboard user lands here and
   has to be able to see where they are. */
.btn:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
}

.btn--accent {
    background: var(--accent);
    border-color: var(--accent);
    color: #1a1300;
    font-weight: 600;
}

.btn--danger {
    color: #e4785f;
    border-color: rgba(228, 120, 95, 0.4);
}

/* Off.

   Placed after the variants so it wins over them: an emphasised button has to
   surrender its emphasis while it cannot be used, or a full-width accent bar
   goes on reading as the thing to press. Colour alone would not carry it —
   opacity is what this file uses for translucent chrome and for animating things
   in, so a faded control reads as "arriving" or "behind something" as readily as
   "off". Surface, border, ink, and cursor together say it; none of them alone.

   `:disabled` is kept beside the attribute although nothing in the application
   uses it today. A native `<button disabled>` added later must not arrive
   untreated, which is the whole reason this is stated once instead of at each
   control that happens to need it.

   `pointer-events: none` is deliberately absent. It would stop the click without
   a handler change, which is exactly why it is tempting — and it would also
   silence the tooltip on the Plex Posters tab, which is the only thing that
   explains why that tab is off. Refusing the action belongs in the handler; this
   rule only draws.

   --surface-2 and not --surface, which is the first thing this rule got wrong:
   .panel and .modal__panel are both drawn on --surface, so an off button filled
   with it dissolved into whatever it sat on and left the label floating with no
   button around it at all. The fill has to differ from every surface a button can
   sit on — the page (--bg), a panel or dialog (--surface), and a tray — and
   --surface-2 is the one tier that clears all three. It doubles as the hover fill
   for an ordinary button, which would be a collision worth avoiding except that an
   off button has no hover state to collide with.

   Only border-color is set, deliberately. `.form button` declares `border: none`,
   and a shorthand sets border-style too, so a border declared here would appear on
   the off state and vanish again when the button came alive — two pixels of layout
   shift at the moment the user completes their selection. Where a border exists
   this recolours it; where the context removed it, the fill carries the state. */
.btn:disabled,
.btn[aria-disabled='true'] {
    background: var(--surface-2);
    border-color: var(--border);
    color: var(--muted);
    cursor: not-allowed;
}

.btn--small {
    padding: 4px 10px;
    font-size: 0.85rem;
}

/* The gallery's controls — the category tabs and the search/sort toolbar —
   pinned to the top of the viewport as one block, so every category, the search
   field, and the sort control stay reachable at any scroll position.

   One wrapper rather than two sticky siblings: both stuck to `top: 0` would land
   on top of each other, and offsetting the toolbar by the tabs' rendered height
   would be a constant to keep in sync with their padding and font size. The
   wrapper introduces no such constant.

   `background` is load-bearing, not cosmetic — the same trap the phone toolbar
   documents below. Neither .tabs nor .toolbar has a background of its own, so
   without it the posters scroll straight through the pinned block. No bleed past
   the gutters is needed here (the phone rule needs one): the poster grid sits
   inside .container's padding box, so nothing ever renders beside this block.

   It stays opaque, and flat, and exactly the page's own colour. That is the
   whole requirement on it: the block must hide the posters passing under it
   without announcing that it is a block. Glass was tried here and drew attention
   to itself every time a poster slid under it; a graded page was tried behind it
   and left it a visibly different shade. Both are gone. The phone bars keep the
   glass, where a narrow bar with content moving behind it genuinely gains from
   being seen through.

   The rule along the bottom edge is .tabs' old border, moved out here. Inside a
   single pinned block that border read as a divider between the tabs and the
   toolbar rather than as the edge of the thing; on the block it bounds the whole
   of it, matching how the page header is separated from the content.

   That hairline is the block's ONLY separator, and deliberately. This rule
   carried an --elev-2 shadow briefly, on the argument that the block outranks the
   cards on the z ladder and so ought to outrank them in depth. The argument was
   sound and the result was not: a wide, straight-edged bar spanning the content
   column, already edged by a hairline and already separated by the blur, gains a
   second and a third edge from a shadow, and the stack of them reads as a
   rendering artefact rather than as elevation. Depth belongs to things with
   shape — cards, dialogs, trays. Chrome that spans the viewport is separated by
   the blur and the hairline, which is what "above" looks like for a surface with
   no sides. The phone toolbar had the same shadow removed for the same reason.
   Do not add one back without looking at it on a real screen first. */
.gallery-head {
    position: sticky;
    top: 0;
    z-index: 30;
    background: var(--bg);
    padding-top: 12px;
    margin-bottom: 16px;
    border-bottom: 1px solid var(--border);
}

/* Category tabs */
.tabs {
    display: flex;
    flex-wrap: wrap;
    gap: 4px;
    margin-bottom: 12px;
}

.tab {
    padding: 10px 14px;
    color: var(--muted-on-chrome);
    text-decoration: none;
    border-bottom: 2px solid transparent;
    transition: color var(--dur-base) var(--ease-standard),
                border-color var(--dur-base) var(--ease-standard);
}

@media (hover: hover) {
    .tab:hover {
        color: var(--ink);
        border-bottom-color: var(--border);
    }

    /* The active tab is already at --ink over the accent, so its hover has
       nothing left to say. Stated so the rule above cannot dim its underline. */
    .tab--active:hover {
        border-bottom-color: var(--accent);
    }
}

.tab--active {
    color: var(--ink);
    border-bottom-color: var(--accent);
}

/* Desktop tabs are text only; the icon and short label are a phone affordance. */
.tab__icon,
.tab__text--short {
    display: none;
}

/* Toolbar */
.toolbar {
    display: flex;
    flex-wrap: wrap;
    gap: 12px;
    align-items: center;
    justify-content: space-between;
    margin-bottom: 8px;
}

/* The orphan count and its Delete all button. Laid out like .toolbar above and
   deliberately not sharing its class, because what this bar needs is everything
   .toolbar does at phone width and none of what it becomes there.
   TraySurfaceTest guards the absences, which are the whole point of the rule:

   No `position` or `z-index`. This markup is rendered into the orphans tray as
   well as onto /orphans, and a tray is not a page. Pinned, it inherited
   `z-index: 30` — above `.sheet .overlay`'s 5, which is itself deliberately low
   so a progress overlay covers the tray rather than the screen. The bar was
   therefore painted *over* the "Checking Plex for orphans…" spinner, and a
   backdrop filter samples only what is behind it, so the overlay's dim and blur
   never reached the one element sitting on top of it. Only a reopen showed it:
   the first open scans an empty body, a reopen leaves the previous result
   standing while it rescans. Declaring no stacking order dissolves that rather
   than re-tuning it — there is no number left to get wrong.

   No `background`. --chrome-tint is the page's colour; over a tray it painted a
   band of page behind the count and the button instead of letting --surface
   through.

   No negative margin. The bleed .toolbar carries is solved against .container's
   14px gutter, and .sheet__body pads 16px — close enough to look intentional and
   wrong enough to leave a 2px channel of tray surface down each edge. With no
   bleed the bar simply sits inside whichever padding it finds.

   Not pinned anywhere, including on /orphans where it used to be by accident.
   The list has no search or sort to keep in reach, and Delete all orphans is not
   a destructive control to hold permanently under the thumb. */
.orphans__bar {
    display: flex;
    flex-wrap: wrap;
    gap: 12px;
    align-items: center;
    justify-content: space-between;
    margin-bottom: 8px;
}

.search {
    display: flex;
    align-items: center;
    gap: 8px;
    flex: 1 1 240px;
}

.search input {
    flex: 1;
    padding: 9px 12px;
    border-radius: var(--radius-sm);
    border: 1px solid var(--border);
    background: var(--surface);
    color: var(--ink);
}

/* Despite the name, this never renders on the pinned toolbar — it is the clear
   link in the results summary and the back link on /plex, /orphans and the
   sign-in screen, all of which sit on the ordinary page. So it keeps --muted;
   --muted-on-chrome here would simply read as brighter than the text beside it. */
.search__clear {
    color: var(--muted);
    text-decoration: none;
    font-size: 0.9rem;
}

.stats {
    color: var(--muted);
    font-size: 0.9rem;
    margin: 8px 0 16px;
}

/* The gallery reports its size two ways, and the screen picks one.

   .stats__range ("Showing 1–24 of 1948") describes a page, so it is only true
   where a pager exists. .stats__total ("Total: 1948") describes the category and
   is true at any scroll position. The swap happens in the `max-width: 640px`
   block at the end of this file — the same block that hides .pagination, because
   it is the same decision: below that width there is no pager to describe.

   `display: none` rather than visibility or opacity, and this is the whole
   reason both may sit in the markup at once. It removes the hidden report from
   the accessibility tree as well as from the page, so a screen reader meets
   exactly one sentence at any width. The other two properties would leave both
   readable and turn this into a regression for anyone not looking at it. */
.stats__total {
    display: none;
}

.stats--filter {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 8px 12px;
    color: var(--muted);
    font-size: 0.9rem;
    margin: 8px 0 16px;
}

.sort {
    display: flex;
    align-items: center;
    gap: 6px;
}

.sort__label {
    color: var(--muted-on-chrome);
    font-size: 0.9rem;
}

/* A sort button is three marks in a row: the field's glyph, its label, and the
   arrow giving the direction. */
.sort__btn {
    display: inline-flex;
    align-items: center;
    gap: 6px;
}

/* The icon macro's 1.7 stroke is set against a 24 viewBox and holds its weight
   at the ~22px the tabs and header draw at, where it lands at 1.56px on screen.
   These two render much smaller to fit a button, and the stroke shrinks with
   them — at 14px a 1.7 comes out under 1px and reads as a hairline. Scaling the
   nominal stroke back up keeps the drawn weight where the rest of the set sits
   rather than letting these two thin out on their own. */
.sort__btn .icon {
    stroke-width: 2.3;
}

/* The active button sets its label in 600, so its marks have to gain the same
   weight or the bolder text shows them up. */
.sort__btn.btn--accent .icon {
    stroke-width: 2.5;
}

/* Drawn pointing down for a field running its ordinary way, so a reversed field
   is the same path turned over rather than a second glyph that could drift away
   from it. Turning it over is also exactly how the two-arrow `sort` glyph draws
   its own second half — this mark being one half of that glyph, the tray's
   trigger and the direction on the rows inside it stay the same shape.

   Keyed to reversed rather than to ascending: A–Z is ascending and newest-first
   is descending, yet both are the ordinary way to read their field, so both rest
   pointing down. An arrow that has turned over therefore always means the one
   thing. */
.sort__dir {
    display: inline-flex;
    align-items: center;
}

.sort__dir--reversed {
    transform: rotate(180deg);
}

/* Square icon button (e.g. the phone sort trigger). Hidden on desktop, where the
   inline sort control is used instead. */
.icon-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    padding: 8px;
    border: 1px solid var(--border);
    border-radius: var(--radius-sm);
    background: var(--surface);
    color: var(--ink);
    cursor: pointer;
    transition: background var(--dur-base) var(--ease-standard),
                border-color var(--dur-base) var(--ease-standard),
                transform var(--dur-fast) var(--ease-standard);
}

@media (hover: hover) {
    .icon-btn:not(:disabled):not([aria-disabled='true']):hover {
        background: var(--surface-2);
        border-color: #4b515b;
    }
}

.icon-btn:not(:disabled):not([aria-disabled='true']):active {
    transform: translateY(1px);
}

.icon-btn:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
}

.sort-trigger {
    display: none;
}

.fieldset {
    border: 1px solid var(--border);
    border-radius: var(--radius-sm);
    padding: 12px 14px;
    margin: 0 0 16px;
}

.fieldset legend {
    padding: 0 6px;
    color: var(--muted);
    font-size: 0.85rem;
}

.check {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 4px 0;
}

.check__meta {
    color: var(--muted);
    font-size: 0.8rem;
}

.check__hint {
    color: var(--muted);
    font-size: 0.8rem;
    margin: 0 0 8px;
}

/* Content-type chooser (import step 1) */
.choice-row {
    display: flex;
    flex-wrap: wrap;
    gap: 10px;
}

.choice {
    position: relative;
}

.choice input {
    position: absolute;
    opacity: 0;
    width: 1px;
    height: 1px;
}

.choice span {
    display: flex;
    align-items: center;
    justify-content: center;
    text-align: center;
    padding: 10px 18px;
    border: 1px solid var(--border);
    border-radius: var(--radius-pill);
    cursor: pointer;
    color: var(--ink);
    /* Elevated fill so an unselected choice reads as a button rather than blending
       into the surface behind it. */
    background: var(--surface-2);
    transition: border-color var(--dur-base) var(--ease-standard),
                color var(--dur-base) var(--ease-standard),
                background var(--dur-base) var(--ease-standard);
}

.choice input:checked + span {
    border-color: var(--accent);
    color: var(--ink);
    background: color-mix(in srgb, var(--accent) 22%, var(--surface-2));
}

.choice input:focus-visible + span {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
}

/* Poster grid.

   The 200px minimum is set by the action stack, not by how posters look. A card
   is `aspect-ratio: 2 / 3`, so its height is 1.5x the column; the tallest stack —
   the seven actions shown for a poster linked to Plex — needs about 295px, which
   wants a column of at least ~197px. At the old 190px minimum a linked poster's
   actions were clipped and the overlay scrolled, which "Poster presentation" has
   always forbidden ("large enough for the overlay action stack to fit").

   Four columns still fit the 912px content column, so nothing changes at full
   width; five never fit at either value. Retune this with the control height in
   .card__actions .btn, never alone. */
.grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
    gap: 18px;
}

/* Loading dim for an in-place view change. These values are only half the
   story: the class is applied on a delay and held for a minimum by the busy
   tracker in gallery.js, so a fast tab switch never reaches this rule at all.
   Retune the timing there, not here. */
[data-gallery].is-loading #results {
    opacity: 0.55;
    transition: opacity var(--dur-fast) var(--ease-standard);
}

.card {
    margin: 0;
}

/* The frame holds the poster and its hover overlay; the title sits below it.

   No resting elevation, deliberately. A shadow on every card was tried and cut:
   posters already carry their own contrast, the border already separates them
   from the page, and a grid of forty shadowed rectangles reads as muddy rather
   than as depth. The failure that made it obvious was at the top of the grid —
   the row sitting under the pinned controls put its shadows along the bar's
   lower edge, and the bar, which casts nothing, looked like it was casting one.
   Depth arrives on hover instead, where it means something. */
.card__frame {
    position: relative;
    aspect-ratio: 2 / 3;
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: var(--radius-md);
    overflow: hidden;
}

/* Subtle shimmer shown until a lazy-loaded image fades in. One treatment, three
   callers: poster cards, Find Posters candidate cells, and the two full-screen
   viewers. A card or a cell paints it on the frame that already reserves the
   poster's space; a full-screen image has no such frame — its size is unknown
   until it loads — so the viewers paint it on a standalone placeholder element
   instead. See .viewer__placeholder. */
.card__frame::before,
.find-item__frame::before,
.viewer__placeholder {
    background: linear-gradient(100deg, var(--surface) 30%, rgba(255, 255, 255, 0.06) 50%, var(--surface) 70%);
    background-size: 200% 100%;
    animation: shimmer var(--tempo-shimmer) linear infinite;
}

.card__frame::before,
.find-item__frame::before {
    content: '';
    position: absolute;
    inset: 0;
}

/* Once the image has resolved the shimmer is redundant, so stop animating it.
   Without :has() support it keeps running, hidden behind the opaque image —
   the same as before this rule, so the degradation is invisible. The viewers
   need no such rule: their placeholder is removed outright once the image
   resolves, because Alpine owns it. */
.card__frame:has(.card__image.is-loaded)::before,
.find-item__frame:has(.find-item__img.is-loaded)::before {
    content: none;
}

/* Lazy-load fade-in. Every image that ships hidden and is revealed by
   `is-loaded` once it resolves — set by gallery.js for cards, and by Alpine
   state for the candidate cells and the viewers, whose images are created or
   re-pointed after that scan has run. */
.card__image,
.find-item__img,
.viewer img {
    opacity: 0;
    transition: opacity var(--dur-fade) var(--ease-entrance);
}

.card__image.is-loaded,
.find-item__img.is-loaded,
.viewer img.is-loaded {
    opacity: 1;
}

.card__image {
    position: relative;
    width: 100%;
    height: 100%;
    object-fit: cover;
    display: block;
    cursor: pointer;
}

.card__caption {
    margin-top: 8px;
    color: var(--muted);
    font-size: 0.84rem;
    line-height: 1.3;
    text-align: center;
    /* Keep the title to a single line no wider than the poster: it truncates
       with an ellipsis instead of wrapping, so a long title never adds a second
       line and pushes the following grid row down. The full title is available
       through the custom tooltip. */
    max-width: 100%;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

/* Non-interactive tooltip host: signal that hovering reveals more (the full,
   truncated title) with a help cursor rather than the text/I-beam cursor.
   Only while the title is actually cut off — a caption that fits shows no
   tooltip, so a help cursor there would promise information that never arrives.
   app.js sets this class from the same measurement that gates the tooltip, on
   hover, because CSS cannot detect truncation. Interactive tooltip hosts
   (pagination steps, find-item preview) keep their own pointer cursor. */
.card__caption.is-truncated {
    cursor: help;
}

/* Shared custom tooltip — one floating element positioned by app.js, replacing
   the browser's native title= tooltip everywhere. Themed to the app so hint text
   (and full, truncated poster titles) match the rest of the UI. */
.tooltip {
    position: absolute;
    top: 0;
    left: 0;
    z-index: 100;
    max-width: 320px;
    padding: 6px 10px;
    background: var(--surface);
    color: var(--ink);
    border: 1px solid var(--border);
    border-radius: var(--radius-sm);
    box-shadow: var(--elev-5);
    font-size: 0.8rem;
    line-height: 1.35;
    text-align: center;
    white-space: normal;
    overflow-wrap: anywhere;
    pointer-events: none;
    opacity: 0;
    transition: opacity var(--dur-fast) var(--ease-standard);
}

.tooltip--visible {
    opacity: 1;
}

/* Type badge — shown only in the All view. Sits in a corner of the frame and
   retreats on hover (pointer devices) so it never competes with the action
   overlay; on touch there is no overlay, so it simply stays put. */
.card__badge {
    position: absolute;
    top: 8px;
    left: 8px;
    z-index: 1;
    padding: 3px 8px;
    font-size: 0.68rem;
    font-weight: 600;
    line-height: 1;
    letter-spacing: 0.02em;
    color: #fff;
    background: rgba(0, 0, 0, 0.72);
    border: 1px solid rgba(255, 255, 255, 0.18);
    border-radius: var(--radius-pill);
    pointer-events: none;
    transition: opacity var(--dur-base) var(--ease-standard);
}

/* Per-type tints drawn from the app palette — muted gold / teal / blue / violet
   so the four types (especially TV Show vs. TV Season) stay distinguishable at a
   glance without the stock web primaries fighting the slate-and-gold theme. */
.card__badge--movies { background: rgba(200, 135, 26, 0.92); }
.card__badge--tv-shows { background: rgba(47, 143, 127, 0.92); }
.card__badge--tv-seasons { background: rgba(63, 123, 181, 0.92); }
.card__badge--collections { background: rgba(109, 90, 192, 0.92); }

/* Actions overlay the poster and reveal on hover (always on touch). */
.card__overlay {
    position: absolute;
    inset: 0;
    display: flex;
    flex-direction: column;
    justify-content: center;
    gap: 7px;
    padding: 12px;
    background: rgba(0, 0, 0, 0.72);
    opacity: 0;
    pointer-events: none;
    transition: opacity var(--dur-base) var(--ease-standard);
    overflow-y: auto;
}

/* Desktop reveals the overlay on hover. Touch devices use the action sheet
   instead (see below), so the overlay never shows there. */
@media (hover: hover) {
    .card__frame:hover .card__overlay,
    .card__frame:focus-within .card__overlay {
        opacity: 1;
        pointer-events: auto;
    }

    /* As the overlay arrives, the badge retreats so they never overlap. */
    .card__frame:hover .card__badge,
    .card__frame:focus-within .card__badge {
        opacity: 0;
    }

    /* The card lifts toward the pointer as its actions arrive.

       `transform` and `box-shadow` only, and that is the whole constraint rather
       than a stylistic preference: neither participates in layout, so a lifted
       card cannot move its neighbours or reflow the grid. Anything that changed
       the card's box instead — margin, scale via width, a border — would push
       every card after it in the row, and on a grid that reflows into the next
       row. The scale is deliberately small for the same reason it is safe: at
       1.02 a 200px card grows 4px, which overlaps its gutter without ever
       reaching the card beside it.

       Keyed to the frame, not the whole card: the caption stays put, so a title
       does not slide out from under its poster.

       No `will-change`. It would promote every card in a long gallery to its own
       layer for a hover that touches one at a time, which costs memory
       continuously to save paint work that happens once. */
    .card__frame {
        transition: transform var(--dur-base) var(--ease-standard),
                    box-shadow var(--dur-base) var(--ease-standard);
    }

    .card__frame:hover,
    .card__frame:focus-within {
        transform: translateY(-3px) scale(1.02);
        box-shadow: var(--elev-3);
    }
}

@media (hover: none) {
    .card__overlay {
        display: none;
    }
}

.card__actions {
    display: flex;
    flex-direction: column;
    gap: 5px;
}

.card__actions form {
    display: block;
}

/* Left-aligned with a leading icon, so the glyphs form one column the eye can
   scan. Centring them would put every icon at a different horizontal position,
   which is most of what the icons were meant to fix.

   The row must not grow: the glyph is 18px against a 20.48px line box (0.8rem at
   the inherited 1.6), so the text still sets the height and the seven-action
   stack occupies exactly what it did before icons. Anything that makes this
   taller — a larger glyph, a wrapped label — puts the stack back over the card's
   height. See the .grid minimum, which is sized against that stack. */
.card__actions .btn {
    display: flex;
    align-items: center;
    gap: 8px;
    width: 100%;
    text-align: left;
    padding: 6px 8px;
    font-size: 0.8rem;
    transition: background var(--dur-base) var(--ease-standard),
                border-color var(--dur-base) var(--ease-standard),
                color var(--dur-base) var(--ease-standard);
}

.card__action-ico {
    display: inline-flex;
    align-items: center;
    flex: 0 0 auto;
}

/* The label is what names the control — no action carries an aria-label, so this
   text is the accessible name in both the hover overlay and the action sheet. */
.card__action-label {
    min-width: 0;
}

/* On the dark hover overlay a plain surface button reads as half-transparent;
   lift the non-accent ones onto the elevated tone with a clearer border, and
   make the delete button's outline read solidly. */
.card__actions .btn:not(.btn--accent) {
    background: var(--surface-2);
    border-color: #4b515b;
}

.card__actions .btn--danger {
    border-color: rgba(228, 120, 95, 0.7);
}

/* Make the control under the pointer (or keyboard focus) unmistakable before
   it is clicked. Scoped to the overlay so buttons elsewhere are unaffected.

   The off state is excluded from all three, focus included. No card action is
   switched off today, so this is for the one that will be: an off control that
   answered a hover or a focus with the accent treatment would read as the most
   pressable thing on the card. Focus stays visible through the plain outline
   rule below, which is all a focused control needs to say where it is. */
.card__actions .btn:not(:disabled):not([aria-disabled='true']):hover,
.card__actions .btn:not(:disabled):not([aria-disabled='true']):focus-visible {
    border-color: var(--accent);
    background: color-mix(in srgb, var(--accent) 14%, var(--surface));
    color: var(--ink);
}

.card__actions .btn--accent:not(:disabled):not([aria-disabled='true']):hover,
.card__actions .btn--accent:not(:disabled):not([aria-disabled='true']):focus-visible {
    background: var(--accent-dim);
    border-color: var(--accent-dim);
    color: #1a1300;
}

.card__actions .btn--danger:not(:disabled):not([aria-disabled='true']):hover,
.card__actions .btn--danger:not(:disabled):not([aria-disabled='true']):focus-visible {
    background: color-mix(in srgb, #e4785f 26%, var(--surface-2));
    border-color: rgba(228, 120, 95, 0.9);
    color: #f3b0a1;
}

.card__actions .btn:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
}

@keyframes shimmer {
    to {
        background-position: -200% 0;
    }
}

.find-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
    gap: 12px;
    max-height: 62vh;
    overflow-y: auto;
    overscroll-behavior: contain;
    padding-top: 4px;
}

.find-item {
    margin: 0;
    display: flex;
    flex-direction: column;
    gap: 6px;
}

/* The candidate's frame, mirroring .card__frame: it reserves the cell at poster
   proportions before the thumbnail arrives, which is both what the shimmer fills
   and what lets native lazy loading defer an off-screen candidate — an image
   with no reserved space would land on top of the fold and be fetched anyway. */
.find-item__frame {
    position: relative;
    aspect-ratio: 2 / 3;
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: var(--radius-xs);
    overflow: hidden;
}

.find-item__img {
    position: relative;
    display: block;
    width: 100%;
    height: 100%;
    object-fit: cover;
    cursor: zoom-in;
}

/* The credit link on a candidate whose service requires one (see the template).
   Bottom-trailing corner, so it clears both .find-item__badge — which holds the
   opposite corner — and the middle of the thumbnail, which is where a user aims
   to open the preview. Sized as a 28px target rather than to the glyph: this is
   tapped on a phone, inside a cell only ~130px wide, and a link the width of an
   arrow would be missed as often as hit. Its own backdrop because it sits on an
   image of unknown colour and has to stay legible on all of them.

   It appears on nearly every candidate — anything the source gave a page for —
   so it has to hold its own against a wall of artwork without becoming the thing
   the eye lands on. That is why it is small, cornered and quiet in its resting
   state, and only takes the accent on hover or focus: at ~189 of them in a show
   search, anything louder would read as the grid's dominant texture rather than
   as a control on each poster.

   The badge shown because a licence requires it is drawn identically to the one
   shown as provenance; the distinction is in the template's condition and in
   data-attribution-required, not in the paint. */
.find-item__credit {
    position: absolute;
    inset-block-end: 6px;
    inset-inline-end: 6px;
    z-index: 1;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 28px;
    height: 28px;
    border-radius: var(--radius-xs);
    /* Opaque, on the elevated tone with the clearer border — the same treatment
       .card__actions gives its buttons over the hover overlay, and for the same
       reason: a translucent control over an image of unknown colour reads as
       half-there. It was translucent here at first and the result was a control
       users could neither see nor deliberately avoid, which for a link that
       leaves the application is the wrong way round.

       This is also what the token contract requires — legibility must not depend
       on the blur — so there is no blur left to depend on. */
    background: var(--surface-2);
    border: 1px solid #4b515b;
    box-shadow: var(--elev-1);
    color: var(--ink);
    font-size: 15px;
    font-weight: 600;
    line-height: 1;
    text-decoration: none;
    transition:
        background var(--dur-base) var(--ease-standard),
        border-color var(--dur-base) var(--ease-standard),
        color var(--dur-base) var(--ease-standard);
}

.find-item__credit:hover,
.find-item__credit:focus-visible {
    background: var(--accent);
    border-color: var(--accent);
    color: var(--bg);
    text-decoration: none;
}

/* Shared by both tabs that stack grids under their own headings: Plex Posters
   (two fixed groups) and Find Posters (one section per supplying service).
   Scrolling belongs to the stack rather than to each grid — independently
   scrolling grids in one panel give a user several places to lose their
   position — and it has to, because the headings below are sticky and a sticky
   element cannot travel outside its own scroll container.

   This is why `.find-grid` must not keep its own `max-height`/`overflow` when it
   sits inside here; the reset below is what hands the scroll up. */
/* Spacing between groups is a flex gap, never `.poster-group + .poster-group`. The
   groups are rendered by `x-if` (Plex) or `x-for` (Find), and Alpine leaves the
   <template> in the DOM and inserts each group after it — so the adjacent-sibling
   selector has a <template> between every pair and silently matches nothing. A
   gap has no such trap: a <template> is display:none, so it never becomes a flex
   item and no gap is laid out around it. */
.poster-groups {
    display: flex;
    flex-direction: column;
    gap: 40px;
    max-height: 62vh;
    overflow-y: auto;
    overscroll-behavior: contain;
}

.poster-groups .find-grid {
    max-height: none;
    overflow: visible;
    padding-top: 0;
}

/* A section label, not a line of prose: the groups are the tab's structure, and
   .stats (muted, body-sized) let them read as captions on the posters below
   rather than as the boundary between different kinds of poster.

   Sticky, because a group runs to dozens of posters — Plex's offered artwork, or
   a well-covered title's TMDB section — and the label is the only thing saying
   which one you are looking at. Scroll past it and that answer is gone. It pins
   to the top of the scroller while its own group is in view and leaves with it,
   so the heading on screen is always the right one.
   The background must be opaque and match the panel: posters scroll underneath
   it, not behind a tint. */
.poster-group__heading {
    position: sticky;
    top: 0;
    z-index: 2;
    display: flex;
    align-items: center;
    gap: 10px;
    margin: 0 0 12px;
    padding: 6px 0;
    background: var(--surface);
    color: var(--ink);
    font-size: 0.75rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.07em;
}

/* The rule runs out to the edge so the heading reads as the start of a band
   rather than as text that happens to sit above some posters. */
.poster-group__heading::after {
    content: "";
    flex: 1;
    height: 1px;
    background: var(--border);
}

.poster-group__count {
    color: var(--muted);
    font-weight: 500;
    letter-spacing: 0;
}

/* Marks the poster Plex is using now. Without it every candidate reads as
   equally current, which bites hardest in exactly the case this tab exists for:
   a run of near-identical posters the user has already cycled through. */
.find-item__badge {
    position: absolute;
    inset-block-start: 6px;
    inset-inline-start: 6px;
    z-index: 1;
    padding: 2px 7px;
    border-radius: var(--radius-pill);
    background: var(--accent);
    color: var(--bg);
    font-size: 11px;
    font-weight: 600;
}

.find-item__actions {
    display: flex;
    gap: 6px;
}

.find-item__actions .btn {
    flex: 1;
    text-align: center;
    padding: 5px 6px;
    font-size: 0.76rem;
}

.find-item__actions form {
    flex: 1;
    display: flex;
}

.find-item__actions form .btn {
    width: 100%;
}

.toast {
    position: fixed;
    /* Anchor to both edges and auto-center: a single `left: 50%` caps the
       shrink-to-fit width at ~50vw, which wrapped short messages needlessly. */
    left: 0;
    right: 0;
    bottom: 28px;
    margin: 0 auto;
    width: fit-content;
    max-width: calc(100vw - 32px);
    z-index: 80;
    background: var(--surface);
    border: 1px solid var(--border);
    color: var(--ink);
    padding: 10px 18px;
    border-radius: var(--radius-pill);
    box-shadow: var(--elev-5);
    font-size: 0.9rem;
    text-align: center;
}

/* The progress overlay — import running, orphan scan, applying a poster. It
   blocks the screen while work happens, so it dims harder than a dialog backdrop
   does: the page behind is not somewhere the user may act right now.

   `background` is a shorthand and resets background-color, so the extra dimming
   is written as its own layer over --backdrop-tint rather than as a second tint
   token nobody else would use. */
.overlay {
    position: fixed;
    inset: 0;
    z-index: 100;
    display: flex;
    align-items: center;
    justify-content: center;
    background:
        linear-gradient(rgba(0, 0, 0, 0.28), rgba(0, 0, 0, 0.28)),
        var(--backdrop-tint);
    -webkit-backdrop-filter: var(--backdrop-blur);
    backdrop-filter: var(--backdrop-blur);
}

.overlay__box {
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: var(--radius-lg);
    box-shadow: var(--elev-4);
    padding: 30px 34px;
    text-align: center;
    max-width: 360px;
}

.spinner {
    width: 40px;
    height: 40px;
    margin: 0 auto 16px;
    border: 4px solid var(--border);
    border-top-color: var(--accent);
    border-radius: 50%;
    animation: spin var(--tempo-spin) linear infinite;
}

@keyframes spin {
    to {
        transform: rotate(360deg);
    }
}



/* NOTE: the mobile `@media (max-width: 640px)` block lives at the END of this
   file so its overrides win over every base component rule (several of which —
   .pagination, .modal, .sheet — are defined below this point). */

/* Infinite-scroll sentinel (phone). It must occupy layout so the
   IntersectionObserver can see it, so it always reserves a little height; only
   its spinner fades in while the next page loads. */
.scroll-sentinel {
    display: flex;
    align-items: center;
    justify-content: center;
    min-height: 44px;
    padding: 8px 0;
}

.scroll-sentinel .spinner {
    width: 26px;
    height: 26px;
    margin: 0;
    border-width: 3px;
    opacity: 0;
    transition: opacity var(--dur-base) var(--ease-standard);
}

.scroll-sentinel.is-busy .spinner {
    opacity: 1;
}

/* Pagination */
.pagination {
    display: flex;
    align-items: center;
    justify-content: center;
    flex-wrap: wrap;
    gap: 6px;
    margin: 28px 0;
}

.pagination__step {
    padding: 6px 12px;
    line-height: 1;
}

.pagination__pages {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 6px;
    list-style: none;
    margin: 0;
    padding: 0;
}

.pagination__page {
    display: inline-block;
    min-width: 38px;
    padding: 6px 10px;
    border: 1px solid var(--border);
    border-radius: var(--radius-sm);
    background: var(--surface);
    color: var(--ink);
    text-align: center;
    text-decoration: none;
    font-variant-numeric: tabular-nums;
    cursor: pointer;
    transition: background var(--dur-base) var(--ease-standard),
                border-color var(--dur-base) var(--ease-standard),
                transform var(--dur-fast) var(--ease-standard);
}

@media (hover: hover) {
    /* Not the current page: it is rendered as a span rather than a link, so it
       has nowhere to go and must not offer to travel. */
    .pagination__page:not(.pagination__page--current):hover {
        background: var(--surface-2);
        border-color: #4b515b;
    }
}

.pagination__page:not(.pagination__page--current):active {
    transform: translateY(1px);
}

.pagination__page--current {
    background: var(--accent);
    border-color: var(--accent);
    color: #1a1300;
    font-weight: 600;
    cursor: default;
}

.pagination__ellipsis {
    padding: 6px 4px;
    color: var(--muted);
    font-variant-numeric: tabular-nums;
}

/* While any overlay is open the page beneath it is pinned, so a drag on a
   backdrop cannot scroll the gallery (or, at the top of the page, pull to
   refresh) from under the overlay. Pinning with `position: fixed` rather than
   `overflow: hidden` is what makes this hold on iOS Safari; the offset is
   restored exactly on close — see the scroll lock in gallery.js. */
.is-overlay-open body {
    position: fixed;
    left: 0;
    right: 0;
    width: 100%;
}

/* Modal.
   Overlay layering is one ordered scale: the gallery's pinned controls (30 — the
   whole .gallery-head on desktop, .toolbar alone on a phone) sit under the bottom
   tab bar (40), which sits under the trays (.sheet, 50), a dialog sits above
   whatever tray raised it (.modal, 55), and the fullscreen viewer covers both
   (.viewer, 60). A dialog must outrank a tray because a confirmation can be
   raised from inside one — deleting an orphan from the orphans tray is the case —
   and a confirmation rendered behind the tray that asked the question cannot be
   answered. The pinned controls take the lowest tier because they are page
   chrome, not an overlay: they only have to cover the poster grid, and every
   overlay must cover them. */
.modal {
    position: fixed;
    inset: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    z-index: 55;
}

/* The focus manager moves focus onto the dialog panel itself when an overlay
   opens (see gallery.js), so the panel's name is announced and the user tabs
   forward through its contents. Suppress the ring that would otherwise draw
   around the whole panel.

   Not a general opt-out of focus indication, and deliberately narrow. This
   stylesheet has no global focus rule — every indicator is scoped to a control
   — so without a rule here the browser's own default ring applies. It would
   appear in exactly the case that matters, too: Chrome propagates
   :focus-visible to a programmatically focused element when the element that
   held focus before it matched, which is what happens when a keyboard user
   presses the button that opens the dialog.

   The selector requires both attributes together, a combination only a dialog
   panel carries, so it cannot reach a control. A panel is announced, not
   operated; "Keyboard focus is as visible as hover" is a promise about
   interactive elements, and this is not one. */
[role='dialog'][tabindex='-1']:focus,
[role='dialog'][tabindex='-1']:focus-visible {
    outline: none;
}

/* Dimmed and blurred, not merely dimmed. Dimming alone leaves the page legible
   behind the dialog and it keeps competing for attention; blurring pushes it
   back to texture while still showing the user where they were. */
.modal__backdrop {
    position: absolute;
    inset: 0;
    background: var(--backdrop-tint);
    -webkit-backdrop-filter: var(--backdrop-blur);
    backdrop-filter: var(--backdrop-blur);
}

.modal__panel {
    position: relative;
    width: min(440px, 92vw);
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: var(--radius-lg);
    box-shadow: var(--elev-4);
    padding: 20px;
    transition: width var(--dur-base) var(--ease-standard);
}

/* The grab handle is markup on every modal but is a phone affordance only: on a
   pointer device the dialog is centred and closes with its own × button, so the
   handle would promise a drag that leads nowhere. The mobile block turns it back
   on. */
.modal .sheet__grip {
    display: none;
}

/* Give the found-poster gallery more room so thumbnails read cleanly. */
.modal__panel--wide {
    width: min(760px, 94vw);
}

.modal__panel--narrow {
    width: min(380px, 92vw);
}

.modal__actions {
    display: flex;
    justify-content: flex-end;
    gap: 10px;
    margin-top: 20px;
}

.modal__actions form {
    display: inline;
}

.modal__head {
    display: flex;
    align-items: center;
    justify-content: space-between;
}

.modal__head h2 {
    margin: 0;
    font-size: 1.1rem;
}

.modal__close {
    border: none;
    background: none;
    color: var(--muted);
    font-size: 1.6rem;
    line-height: 1;
    cursor: pointer;
    transition: color var(--dur-base) var(--ease-standard);
}

@media (hover: hover) {
    .modal__close:hover {
        color: var(--ink);
    }
}

.modal__close:active {
    color: var(--accent);
}

.modal__tabs {
    display: flex;
    gap: 4px;
    margin: 16px 0;
    border-bottom: 1px solid var(--border);
}

.modal__tabs button {
    border: none;
    background: none;
    color: var(--muted);
    padding: 8px 12px;
    cursor: pointer;
    border-bottom: 2px solid transparent;
    transition: color var(--dur-base) var(--ease-standard),
                border-color var(--dur-base) var(--ease-standard);
}

@media (hover: hover) {
    .modal__tabs button:not(.is-active):not(:disabled):not([aria-disabled='true']):hover {
        color: var(--ink);
        border-bottom-color: var(--border);
    }
}

.modal__tabs button.is-active {
    color: var(--ink);
    border-bottom-color: var(--accent);
}

/* Off — and drawn for nobody. No tab in the application is switched off: the one
   that was, Plex Posters on a poster with no Plex item, is now an ordinary tab
   that opens to say so. These rules are kept anyway, for the reason .btn:disabled
   is kept with no caller either — a tab switched off later must not arrive with no
   treatment at all, which is the whole point of stating it once here rather than
   at whichever control next needs it.

   What that tab will inherit, and what it owes on top:

   The strip cannot say this as loudly as .btn can, and the reason is structural
   rather than an omission — an available-but-inactive tab already sits at --muted,
   so the off state has no tier of its own to drop into. What is left is a further
   step down from muted, the cursor, and the absence of any response to the
   pointer. On touch none of the last two exist, so this reduces to opacity alone,
   which is the one thing the off state is required not to be carried by. A tab
   switched off here therefore owes a signal that is not a further step down from
   muted, and owes its reason to what the panel says rather than to a hint —
   tooltips are pointer-only by design, so a reason attached to a switched-off
   control is a reason half the devices never receive.

   No `pointer-events: none`, for the same reason as .btn: it makes the element
   non-hit-testable, and that takes `cursor: not-allowed` with it. */
.modal__tabs button:disabled,
.modal__tabs button[aria-disabled='true'] {
    opacity: 0.5;
    cursor: not-allowed;
}

.modal__tabs button:disabled:active,
.modal__tabs button[aria-disabled='true']:active {
    color: var(--muted);
}

/* The support ask, inside the dialog that holds it (partials/_support.html.twig).
   A head of three: the heart at the left edge, the heading centred, the close at
   the right. Then the copy, left. Then one accented button across the panel.
   Ported in shape from the project site's support card and re-expressed in this
   application's tokens rather than in its colours: the site is not themed by this
   stylesheet, and a hard-coded rgba() lifted from it would be the one surface here
   that does not move when a token does.

   One rule here is load-bearing and the rest is taste, and it is worth keeping
   them apart. **The mark belongs on the heading's row.** The first build put it on
   its own line beneath the head, above the copy, where it sat between the heading
   it named and the text it introduced and belonged to neither. The site gets away
   with that only because its card has no title bar: its heading is the top of the
   composition, so a mark above it reads as the card's crown. An overlay's heading
   lives in a head beside a close control, so there is no crown position to put one
   in. Where on that row the mark sits — beside the words or out at the edge — and
   which blocks of text are centred are both taste, and both have changed since.
   Don't reason from them; don't put the mark back below the head.

   Nothing in this block is width-conditional, and nothing needs to be. The button
   stretches to the panel at both widths, which is also what the mobile block gives
   every other dialog's actions. */
.support-ask {
    display: flex;
    flex-direction: column;
}

/* Mark left, heading centred, close right — a grid rather than the flex row
   .modal__head otherwise is.

   `1fr auto 1fr` is what makes the heading *actually* centred rather than merely
   between the other two. The outer tracks are equal by definition, so the middle
   one lands on the panel's centre line whatever the mark and the close measure —
   and they do not measure the same: a 40px tile against a text glyph. The obvious
   flex alternatives both fail on that. `space-between` centres the heading in the
   gap left over, which is off by half the difference; taking the mark and the
   close out of flow instead fixes the widths but breaks the vertical, because an
   absolutely positioned child resolves against the padding box while its in-flow
   siblings centre in the content box — and the mobile block gives this head
   2px of padding above and 12px below, so the heading would sit 5px low there.

   It also survives the phone on its own. The close is `display: none` at that
   width, but its track is not: the third `1fr` stays, so the heading stays
   centred and the mobile block needs nothing. */
.support-ask__head {
    display: grid;
    grid-template-columns: 1fr auto 1fr;
    align-items: center;
}

.support-ask__title {
    justify-self: center;
}

.support-ask__head .modal__close {
    justify-self: end;
}

.support-ask__mark {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    /* Fixed, and never allowed to shrink: it shares a flex row with a heading, and
       a squashed tile draws a heart as an ellipse. */
    flex: 0 0 auto;
    width: 40px;
    height: 40px;
    border-radius: var(--radius-md);
    /* The accent at its glass weight, over the raised surface rather than the
       panel's, so the tile reads as sitting on the dialog instead of tinting it. */
    background: color-mix(in srgb, var(--accent) 16%, var(--surface-2));
    color: var(--accent);
}

.support-ask__text {
    margin: 14px 0 0;
    color: var(--muted);
    line-height: 1.55;
}

/* Stretched rather than centred at its intrinsic width. A single call to action
   in a narrow dialog reads as the dialog's answer when it spans it, and this is
   also what the mobile block gives every other dialog's actions — so the phone
   presentation matches its neighbours without a second rule. */
.support-ask__cta {
    align-self: stretch;
    margin-top: 20px;
    padding: 12px;
    /* .btn is inline-block, so this — not a flex property — is what centres the
       label once the button has been stretched. Matches how the mobile block
       centres .modal__actions .btn for the same reason. */
    text-align: center;
}

/* Fullscreen viewer */
.viewer {
    position: fixed;
    inset: 0;
    z-index: 60;
    background: rgba(0, 0, 0, 0.9);
    display: flex;
    align-items: center;
    justify-content: center;
    cursor: zoom-out;
    padding: 24px;
}

/* The image's region. It claims all the height the viewer's other children do
   not, so in the Find Posters preview the action bar's position is fixed by the
   layout rather than by the image — the bar cannot move when the image lands,
   nor when a candidate turns out not to be poster-shaped. `min-height: 0` keeps
   a tall image from pushing the bar off the bottom instead of shrinking. */
.viewer__stage {
    flex: 1 1 auto;
    min-height: 0;
    /* The viewer centres its children; the stage must instead fill the cross
       axis, or its height stays content-sized and the image's `max-height: 100%`
       has nothing definite to resolve against. Centring then happens inside the
       stage, below. */
    align-self: stretch;
    display: flex;
    align-items: center;
    justify-content: center;
    /* Positioning context for the placeholder, which is taken out of flow. */
    position: relative;
}

.viewer img {
    max-width: 100%;
    max-height: 100%;
    border-radius: var(--radius-xs);
}

/* Stands in for the image while it loads, at poster proportions — deliberately
   not the image's own shape, which is unknown until it arrives. It is replaced
   by the image rather than filled by it, so a wide candidate settles into its
   real shape instead of being letterboxed into a 2:3 box forever.

   Out of flow, and that is load-bearing rather than tidiness. A browser gives
   the <img> its intrinsic box as soon as it has parsed the image's dimensions,
   which is long before the image has arrived and even longer before it fades in.
   As a sibling in this centred row, that still-transparent box would sit beside
   the placeholder and shove it left — for a second on a fast connection, and for
   as long as the download takes on a slow one. Absolute positioning leaves the
   stage's flow to the image alone, so the placeholder holds still and is simply
   replaced. */
.viewer__placeholder {
    position: absolute;
    top: 0;
    bottom: 0;
    left: 50%;
    transform: translateX(-50%);
    /* Both insets are set but the height comes from the aspect ratio once
       max-width clamps the box — which is over-constrained, and the browser
       resolves that by honouring `top` and pinning the placeholder to the top of
       the stage. On a narrow screen, where width is the limiting axis, that is
       far above where the poster lands and reads as a drop when it arrives.
       `auto` block margins centre it in the leftover space instead, which is
       what the image itself does. */
    margin-block: auto;
    max-width: 100%;
    aspect-ratio: 2 / 3;
    border-radius: var(--radius-xs);
    /* Decorative: a click on it is a click on the backdrop behind it, which is
       what dismisses the viewer. Waiting for a poster must not be the one state
       the viewer cannot be closed from. */
    pointer-events: none;
}

/* The change-poster preview — a found candidate, a pasted URL, or a picked file:
   the image plus a use/close (then confirm) action bar. */
.viewer--preview {
    flex-direction: column;
    gap: 16px;
    cursor: default;
}

.viewer__bar {
    flex: 0 0 auto;
    display: flex;
    /* A column since the credit line stacks above the actions. Bottom-anchoring
       the actions — so the button row stays put when the confirm step adds its
       question line above the buttons — is `justify-content` in this direction,
       and centring is `align-items`; both were the other property when this was
       a row. */
    flex-direction: column;
    align-items: center;
    justify-content: flex-end;
    gap: 12px;
    min-height: 96px;
}

/* The credit for a poster whose source requires a link back. Deliberately not
   styled as a .btn: it leaves the app, while every button in this bar acts on
   the poster, and one that looked like the others would be pressed by someone
   meaning to choose it. Sits above the action row and outlasts the confirm step,
   so it never changes the bar's height while the user is deciding — that motion
   would move the image they are deciding about. */
.viewer__credit {
    color: var(--ink);
    font-size: 13px;
    text-decoration: underline;
    text-underline-offset: 3px;
}

.viewer__credit:hover,
.viewer__credit:focus-visible {
    color: var(--accent);
}

.viewer__actions {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: center;
    gap: 10px;
}

.viewer__actions .btn {
    padding: 11px 18px;
}

/* Fixed widths per role so the row is the same size in both states ("Use this
   poster" vs "Change poster", "Close" vs "Cancel") and nothing shifts sideways. */
.viewer__actions .btn--accent {
    min-width: 168px;
}

.viewer__actions .btn:not(.btn--accent) {
    min-width: 104px;
}

.viewer__ask {
    color: var(--ink);
    font-weight: 600;
    width: 100%;
    text-align: center;
}

/* Re-check button sits below the in-sync message in the orphans empty state.
   Lift it onto the elevated tone with a clear border so it reads as a button and
   does not blend into the panel. */
.panel [data-action="recheck"] {
    margin-top: 14px;
    background: var(--surface-2);
    border-color: #4b515b;
}

/* ==========================================================================
   Overlay transitions.

   Driven by Alpine's x-transition, which is the only thing that can animate
   these: every overlay is toggled by x-show, and x-show sets `display: none`,
   which CSS cannot transition. That is why the trays had an entrance keyframe
   and no exit at all before this — an animation can fire when an element is
   displayed, but nothing can run while it is being undisplayed.

   The class names come from partials/_transitions.html.twig, which declares the
   six x-transition attributes once for all eight overlays rather than repeating
   them at each tag. A dialog fades and scales; a tray slides from the edge it is
   docked to. Both carry their backdrop with them, because the transition is on
   the overlay root and the backdrop is inside it.

   `pointer-events: none` while closing is load-bearing, not polish. Alpine holds
   the element displayed for the length of the leave transition, so without it a
   dismissed dialog goes on swallowing clicks for as long as it takes to fade —
   the user taps Cancel, reaches for something behind it, and the tap lands on a
   backdrop that is visually almost gone. The scroll lock in gallery.js has the
   same problem and solves it the same way, by treating a closing overlay as shut.

   Exits run at --dur-exit, which is shorter than the entrance. Leaving should
   feel quicker than arriving.
   ========================================================================== */
.overlay-opening {
    transition: opacity var(--dur-base) var(--ease-entrance);
}

.overlay-closing {
    transition: opacity var(--dur-exit) var(--ease-exit);
    pointer-events: none;
}

.overlay-shut {
    opacity: 0;
}

.overlay-shown {
    opacity: 1;
}

/* A dialog scales from slightly reduced. 0.96 rather than anything smaller: the
   dialog should look like it is arriving, not like it is being thrown at the
   screen. Scaling the overlay root would take the backdrop with it, so the
   transform is on the panel and the fade is on the root. */
.overlay-opening .modal__panel {
    transition: transform var(--dur-base) var(--ease-entrance);
}

.overlay-closing .modal__panel {
    transition: transform var(--dur-exit) var(--ease-exit);
}

.overlay-shut .modal__panel {
    transform: scale(0.96);
}

.overlay-shown .modal__panel {
    transform: scale(1);
}

/* A tray slides instead. Its entrance is longer than a dialog's — it travels the
   height of the panel rather than 4% of it, and at --dur-base that reads as a
   snap rather than a slide. */
.overlay-opening .sheet__panel {
    transition: transform var(--dur-slow) var(--ease-entrance);
}

.overlay-closing .sheet__panel {
    transition: transform var(--dur-exit) var(--ease-exit);
}

.overlay-shut .sheet__panel {
    transform: translateY(100%);
}

.overlay-shown .sheet__panel {
    transform: translateY(0);
}

/* Mobile action sheet: a poster's actions slide up from the bottom.
   Sits below .modal on the shared layering scale — see the Modal section. */
.sheet {
    position: fixed;
    inset: 0;
    z-index: 50;
    display: flex;
    align-items: flex-end;
    justify-content: center;
}

.sheet__backdrop {
    position: absolute;
    inset: 0;
    background: var(--backdrop-tint);
    -webkit-backdrop-filter: var(--backdrop-blur);
    backdrop-filter: var(--backdrop-blur);
}

/* The panel clips; its body scrolls. Keeping the scroll on the body (not the
   panel) lets a progress overlay pin to the whole tray instead of scrolling
   away with the content — see `.sheet .overlay` below. */
.sheet__panel {
    position: relative;
    display: flex;
    flex-direction: column;
    width: 100%;
    max-width: 520px;
    max-height: 85vh;
    overflow: hidden;
    background: var(--surface);
    border: 1px solid var(--border);
    border-bottom: none;
    border-radius: var(--radius-xl) var(--radius-xl) 0 0;
    /* A tray is docked to the bottom edge, so it casts upward, not down: the
       tier's own downward offsets would throw the shadow off-screen and leave
       the raised top edge — the only edge anyone sees — flat against the page.
       Stated as a literal for that reason, with the tray tier's weights. */
    box-shadow: 0 -4px 8px rgba(0, 0, 0, 0.36), 0 -12px 32px rgba(0, 0, 0, 0.34);
    /* The entrance slide is x-transition's now, not a keyframe's — see the
       Overlay transitions block. Keeping `animation: sheet-up` here as well would
       run the tray up twice over different durations, and the keyframe would win
       the first frame. The transition below stays: it is what the drag-dismiss
       gesture animates against when it releases a partly-dragged tray, and that
       is a different movement from opening or closing. */
    transition: transform var(--dur-base) var(--ease-standard);
}

/* Import/orphans trays hold a whole page; give them more of the screen. */
.sheet__panel--tall {
    max-height: 92vh;
    min-height: 60vh;
}

.sheet__body {
    flex: 1 1 auto;
    min-height: 0;
    overflow-y: auto;
    /* Stop at the end of the tray's own content. Without this, a flick that
       reaches the end hands the rest of the gesture to the page behind the tray,
       which then scrolls (or, at the top of the page, pulls to refresh) from
       under an overlay the user thought had the screen. */
    overscroll-behavior: contain;
    padding: 0 16px calc(20px + env(safe-area-inset-bottom));
}

/* Progress overlays reused inside a tray (import spinner, orphan scan/delete)
   are contained to the tray and pinned over it, instead of covering the screen.
   The panel is the positioned ancestor and does not scroll, so the overlay stays
   put while the body scrolls under it. */
.sheet .overlay {
    position: absolute;
    z-index: 5;
    border-radius: inherit;
}

.sheet .overlay__box {
    max-width: 84%;
}

/* Grab handle: the app-style affordance that replaces a close button. Dragging
   it (or the head) down dismisses the tray; see initSheetGestures in gallery.js. */
.sheet__grip {
    display: flex;
    justify-content: center;
    padding: 12px 0 0;
    cursor: grab;
    touch-action: none;
}

.sheet__handle {
    width: 40px;
    height: 5px;
    border-radius: var(--radius-pill);
    background: #4b4f57;
}

.sheet__head {
    flex: 0 0 auto;
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 12px;
    padding: 14px 16px 12px;
    border-bottom: 1px solid var(--border);
    margin-bottom: 12px;
    touch-action: none;
}

/* The same type as `.modal__head h2`, and that is the whole point of the rule.

   A tray and a dialog docked to the bottom edge wear the same grab handle, so
   they have to present their titles the same distance below it. Matching the
   head padding is only half of that: the distance the eye measures runs to the
   glyph, not to the top of the line box, so the title's half-leading is part of
   it. At 16px/1.3 that leading is 2.4px and at 1.1rem/1.6 it is 5.28px — two
   heads padded identically would still hold their titles 3px apart.

   So `line-height` is inherited rather than set. Restoring an override here is
   the edit most likely to undo this quietly, because it changes nothing about
   the padding anyone would think to check. TrayHeadSpacingTest asserts both the
   shared size and the absence of the override.

   Still a <span>: whether tray titles want heading semantics is a live question,
   but it is not this rule's to answer. */
.sheet__title {
    font-weight: 600;
    font-size: 1.1rem;
}

/* Tray buttons need to read as tappable cards against the sheet surface, which
   is the same colour as a default button. Lift the plain ones onto an elevated
   tone with a clearer border; accent/danger keep their own colour. */
.sheet__body .btn:not(.btn--accent):not(.btn--danger) {
    background: var(--surface-2);
    border-color: #474c55;
}

.sheet__body .btn:not(.btn--accent):not(.btn--danger):not(:disabled):not([aria-disabled='true']):active {
    background: #3c4048;
}

.sheet__body .card__actions {
    gap: 8px;
}

.sheet__body .card__actions .btn {
    padding: 13px 12px;
    font-size: 0.95rem;
}

/* Menu tray body: the navigation links stack as full-width, tap-friendly rows. */
.menu__body {
    display: flex;
    flex-direction: column;
    gap: 8px;
}

/* Menu items read like a native app menu: full-width rows with a leading icon and
   a left-aligned label, on the elevated tray-button surface. */
.menu__body .btn {
    display: flex;
    align-items: center;
    justify-content: flex-start;
    gap: 13px;
    width: 100%;
    text-align: left;
    padding: 13px 15px;
    font-size: 0.95rem;
}

/* A sort button in the tray is one of those full-width rows, so its arrow goes
   to the far end instead of trailing the label halfway across the row. */
.menu__body .sort__btn .sort__dir {
    margin-left: auto;
}

.menu__body .nav-ico {
    display: inline-flex;
    align-items: center;
    flex: 0 0 auto;
    color: var(--accent);
}

/* Product name + version pinned at the bottom of the menu tray (the page footer
   is hidden on mobile). */
.menu__footer {
    flex: 0 0 auto;
    margin-top: 4px;
    padding: 14px 16px calc(6px + env(safe-area-inset-bottom));
    border-top: 1px solid var(--border);
    color: var(--muted);
    font-size: 0.8rem;
    text-align: center;
}

/* ==========================================================================
   Mobile overrides. Placed last so they win over every base component rule
   (.pagination, .modal, .sheet, … are defined above), which a mid-file media
   query could not do at equal specificity.
   ========================================================================== */
@media (max-width: 640px) {
    .container {
        padding: 20px 14px;
    }

    /* The gallery's toolbar pins to the top of the viewport, so on a phone it
       comes to rest directly under the header — and the container's 20px top
       padding sat between them as a band of page colour, which read as the bar
       having come loose from the header rather than as breathing room.

       Scoped with :has() so only the gallery loses that padding. /plex, /orphans
       and the sign-in screen open with a heading rather than a pinned bar, and
       they still want the gap. `display: contents` on .gallery-head means it
       generates no box here, but it is still in the DOM and still matched — which
       is exactly what makes it a usable marker for "this page pins a toolbar".
       The stylesheet already relies on :has() for the shimmer, so this needs no
       new support assumption. */
    .container:has(.gallery-head) {
        padding-top: 0;
    }

    /* The desktop wrapper leaves the box tree entirely on a phone, restoring the
       arrangement the rules below expect: .tabs pins itself to the bottom of the
       viewport and .toolbar does its own pinning at the top.

       This is not tidiness — it is what keeps the phone toolbar working. A sticky
       element cannot travel outside its containing block, so leaving the wrapper
       in place would cut the toolbar's sticky range to the wrapper's own height
       and unpin it after roughly one tab bar of scrolling. `display: contents` is
       safe on this element specifically because it is a bare <div> carrying no
       semantics of its own to lose. */
    .gallery-head {
        display: contents;
    }

    /* Room for the fixed bottom tab bar so the last posters are never hidden. */
    body {
        padding-bottom: calc(76px + env(safe-area-inset-bottom));
    }

    /* The version footer — and with it the provider credit — moves into the menu
       tray on a phone. No rule is needed to hide the credit itself: it lives
       inside this footer, and the tray's copy is only ever seen at this width. */
    .footer {
        display: none;
    }

    /* The tray is narrower than the page — roughly 288px inside its padding on a
       320px phone — and four logos no longer fit across it: with gaps they come
       to ~349px against the ~278px the first three occupy. They are therefore
       allowed to wrap, which .attribution__logos already does (flex-wrap plus a
       centred main axis). At 320px that puts TMDB, TVDB and fanart.tv on one line
       and TVmaze centred beneath them; a little more width and all four share a
       line again.

       That is the deliberate trade. Shrinking them enough to hold one line was
       tried when there were three and fanart.tv already set the floor there;
       a fourth mark below that height is a smudge, and an illegible credit
       credits nobody. Vertical space in the drawer footer is the cheaper thing
       to spend. */
    .attribution__logos {
        gap: 14px;
    }

    .attribution__logo--tmdb {
        height: 12px;
    }

    .attribution__logo--tvdb {
        height: 26px;
    }

    .attribution__logo--fanart {
        height: 22px;
    }

    .attribution__logo--tvmaze {
        height: 18px;
    }

    /* The same optical run-in, rescaled: 16/253 of an 18px mark is ~3.6px.

       It earns its place on the wider phones where all four marks still share a
       line (they total ~349px). At the narrowest, TVmaze wraps alone and the
       offset merely shifts a centred item by half of itself — 1.5px, which
       nobody sees, and not worth a query to switch off. */
    .attribution__logos a:has(.attribution__logo--tvmaze) {
        margin-inline-start: -3px;
    }

    /* Tabs become a native-style bottom tab bar: pinned to the bottom, always
       visible, all five sharing the width as equal icon-over-label columns. */
    .tabs {
        position: fixed;
        left: 0;
        right: 0;
        bottom: 0;
        z-index: 40;
        display: flex;
        gap: 0;
        margin: 0;
        /* Glass, like every other bar content passes behind. The tab bar gains
           the most from it: it sits over the grid permanently, so an opaque
           strip across the bottom of a phone is a standing announcement that the
           posters stop there. */
        background: var(--chrome-tint);
        -webkit-backdrop-filter: var(--chrome-blur);
        backdrop-filter: var(--chrome-blur);
        /* Docked to the bottom, so it casts upward — the same reasoning as
           .sheet__panel, one tier lower because the tab bar sits under the trays
           on the z ladder. */
        box-shadow: 0 -2px 4px rgba(0, 0, 0, 0.32), 0 -6px 16px rgba(0, 0, 0, 0.28);
        border-top: 1px solid var(--border);
        border-bottom: none;
        padding-bottom: env(safe-area-inset-bottom);
    }

    .tab {
        flex: 1 1 0;
        min-width: 0;
        display: flex;
        flex-direction: column;
        align-items: center;
        gap: 4px;
        padding: 9px 2px;
        font-size: 0.68rem;
        text-align: center;
        border-bottom: none;
    }

    .tab__icon {
        display: block;
    }

    .tab__icon .icon {
        width: 26px;
        height: 26px;
    }

    .tab__text {
        display: none;
    }

    .tab__text--short {
        display: block;
        max-width: 100%;
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
    }

    .tab--active {
        color: var(--accent);
    }

    /* Reveal the menu button; the desktop Log out link moves into the tray. */
    .menu-btn {
        display: flex;
    }

    .topnav__desktop {
        display: none;
    }

    /* Search and sort are used continuously while browsing, so the toolbar stays
       pinned instead of scrolling away with the content. `sticky` rather than
       `fixed` keeps it in flow, so it holds its own space at the top of the page
       and needs no compensating padding on the grid below — padding that would
       have to be kept in sync with the bar's rendered height. (.tabs is `fixed`
       because it pins to the bottom, against the document's natural flow.)

       Two declarations here are load-bearing rather than cosmetic. The background
       is the bar's own surface — .toolbar has none otherwise, so without it the
       posters scroll straight through with nothing between them and the search
       field. The negative side margins, matched by equal padding, bleed the bar
       past .container's 14px gutters: inside them, posters show through a 14px
       channel down each edge while the contents stay on the content grid.

       The bleed survives the move to glass, and is the reason to keep watching
       it. Translucency changed what the surface owes — legibility rather than
       concealment — but it did not make an uncovered channel acceptable: an
       unblurred 14px strip of poster beside a blurred bar reads as a broken bar,
       which is worse than the flat one it replaced. Glass makes that channel
       harder to spot in review, not less wrong.
       StickyToolbarTest::testPinnedToolbarBleedsPastTheContentGutters guards it. */
    .toolbar {
        position: sticky;
        top: 0;
        z-index: 30;
        background: var(--chrome-tint);
        -webkit-backdrop-filter: var(--chrome-blur);
        backdrop-filter: var(--chrome-blur);
        margin: 0 -14px 8px;
        padding: 8px 14px;
    }

    /* The toolbar is just search plus the sort trigger; the rest moved to trays. */
    .search {
        flex: 1 1 auto;
    }

    .sort-trigger {
        display: inline-flex;
    }

    .sort {
        display: none;
    }

    /* At least two posters per row on a phone. */
    .grid {
        grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
        gap: 12px;
    }

    /* The Change Poster tray body is already the scroller, so the results grid
       does not need to be a second one nested inside it — two scrollers competing
       for the same few vh is how a flick ends up moving the wrong thing. Let the
       grid run at its natural height and scroll the tray instead. */
    .find-grid {
        grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
        max-height: none;
        overflow-y: visible;
    }

    /* The same reset, one level up, and the pair has to stay a pair.
       Overrides `.poster-groups`'s `max-height: 62vh` / `overflow-y: auto`.

       That cap is not a duplicate of the one above: it was MOVED here from
       `.find-grid` when the grouped stack was introduced, because a sticky
       heading can only travel inside its own scroll container and the headings
       had to pin across a whole stack of grids rather than inside one. The
       desktop rule was relocated; this mobile counterpart was not, and went on
       resetting the element that no longer scrolled. So the reasoning in the
       comment above survived the move and its coverage did not — which is the
       whole bug, not a detail of it.

       What that left was two scrollers in one tray, and containment made it
       worse rather than better: `overscroll-behavior: contain` on the inner one
       stops a flick at the end of the candidates instead of handing the rest to
       `.modal__body`, so the ~130px of grid that the panel clips could not be
       reached by the only gesture anyone would try. A dead second scrollbar sat
       next to the live one. The tray body is the scroller; nothing inside it
       may be a second one.

       The cap stays on the base rule on purpose. Above 640px `.modal__body`
       has no `overflow` at all — the rule 130 lines below is its only one — so
       there the stack IS the scroller and the sticky headings depend on it.
       The two presentations own the scroll at different levels; what must not
       happen is both at once. PosterGroupsTest pins both halves together. */
    .poster-groups {
        max-height: none;
        overflow-y: visible;
    }

    /* Infinite scroll replaces pagination on a phone. */
    .pagination {
        display: none;
    }

    /* …and with no pager, the line above the grid stops describing a page. See
       .stats__total. The two rules belong together: a phone showing the range
       would be naming a control hidden two lines below it, and naming a batch
       the reader scrolled past long ago. */
    .stats__range {
        display: none;
    }

    .stats__total {
        display: inline;
    }

    /* Lift toasts above the fixed bottom tab bar. */
    .toast {
        bottom: calc(88px + env(safe-area-inset-bottom));
    }

    /* Modals (delete confirmations, change poster) become app-style sheets: they
       dock to the bottom and slide up, show a grab handle instead of a close (×)
       button, can be swiped down to dismiss, and stack large full-width actions
       with the primary/destructive one on top.

       They take the *structure* of a tray here, not just its looks. Earlier this
       block restyled the panel alone: the handle was a ::before, and the panel
       both scrolled and was the drag target. Neither worked. A pseudo-element can
       never be a touch target, so the handle could not be grabbed; and a single
       element cannot both scroll and carry `touch-action: none`, so a downward
       drag was claimed by the browser as a scroll, chained out to the document,
       and became a page scroll or a pull-to-refresh — with the drag cancelled
       partway. Splitting grip / head / body the way .sheet does is what makes the
       gesture reliable, and it lets the existing handler in gallery.js drive
       these overlays with no special cases. */
    .modal {
        align-items: flex-end;
    }

    .modal__panel,
    .modal__panel--narrow,
    .modal__panel--wide {
        display: flex;
        flex-direction: column;
        width: 100%;
        max-width: none;
        /* Matches .sheet__panel. The panel used to be taller than any tray, which
           left too little backdrop above it to tap — and the backdrop is one of
           only two ways out, since these carry no close button on a phone. */
        max-height: 85vh;
        overflow: hidden;
        border-radius: var(--radius-xl) var(--radius-xl) 0 0;
        /* The base rule gives a dialog the downward --elev-4. A dialog docked to
           the bottom edge throws that shadow off-screen and leaves its raised top
           edge — the only edge visible here — flat. Turned over, at the dialog
           tier's weights, so it still outranks the tray it may have been raised
           from. */
        box-shadow: 0 -6px 12px rgba(0, 0, 0, 0.40), 0 -20px 48px rgba(0, 0, 0, 0.42);
        padding: 0;
        transition: transform var(--dur-base) var(--ease-standard);
    }

    /* A dialog is a tray at this width, so it has to arrive and leave like one.
       The base rules scale .modal__panel from 0.96; that is the wrong movement
       for something docked to the bottom edge, and it also leaves the panel
       fully visible at the start of its entrance instead of off-screen. */
    .overlay-opening .modal__panel {
        transition: transform var(--dur-slow) var(--ease-entrance);
    }

    .overlay-shut .modal__panel {
        transform: translateY(100%);
    }

    .overlay-shown .modal__panel {
        transform: translateY(0);
    }

    .modal .sheet__grip {
        display: flex;
    }

    .modal__close {
        display: none;
    }

    /* The 14px above is .sheet__head's, deliberately. A dialog is a tray at this
       width and wears the same grab handle, so it has to sit the same distance
       below it — but the two rules are 400 lines apart and nothing else connects
       them, so the number has to be kept in step by hand. It was 2px, which put a
       dialog's title 9px closer to the handle than a tray's and made the two
       families read as misaligned when opened one after the other.

       TrayHeadSpacingTest asserts the two agree. */
    .modal__head {
        flex: 0 0 auto;
        padding: 14px 18px 12px;
        touch-action: none;
    }

    /* One title line tall, so the 40px heart tile stops setting the height of
       the head it sits in.

       It used to. `.support-ask__head` is a `1fr auto 1fr` grid, and a grid row
       sizes to its tallest occupant, so the tile — not the title — decided how
       tall the row was, and the title centred 5.9px lower than a title in any
       other tray. That is invisible from inside this head, which looks perfectly
       balanced on its own; it only shows when the tray is opened after another
       one. Any future head that adds an icon or a badge would do the same.

       `1lh` resolves against the element it is written on, NOT against the grid
       item holding the title — which is why `font-size` is declared here too.
       Without it the head keeps the inherited 16px, `1lh` computes 25.6px
       instead of 28.2px, and the row is 2.5px short: still wrong, still
       plausible-looking in the source. The two declarations are one decision.

       The tile keeps its 40px and overflows the row by 5.9px above and below,
       centred on the line its heading occupies. 5.9px is less than the head's
       14px of padding, so it clears the handle by 8.1px — where before it came
       within 2px of it, close enough to read as a mistake.

       Mobile only. The base rule still lets the tile size the row above 640px,
       where this is a centred dialog rather than a tray; the rendered result is
       the same tile centred against the same heading. */
    .support-ask__head {
        font-size: 1.1rem;
        grid-template-rows: 1lh;
    }

    .modal__body {
        flex: 1 1 auto;
        min-height: 0;
        overflow-y: auto;
        overscroll-behavior: contain;
        padding: 0 18px calc(20px + env(safe-area-inset-bottom));
    }

    .modal__actions {
        flex-direction: column-reverse;
        gap: 8px;
        margin-top: 22px;
    }

    .modal__actions form {
        display: block;
        width: 100%;
    }

    .modal__actions .btn,
    .modal__actions form .btn {
        width: 100%;
        padding: 13px 12px;
        font-size: 0.95rem;
        text-align: center;
    }

    /* Native-app styling for the import form inside its tray: the media-type
       choices become selectable pills and the libraries become tappable rows,
       instead of bare radio buttons and checkboxes. Scoped to a tray body so the
       desktop /plex page keeps its own form styling. */

    /* The reset has to be complete, and `box-shadow` is the line that looks
       removable. A panel is a surface — background, border, radius and the
       --elev-2 that traces its edge — and this rule flattens it so the tray's own
       surface shows through instead. Leave the shadow behind and it goes on
       drawing the outline of a rectangle that is no longer painted: a halo around
       nothing, which reads as a rendering fault rather than as depth. The import
       form and the orphans empty state are both hit, and the empty state shows it
       worst — one line of text and a button, with daylight between the halo and
       any real edge. See visual-design: elevation is drawn only where a surface
       is drawn. */
    .sheet__body .panel {
        background: none;
        border: none;
        box-shadow: none;
        padding: 0;
    }

    .sheet__body .fieldset {
        border: none;
        padding: 0;
        margin: 0 0 18px;
    }

    .sheet__body .fieldset legend {
        padding: 0;
        margin-bottom: 10px;
        font-weight: 600;
        color: var(--ink);
    }

    .sheet__body .choice-row {
        display: flex;
        flex-wrap: wrap;
        gap: 8px;
    }

    /* Build on the base pill (.choice span) — just make the pills fill the row
       and grow the tap target; the base handles the checked highlight. */
    .sheet__body .choice {
        flex: 1 1 auto;
        display: flex;
    }

    .sheet__body .choice span {
        flex: 1;
        padding: 13px 16px;
    }

    .sheet__body .check {
        display: flex;
        align-items: center;
        flex-wrap: wrap;
        gap: 10px;
        padding: 13px 14px;
        margin-bottom: 8px;
        border: 1px solid var(--border);
        border-radius: var(--radius-md);
        background: var(--surface-2);
        cursor: pointer;
    }

    .sheet__body .check input {
        width: 20px;
        height: 20px;
        flex: 0 0 auto;
        accent-color: var(--accent);
    }

    .sheet__body .check__meta {
        width: 100%;
        padding-left: 30px;
        color: var(--muted);
        font-size: 0.82rem;
    }

    .sheet__body .check__hint {
        color: var(--muted);
        font-size: 0.85rem;
    }

    .sheet__body form .btn--accent {
        width: 100%;
        padding: 14px;
        font-size: 1rem;
        margin-top: 8px;
    }
}

/* ==========================================================================
   Reduced motion.

   One blanket rule rather than a list of selectors. The list this replaced named
   five elements and had to be extended by hand every time anything new was
   animated — which is a rule that is wrong by default, and silently: nothing
   fails when an animation is added and not listed, it simply keeps moving for
   the people who asked it not to. Placed near the end of the file so it beats
   the component rules it overrides; `!important` covers the rest.

   Durations collapse to 0.01ms, NOT to `none`. `transition: none` suppresses
   `transitionend` outright and a zero-length animation can skip `animationend`,
   so anything waiting on either event would wait forever. A hundredth of a
   millisecond is imperceptible and still fires both. The state change itself is
   untouched either way: a hovered button reaches its hover appearance, it just
   arrives there instead of travelling.

   No `scroll-behavior`, though the usual form of this snippet carries one. This
   stylesheet declares scroll behaviour nowhere, deliberately: both smooth scrolls
   in the application are per-call `behavior` options in gallery.js which read the
   preference themselves, and a CSS rule would also catch the scroll-lock restore
   and animate the page sliding back after every tray dismissal. Even `auto` here
   would be stating a policy that belongs to the caller.
   PaginationScrollTest::testTheSmoothScrollStaysLocalToTheHelper enforces that.
   ========================================================================== */
@media (prefers-reduced-motion: reduce) {
    *,
    *::before,
    *::after {
        animation-duration: 0.01ms !important;
        animation-iteration-count: 1 !important;
        transition-duration: 0.01ms !important;
    }

    /* Exempt: progress. These two report that work is happening, and freezing
       them says the opposite — a stopped spinner over a running import reads as
       an import that has hung. Reduced motion asks for less decoration, not for
       less information, so they keep their tempo.

       Re-declared as whole shorthands because the blanket rule above sets both
       duration and iteration-count; restoring either alone would leave the other
       clamped and the animation would run once, fast, and stop. */
    .spinner {
        animation: spin var(--tempo-spin) linear infinite !important;
    }

    .card__frame::before,
    .find-item__frame::before,
    .viewer__placeholder {
        animation: shimmer var(--tempo-shimmer) linear infinite !important;
    }
}

/* ==========================================================================
   Backdrop-filter fallback. Placed after the mobile block — which is itself
   placed last so it wins at equal specificity — because the phone toolbar and
   tab bar are declared in there and this has to beat them too. @supports adds no
   specificity of its own, so source order is the whole mechanism.

   Every rule in the file that uses --chrome-blur or --backdrop-blur MUST have an
   entry here. A glass surface with no fallback does not degrade to the old flat
   bar; it degrades to a translucent one with nothing blurring behind it, which
   is a poster grid showing through at nearly full strength under the search
   field. That is a legibility failure, not a cosmetic one.

   The condition tests both spellings. Safari carried -webkit-backdrop-filter
   alone for six major versions, and testing the unprefixed property by itself
   would send every one of those browsers down this path and make them opaque
   while the blur they do support goes unused. */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
    /* Chrome falls back to the page colour. --chrome-tint is --bg carrying an
       alpha, so an opaque --bg is what the glass composited to anyway — the depth
       is lost, the colour is not. */
    .topbar {
        background: var(--bg);
    }

    /* .toolbar and .tabs carry a background only at phone width — on desktop they
       are transparent children of .gallery-head, which is handled above. Scoped
       to match, so this block never hands a desktop element a surface it does not
       have. */
    @media (max-width: 640px) {
        .toolbar,
        .tabs {
            background: var(--bg);
        }
    }

    /* Backdrops dim harder to compensate. Without the blur, the page behind stays
       fully legible and goes on competing with the dialog in front of it; the
       extra darkness is what does that job instead. */
    .modal__backdrop,
    .sheet__backdrop {
        background: rgba(0, 0, 0, 0.74);
    }

    .overlay {
        background: rgba(0, 0, 0, 0.85);
    }
}
