:root {
    color-scheme: light dark;
    --bg: #0f1115;
    --panel: #1a1d24;
    --panel-border: #2a2e37;
    --text: #e6e8eb;
    --text-dim: #9aa1ac;
    --accent: #5b8cff;
    --danger: #e05252;
    --radius: 10px;
}

* {
    box-sizing: border-box;
}

/* Every page renders inside this: the banners at the top, then whatever the
   router chose, filling what is left. `min-height` rather than `height` so a
   long dashboard still scrolls the document normally; the full-height pages
   below take the remainder and scroll inside themselves instead.

   `dvh` so a browser's own collapsing toolbar does not leave a strip of the
   page permanently off-screen — in the installed app it is the same as `vh`. */
.app-shell {
    display: flex;
    flex-direction: column;
    min-height: 100vh;
    min-height: 100dvh;
}

body {
    margin: 0;
    font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
    background: var(--bg);
    color: var(--text);
}

/* Sits above every page (`app.rs`'s `Dashboard` renders it outside the
   RSS/Lectures/grid branch, so it shows on all three). `sticky` rather than
   `fixed` so it pushes the layout down instead of covering the first widget,
   and stays in view while scrolling a long article. */
.offline-banner {
    position: sticky;
    top: 0;
    z-index: 50;
    padding: 0.5rem 0.9rem;
    background: var(--danger);
    color: #ffffff;
    font-size: 0.85rem;
    text-align: center;
}

/* Same strip as the offline banner, in the accent colour rather than the
   danger one: nothing is wrong, there is simply a newer build to pick up
   (see `components::update`). */
.update-banner {
    position: sticky;
    top: 0;
    z-index: 50;
    padding: 0.5rem 0.9rem;
    background: var(--accent);
    color: #ffffff;
    font-size: 0.85rem;
    text-align: center;
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 0.6rem;
}

.update-banner button {
    padding: 0.15rem 0.6rem;
    border: 1px solid rgba(255, 255, 255, 0.7);
    border-radius: 999px;
    background: transparent;
    color: inherit;
    font: inherit;
    cursor: pointer;
}

.update-banner button:hover {
    background: rgba(255, 255, 255, 0.15);
}

.dashboard {
    width: 100%;
    min-height: 100vh;
    margin: 0;
    padding: 0.6rem;
    display: flex;
    align-items: stretch;
    gap: 0.6rem;
}

/* Capped to the viewport (minus `.dashboard`'s own top/bottom padding) and
   pinned there via `position: sticky` — per user request 2026-08-08, fixing
   a real bug where the sidebar stretched (`.dashboard`'s `align-items:
   stretch`) to match `.main-grid`'s own height, which can exceed the
   viewport, forcing the whole page (sidebar included) to scroll just to
   reach e.g. the account bar at the bottom. `.sidebar .shortcuts-sidebar-
   list`'s own `flex: 1; overflow-y: auto` (below) already handles "too many
   shortcuts to fit" *within* this now-fixed height — that's the one place
   scrolling should happen, not the whole sidebar or the other widgets in
   it. Reset back to normal flow under 900px (see the media query below) —
   the mobile layout stacks sidebar above main-grid in one column, where a
   viewport-height sidebar would just push everything below it off-screen. */
/* `z-index: 1` fixes a real bug found live 2026-08-08: `.sidebar` and
   `.main-grid` are both flex items of `.dashboard`, and flex items paint in
   DOM order when `z-index: auto` (per the flexbox spec, flex items paint
   like inline-blocks — the usual "non-positioned content paints before
   positioned content" rule from CSS2.1 doesn't apply here). `.sidebar`
   comes *before* `.main-grid` in the DOM, so with both at the default
   `auto`, `.main-grid` (e.g. the RATP widget's line badges) painted on top
   of anything inside `.sidebar` — including `PlayerWidget`'s
   `.fullscreen-backdrop` (its own `z-index: 200` only matters *within*
   `.sidebar`'s own stacking context, which `position: sticky` forces to
   exist, per the CSS spec listing `sticky` among the position values that
   always create one — that internal z-index can never let it escape to
   compete with `.main-grid` at the root level). An explicit z-index here
   moves `.sidebar` out of the "auto" bucket so it paints by z-index instead
   of DOM order, above `.main-grid`. */
.sidebar {
    flex: 0 0 260px;
    height: calc(100vh - 1.2rem);
    position: sticky;
    top: 0.6rem;
    z-index: 1;
    display: flex;
    flex-direction: column;
    gap: 0.6rem;
    min-width: 0;
}

.sidebar .widget {
    flex: 1;
    display: flex;
    flex-direction: column;
    min-height: 0;
}

/* `.shortcuts-sidebar-overview` (the plain wrapper around every category
   group, `ShortcutsSidebarOverview`'s own root element) is the actual
   single scrollable region — NOT `.shortcuts-sidebar-list`, which is one
   small per-category list nested two levels inside it and was never the
   right target for this rule (a stale mismatch from before category
   grouping existed; each little list independently getting `flex: 1;
   overflow-y: auto` inside a plain, non-flex `.shortcuts-sidebar-group`
   parent did nothing — per user bug report 2026-08-10, "je n'ai toujours
   aucun scroll... ça passe sous les blocs suivants"). `min-height: 0` is
   required too (not just present on `.sidebar .widget` above) — a flex
   item's default `min-height: auto` refuses to shrink below its own
   content size, the classic reason `overflow-y: auto` alone silently does
   nothing inside a flex column. */
.sidebar .shortcuts-sidebar-overview {
    flex: 1;
    min-height: 0;
    overflow-y: auto;
}

.main-grid {
    flex: 1;
    min-width: 0;
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 0.6rem;
    align-content: start;
}

.main-grid > * {
    min-width: 0;
}

.main-grid > .grid-full {
    grid-column: 1 / -1;
}

@media (max-width: 900px) {
    .dashboard {
        flex-direction: column;
    }

    .sidebar {
        flex: none;
        height: auto;
        position: static;
    }

    .main-grid {
        grid-template-columns: 1fr;
    }
}

.widget {
    display: flex;
    flex-direction: column;
    background: var(--panel);
    border: 1px solid var(--panel-border);
    border-radius: var(--radius);
    padding: 1rem 1.25rem;
}

.widget-body {
    flex: 1;
    display: flex;
    flex-direction: column;
    min-height: 0;
}

/* The compact catalog row shared by `CinemaCatalog`/`SpectaclesCatalog`/
   `BooksCatalog` — applied identically to the populated poster-row `<div>`,
   the loading `<p>`, and the empty-state `<div>` (see `EmptyCatalogRow`),
   so switching to an empty or short category (or the brief loading flash)
   doesn't shrink the widget and shift the rest of the page — per user
   request 2026-08-08 ("si on sélectionne une catégorie vide... la section
   devient beaucoup plus petite ce qui fait bouger tout l'écran"). A
   definite `height` (not just a `min-height` floor) plus `margin: 0`
   (neutralizing the loading `<p>`'s default browser margin, which the
   populated `<div>` never had) plus `.poster-title`'s own reserved height
   above make this exact across all three states — a floor alone still let
   the row grow/shrink a few pixels depending on content, which is what
   the user meant by "elle varie légèrement" on a second pass. Height
   covers a compact poster (90px-wide button, 2:3 cover + 2 reserved title
   lines ≈ 170px) plus this row's own bottom padding and headroom for a
   non-overlay horizontal scrollbar. `gap`/`overflow-x`/`padding-bottom`
   used to be a duplicated inline style on each of the 3 populated-row
   `<div>`s — centralized here so every state shares one box model. */
.catalog-row {
    height: 200px;
    margin: 0;
    display: flex;
    align-items: center;
    gap: 0.75rem;
    overflow-x: auto;
    padding-bottom: 0.6rem;
}

/* The "no results" state (see `EmptyCatalogRow`) — same box as a populated
   row, just centered column content instead of a horizontal poster strip.
   Per user request 2026-08-08 ("essaie de trouver quelque chose pour mieux
   remplir l'espace visuellement"). */
.catalog-empty {
    flex-direction: column;
    justify-content: center;
    width: 100%;
    color: var(--text-dim);
    text-align: center;
}

.catalog-empty svg {
    font-size: 2.2rem;
    opacity: 0.5;
}

.catalog-empty p {
    margin: 0.5rem 0 0;
    font-size: 0.85rem;
}

.widget h2 {
    margin: 0 0 0.75rem;
    font-size: 1rem;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: var(--text-dim);
}

.widget-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.5rem;
    margin: 0 0 0.75rem;
}

.widget-header h2 {
    margin: 0;
}

/* A clickable title for widgets that switch between two data sources
   (currently: Cinéma/Spectacles, Radios/Podcasts) instead of a plain
   `<h2>` — same typography as `.widget h2` (uppercase, letter-spacing,
   muted color) so it still *reads* as a title, but the active side
   brightens to `--text` to double as the tab indicator. Deliberately has
   none of `.ghost`'s pill chrome (border/background/padding) so it doesn't
   get visually confused with the filter tabs directly below it. `.widget
   .media-section-tab`/`.fullscreen-header .media-section-tab` (two classes
   each), not `.media-section-tab` alone — same trap as `.widget
   .icon-button` above: a single class loses to the plainer-looking but
   higher-specificity `.widget button`/`.fullscreen-panel button`
   element+class selectors and silently keeps its blue fill.
   `media_tab_row`'s callers pass it both as a widget's own `.widget-header`
   content (already inside `.widget`, e.g. `MediaWidget`) *and* as
   `FullscreenPanel`'s `title_content` — the latter only works when that
   widget also happens to nest `<FullscreenPanel>` inside its own `.widget`
   wrapper (true for `MediaWidget`, false for `player::PlayerWidget`, whose
   compact form deliberately has no `.widget` wrapper of its own — see
   `.radio-player`), hence the separate `.fullscreen-header` scoping so it
   works regardless of that nesting detail. `.audio-page-header` is the third
   place the same switcher appears — the standalone audio app's own header,
   which is a `.fullscreen-panel` without a `.fullscreen-header` inside it —
   and it needs naming here for exactly the same reason: left out, both tabs
   kept `.fullscreen-panel button`'s blue fill and neither read as selected.
   */
.widget .media-section-tab,
.fullscreen-header .media-section-tab,
.audio-page-header .media-section-tab {
    background: transparent;
    border: none;
    box-shadow: none;
    padding: 0;
    font-size: 1rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: var(--text-dim);
}

.widget .media-section-tab.active,
.fullscreen-header .media-section-tab.active,
.audio-page-header .media-section-tab.active {
    color: var(--text);
}

.widget .media-section-tab:hover,
.fullscreen-header .media-section-tab:hover,
.audio-page-header .media-section-tab:hover {
    background: transparent;
    box-shadow: none;
    color: var(--text);
}

.widget .media-section-tab:active,
.fullscreen-header .media-section-tab:active,
.audio-page-header .media-section-tab:active {
    transform: none;
    background: transparent;
    box-shadow: none;
}

.media-section-tab-sep {
    color: var(--text-dim);
    font-weight: 400;
}

/* `.widget button` (below) sets a blue fill + square corners on every
   button by default; `.widget .icon-button` (two classes) is needed to
   out-specificity that plain-element-combined selector — a single
   `.icon-button` class alone is lower specificity and silently lost, same
   trap as `.widget .movie-poster-button` elsewhere in this file. */
.widget .icon-button,
.modal-panel .icon-button,
.sidebar-account-bar .icon-button,
.radio-player .icon-button,
.fullscreen-panel .icon-button {
    background: transparent;
    border: 1px solid var(--panel-border);
    color: var(--text-dim);
    width: 26px;
    height: 26px;
    min-width: 26px;
    padding: 0;
    border-radius: 50%;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    font-size: 1rem;
    line-height: 1;
    cursor: pointer;
    transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease;
}

.widget .icon-button:hover,
.modal-panel .icon-button:hover,
.sidebar-account-bar .icon-button:hover,
.radio-player .icon-button:hover,
.fullscreen-panel .icon-button:hover {
    color: var(--text);
    border-color: var(--accent);
    background: rgb(91 140 255 / 10%);
}

.modal-backdrop {
    display: none;
    position: fixed;
    inset: 0;
    align-items: center;
    justify-content: center;
    padding: 1rem;
    background: rgba(0, 0, 0, 0.55);
    /* Above `.fullscreen-backdrop` (z-index 200) — a modal opened from
       within a fullscreen panel (e.g. "add shortcut") must render on top of
       it, not disappear behind it. */
    z-index: 300;
}

.modal-backdrop.open {
    display: flex;
}

.modal-panel {
    width: 100%;
    max-width: 420px;
    max-height: calc(100vh - 4rem);
    overflow-y: auto;
    background: var(--panel);
    border: 1px solid var(--panel-border);
    border-radius: var(--radius);
    padding: 1.25rem;
}

.modal-panel.wide {
    max-width: 760px;
}

/* One step wider than the default, nowhere near `wide`: enough that a row of
   six controls leaves its name field something to read (user request
   2026-08-27). */
.modal-panel.roomy {
    max-width: 540px;
}

/* `.widget button` (below) sets a blue background on every button; this
   needs to out-specificity it (2 classes beats 1 class + 1 element) rather
   than just relying on declaration order. `.widget button:hover` (also
   below) needs the same treatment separately — adding `:hover` there
   raises it to 2 classes + 1 element, which otherwise wins back over this
   rule's hover state. */
.widget .movie-poster-button {
    display: block;
    width: 100%;
    padding: 0;
    background: none;
    border: none;
    text-align: left;
    cursor: pointer;
    color: inherit;
    font: inherit;
}

.widget .movie-poster-button:hover {
    background: none;
    box-shadow: none;
}

.movie-poster-button img {
    transition: opacity 0.15s ease;
}

.movie-poster-button:hover img {
    opacity: 0.8;
}

/* Clamped to 2 lines with an ellipsis rather than left to wrap freely — a
   long title (movie or spectacle) would otherwise grow that poster's row
   taller than its neighbors and visibly distort the row. `min-height:
   2.4em` (2 lines at `line-height: 1.2`) reserves that space even when the
   title is short enough to only need 1 line — without it, a poster's own
   height (and therefore `.catalog-row`'s) varied by up to a line's height
   depending on which titles happened to wrap, which is what made the
   widget's height visibly shift between different result sets (per user
   request 2026-08-08, "la hauteur de ces sections... varie légèrement"). */
.poster-title {
    margin: 0.25rem 0 0;
    font-size: 0.8rem;
    line-height: 1.2;
    min-height: 2.4em;
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
}

/* More vertical room in the fullscreen grid than the compact overview's
   narrow horizontal strip, so 3 lines fit comfortably before truncating. */
.poster-title-lg {
    -webkit-line-clamp: 3;
}

.poster-placeholder {
    width: 100%;
    aspect-ratio: 2 / 3;
    border-radius: 6px;
    background: var(--panel-border);
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 0.4rem;
    font-size: 0.7rem;
    color: var(--text-dim);
    text-align: center;
}

.movie-poster-button.watched img,
.movie-poster-button.watched .poster-placeholder {
    opacity: 0.4;
}

.poster-badge {
    position: absolute;
    top: 0.3rem;
    width: 1.3rem;
    height: 1.3rem;
    border-radius: 50%;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 0.7rem;
    background: rgba(15, 17, 21, 0.85);
    pointer-events: none;
}

.poster-badge-wishlist {
    right: 0.3rem;
    color: var(--accent);
}

.poster-badge-watched {
    left: 0.3rem;
    color: var(--text-dim);
}

.video-embed {
    position: relative;
    width: 100%;
    padding-top: 56.25%;
    margin: 0.75rem 0;
    border-radius: 8px;
    overflow: hidden;
    background: #000;
}

.video-embed iframe {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    border: 0;
}

/* The player inside an article's reading pane sits at two thirds width (per
   user request 2026-08-18) — full width dominated the pane and pushed the
   description below the fold. `aspect-ratio` replaces the shared rule's
   `padding-top` hack above: that percentage resolves against the
   *container's* width, so it stops describing 16:9 the moment the element
   is narrower than its container. `min-width` keeps it usable once the
   resizable pane gets narrow. */
.rss-reading-pane .video-embed {
    width: 66%;
    min-width: min(100%, 320px);
    padding-top: 0;
    aspect-ratio: 16 / 9;
}

/* `.events-view-tabs` shares this rather than getting a near-identical copy:
   the two are the same control (a row of `.ghost` tabs above a list), only in
   different panels. */
.cinema-view-tabs,
.events-view-tabs {
    display: flex;
    flex-wrap: wrap;
    gap: 0.5rem;
    margin-bottom: 0.75rem;
}

/* Le filtre secondaire par matière, sous l'onglet « Conférence » (les autres
   catégories n'en ont pas). Volontairement plus discret que
   `.cinema-view-tabs` juste au-dessus : deux rangées de boutons de même poids
   se disputeraient la lecture, alors que celle-ci affine ce que l'autre a
   choisi. D'où des puces plates, sans le fond bleu de `.widget button` — qu'il
   faut désactiver explicitement, même piège de spécificité que
   `.widget .movie-poster-button`. */
.spectacle-theme-tabs {
    display: flex;
    flex-wrap: wrap;
    gap: 0.35rem;
    margin: -0.35rem 0 0.75rem;
}

.widget .spectacle-theme-tab,
.spectacle-theme-tab {
    padding: 0.15rem 0.5rem;
    font-size: 0.75rem;
    line-height: 1.4;
    border: 1px solid var(--panel-border);
    border-radius: 999px;
    background: none;
    color: var(--text-dim);
    cursor: pointer;
}

.widget .spectacle-theme-tab:hover,
.spectacle-theme-tab:hover {
    background: var(--panel-border);
    box-shadow: none;
    color: var(--text);
}

.widget .spectacle-theme-tab.active,
.spectacle-theme-tab.active {
    border-color: var(--accent);
    color: var(--accent);
}

/* Les retours à la ligne simples d'un synopsis sont signifiants — listes à
   puces, découpage séance par séance d'un cycle — et les règles d'espacement
   HTML les écraseraient. `pre-wrap` et non `pre` : le texte doit continuer à
   se replier sur la largeur de la modale. Les lignes vides, elles, restent
   traitées en amont, qui en fait des paragraphes séparés. */
.spectacle-description {
    white-space: pre-wrap;
}

/* Unlike the widget's `.widget-list-scroll`, this one is not height-capped —
   the fullscreen panel is already the scroll container, and a nested scroll
   area inside it traps the wheel. */
.events-fullscreen-list {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 0.35rem;
}

.movie-title-row {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: space-between;
    gap: 0.5rem;
}

.movie-actions {
    display: flex;
    flex-wrap: wrap;
    gap: 0.5rem;
}

.movie-genres {
    display: flex;
    flex-wrap: wrap;
    gap: 0.35rem;
    margin: 0.5rem 0;
}

.widget .movie-cinemas li {
    flex-direction: column;
    align-items: flex-start;
    gap: 0.3rem;
}

.movie-showtimes-days {
    display: flex;
    flex-direction: column;
    gap: 0.4rem;
    width: 100%;
}

/* A capped list's "show N more" affordance — plain text + chevron rather
   than a bordered `.ghost` button (same reasoning as
   `.widget .shortcuts-category-toggle` above: it should read as a subtle
   reveal-more link, not an action competing with the real buttons — booking
   links, "+" event buttons — in the same section). `margin-top` gives it
   breathing room from the last visible row above it, two classes
   (`.widget .show-more-toggle`) for the same `.widget button` specificity
   reason documented throughout this file. */
.widget .show-more-toggle {
    display: inline-flex;
    align-items: center;
    gap: 0.3rem;
    margin: 0.5rem 0 0 -0.45rem;
    padding: 0.15rem 0.45rem;
    background: none;
    border: none;
    border-radius: 999px;
    box-shadow: none;
    color: var(--accent);
    font-size: 0.85rem;
    cursor: pointer;
    transition: background 0.15s ease, color 0.15s ease;
}

/* A soft accent-tinted pill (same tint as `.widget .icon-button:hover`)
   reads as a much cleaner hover cue here than an underline, which fights
   with the chevron and the surrounding pill/chip-shaped controls elsewhere
   in these sections. */
.widget .show-more-toggle:hover {
    background: rgb(91 140 255 / 10%);
    box-shadow: none;
    color: var(--text);
}

/* Bare informational links (a registered cinema's name, "Voir la fiche...",
   "Trouver une séance...") have no other class to inherit styling from, so
   without this they fall back to the browser's default blue/underline/
   visited-purple — jarring against this dark theme. */
.widget .plain-link {
    color: var(--text);
    text-decoration: none;
}

.widget .plain-link:hover {
    color: var(--accent);
    text-decoration: underline;
}

.show-more-chevron {
    display: inline-block;
    font-size: 0.65rem;
    transition: transform 0.15s ease;
}

.show-more-chevron.expanded {
    transform: rotate(180deg);
}

/* List/grid view switcher — a single bordered pill containing both buttons
   (rather than two separate `.ghost` buttons) so it reads as one control
   with two mutually-exclusive states, not two independent actions. Icon-only
   (no text label) needs the extra specificity (`.widget .view-mode-toggle
   button`, three selectors deep) to beat `.widget button`'s default blue
   fill — same trap as `.widget .icon-button` elsewhere in this file. */
.widget .view-mode-toggle {
    display: inline-flex;
    align-items: center;
    border: 1px solid var(--panel-border);
    border-radius: 6px;
    overflow: hidden;
}

.widget .view-mode-toggle button {
    display: flex;
    align-items: center;
    justify-content: center;
    background: none;
    border: none;
    border-radius: 0;
    box-shadow: none;
    padding: 0.4rem 0.55rem;
    color: var(--text-dim);
    cursor: pointer;
}

.widget .view-mode-toggle button + button {
    border-left: 1px solid var(--panel-border);
}

.widget .view-mode-toggle button:hover {
    background: rgb(91 140 255 / 10%);
    box-shadow: none;
    color: var(--text);
}

.widget .view-mode-toggle button.active,
.widget .view-mode-toggle button.active:hover {
    background: var(--accent);
    color: #fff;
}

/* Drawn in CSS rather than a unicode glyph (☰/▦ render inconsistently
   across fonts and look muddy at this size) — crisp bars/squares filled
   with `currentColor` so they automatically pick up the button's own
   text/active color without a separate rule. */
.view-icon {
    display: grid;
    width: 15px;
    height: 15px;
}

.view-icon-list {
    align-content: space-between;
}

.view-icon-list span {
    display: block;
    height: 2px;
    border-radius: 1px;
    background: currentColor;
}

.view-icon-grid {
    grid-template-columns: repeat(2, 1fr);
    grid-template-rows: repeat(2, 1fr);
    gap: 3px;
}

.view-icon-grid span {
    display: block;
    border-radius: 2px;
    background: currentColor;
}

.movie-showtimes-chips {
    display: flex;
    flex-wrap: wrap;
    gap: 0.3rem;
    margin-top: 0.15rem;
}

.movie-showtime-chip-group {
    display: inline-flex;
    align-items: center;
    gap: 0.15rem;
}

/* `.widget button` (above) sets a blue background + padding on every
   button; out-specificity it the same way `.widget .movie-poster-button`
   does, rather than relying on declaration order. */
.widget .chip-add-event {
    width: 1.25rem;
    height: 1.25rem;
    min-width: 1.25rem;
    padding: 0;
    border-radius: 50%;
    border: 1px solid var(--panel-border);
    background: transparent;
    color: var(--text-dim);
    font-size: 0.75rem;
    line-height: 1;
    cursor: pointer;
}

.widget .chip-add-event:hover {
    color: var(--text);
    border-color: var(--accent);
    background: transparent;
}

.widget .chip-add-event.added {
    color: var(--accent);
    border-color: var(--accent);
    cursor: default;
}

.movie-section-title {
    margin: 1rem 0 0.4rem;
    font-size: 0.85rem;
    color: var(--text-dim);
    text-transform: uppercase;
    letter-spacing: 0.04em;
}

.modal-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.5rem;
    margin-bottom: 0.9rem;
}

.modal-header h3 {
    margin: 0;
    font-size: 1rem;
}

.modal-panel form {
    display: flex;
    flex-direction: column;
    align-items: stretch;
    gap: 0.6rem;
    margin-top: 0;
}

/* A trailing "Supprimer" button (edit-todo/-event/-shopping-item/-shortcut
   modals all follow this exact shape: a `.stacked-form` then a standalone
   `.ghost` delete button) sat with zero space from the form's own last
   button above it — `.modal-panel form`'s `gap` only spaces the form's
   *own* children, not a sibling after the form closes. */
.modal-panel form + button.ghost {
    margin-top: 0.75rem;
}

/* Same layout as `.modal-panel form` above, for the non-`<form>` wrapper
   `StepUpPrompt` uses in its `WebAuthn`-primary branch (a plain `<div>`,
   since that branch has no fields to submit) — without this, a follow-up
   button placed right after the TOTP form (e.g. "utiliser ma clé de
   sécurité") had no spacing from it at all. */
.modal-panel .stacked-form {
    display: flex;
    flex-direction: column;
    align-items: stretch;
    gap: 0.6rem;
}

/* Same layout, for forms living directly inside a `FullscreenPanel`'s body
   rather than a `.widget`/`.modal-panel` — e.g. the radio player's search
   box. Without this, `<form class="stacked-form">` had no flex layout at
   all in that context (plain block flow, input and button just stacked
   with no gap). */
.fullscreen-panel form,
.fullscreen-panel .stacked-form {
    display: flex;
    flex-direction: column;
    align-items: stretch;
    gap: 0.6rem;
}

.fullscreen-backdrop {
    display: none;
    position: fixed;
    inset: 0;
    align-items: center;
    justify-content: center;
    /* Just enough inset to keep the panel reading as a panel; it used to be
       2rem, which cost 4rem of a screen these views are always short of. */
    padding: 1rem;
    background: rgba(0, 0, 0, 0.7);
    z-index: 200;
}

.fullscreen-backdrop.open {
    display: flex;
}

.fullscreen-panel {
    width: 100%;
    height: 100%;
    /* Per user request 2026-08-17 — "élargis un peu les modales full-screen
       pour qu'on ait plus de place sur cet écran". Capped rather than
       unbounded so text-heavy panels (the address book, the RSS reader)
       don't stretch a line of prose across a 34" display. */
    max-width: 1800px;
    display: flex;
    flex-direction: column;
    background: var(--panel);
    border: 1px solid var(--panel-border);
    border-radius: var(--radius);
    padding: 1.25rem 1.5rem;
    overflow-y: auto;
}

.fullscreen-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.5rem;
    margin-bottom: 1rem;
    flex: none;
}

.fullscreen-header h2 {
    margin: 0;
    font-size: 1.2rem;
}

.fullscreen-header-actions {
    display: flex;
    align-items: center;
    gap: 0.35rem;
}

.fullscreen-body {
    flex: 1;
    min-height: 0;
}

.widget.loading,
.widget.error {
    color: var(--text-dim);
}

.widget.error {
    color: var(--danger);
}

.widget ul {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
}

/* Caps the compact Tâches/Événements bodies at ~10 rows, scrolling
   internally past that instead of growing the widget (and therefore the
   whole grid row it sits in) indefinitely — a long list must not distort
   the rest of the dashboard's layout. Fullscreen views are unaffected
   (`.fullscreen-panel` already scrolls on its own). Applied directly to a
   `<ul>` (events) or to a wrapping `<div>` around more than one `<ul>`
   (tasks: recurring + one-off todos share a single scroll region). For
   events, "10 rows" already covers day-separator rows too — they're plain
   `<li>`s in the same `<ul>`, no special-casing needed. Shopping gets its
   own, shorter `.shopping-list-scroll` below. */
.widget-list-scroll {
    /* Empirically sized to exactly 10 rows at this list's actual row height
       (20px + 0.5rem/8px gap), plus a little slack for the Tâches tab's
       recurring-tasks `<ul>` (its `margin-bottom` still takes up space even
       when empty) — not a round rem value on purpose, see git history for
       the measurement. */
    max-height: 288px;
    overflow-y: auto;
}

/* Same idea as `.widget-list-scroll`, deliberately ~2 rows shorter for the
   shopping widget specifically. Empirically sized the same way (8 rows at
   this list's 20px + 8px-gap row height, no extra slack needed here). */
.shopping-list-scroll {
    max-height: 232px;
    overflow-y: auto;
}

.widget li {
    display: flex;
    justify-content: space-between;
    gap: 0.5rem;
    align-items: center;
}

/* `.widget button` (below) sets a blue background on every button; this
   needs to out-specificity it the same way `.widget .movie-poster-button`
   does. */
.widget .event-item-button {
    display: block;
    width: 100%;
    padding: 0;
    background: none;
    border: none;
    text-align: left;
    cursor: pointer;
    color: inherit;
    font: inherit;
}

.widget .event-item-button:hover {
    color: var(--accent);
    background: none;
}

.widget .event-day-separator {
    color: var(--text-dim);
    font-size: 0.75rem;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    margin-top: 0.3rem;
}

.widget li.event-day-separator:first-child {
    margin-top: 0;
}

.event-datetime-row {
    display: flex;
    gap: 0.4rem;
    align-items: center;
}

.event-birthday-year-unknown {
    display: flex;
    gap: 0.4rem;
    align-items: center;
    color: var(--text-dim);
    font-size: 0.85rem;
}

/* Sits inline before the name, so it needs the same dimmed treatment as the
   date that follows it rather than competing with the name itself. */
.event-birthday-icon {
    margin-right: 0.35rem;
    color: var(--text-dim);
}

/* `.widget form` (below) forces a single-line row layout, right for the
   simple one-liner forms elsewhere but not a multi-field one — needs to
   out-specificity it the same way `.widget .movie-poster-button` does. */
.widget form.stacked-form {
    flex-direction: column;
    align-items: stretch;
    gap: 0.5rem;
}

.widget form {
    display: flex;
    gap: 0.5rem;
    margin-top: 0.75rem;
}

.widget input[type="text"],
.widget input[type="url"],
.widget input[type="datetime-local"],
.widget input[type="date"],
.widget input[type="time"],
.widget input[type="number"],
.modal-panel input[type="text"],
.modal-panel input[type="password"],
.modal-panel input[type="url"],
.auth-card input[type="text"],
.auth-card input[type="password"],
.fullscreen-panel input[type="text"] {
    flex: 1;
    background: var(--bg);
    border: 1px solid var(--panel-border);
    border-radius: 6px;
    color: var(--text);
    padding: 0.55rem 0.7rem;
    transition: border-color 0.15s ease, box-shadow 0.15s ease;
}

/* `select` and `textarea` have no generic rule to inherit from — the same gap
   `.contact-form select` documents for the contact card — so every modal
   holding one rendered it in the browser's own white against a dark panel.
   The widths are stated here too: a `<textarea>` that doesn't fill its form
   is a note field nobody can write three lines in. */
.modal-panel select,
.fullscreen-panel select,
.modal-panel textarea,
.fullscreen-panel textarea {
    background: var(--bg);
    border: 1px solid var(--panel-border);
    border-radius: 6px;
    color: var(--text);
    padding: 0.5rem 0.6rem;
    font: inherit;
    min-width: 0;
}

.modal-panel textarea,
.fullscreen-panel textarea {
    width: 100%;
    box-sizing: border-box;
    resize: vertical;
}

.modal-panel select:focus,
.fullscreen-panel select:focus,
.modal-panel textarea:focus,
.fullscreen-panel textarea:focus {
    outline: none;
    border-color: var(--accent);
    box-shadow: 0 0 0 3px rgb(91 140 255 / 20%);
}

.auth-card input[type="text"]:focus,
.auth-card input[type="password"]:focus {
    outline: none;
    border-color: var(--accent);
    box-shadow: 0 0 0 3px rgb(91 140 255 / 20%);
}

/* A typable field plus a calendar toggle, rather than the single button this
   used to be: reaching a birth year through the popup alone means one click
   per month back, which is unusable past a year or two. */
.date-picker {
    position: relative;
    flex: 1;
    display: flex;
    gap: 0.3rem;
}

.date-picker-input {
    flex: 1;
    /* Without this the field refuses to shrink below its default size and
       pushes the calendar button out of narrow rows like
       `.event-datetime-row`. */
    min-width: 0;
    background: var(--bg);
    border: 1px solid var(--panel-border);
    border-radius: 6px;
    color: var(--text);
    padding: 0.4rem 0.5rem;
    font: inherit;
    transition: border-color 0.15s ease, box-shadow 0.15s ease;
}

.date-picker-input:focus {
    outline: none;
    border-color: var(--accent);
    box-shadow: 0 0 0 3px rgb(91 140 255 / 20%);
}

/* `.widget button` sets a blue fill; needs the usual 2-class
   out-specificity treatment (see `.widget .icon-button` above). */
.widget .date-picker-trigger {
    flex: 0 0 auto;
    text-align: left;
    background: var(--bg);
    border: 1px solid var(--panel-border);
    border-radius: 6px;
    color: var(--text-dim);
    padding: 0.4rem 0.55rem;
    cursor: pointer;
}

.widget .date-picker-trigger:hover {
    background: var(--bg);
    box-shadow: none;
}

/* Centered overlay, same pattern as `.modal-backdrop` — above it (z-index
   300) since a date field can be opened from within an add/edit modal. */
.date-picker-backdrop {
    position: fixed;
    inset: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 1rem;
    background: rgba(0, 0, 0, 0.4);
    z-index: 350;
}

.date-picker-popup {
    width: 260px;
    background: var(--panel);
    border: 1px solid var(--panel-border);
    border-radius: var(--radius);
    padding: 0.75rem;
    /* Programmatically focused on open (see the `Effect` in `DatePicker`)
       so Escape can be intercepted locally — not part of the tab order
       (`tabindex="-1"`), so the default focus ring would be a visible
       artifact rather than a useful affordance. */
    outline: none;
}

.date-picker-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    margin-bottom: 0.5rem;
}

.date-picker-weekdays,
.date-picker-grid {
    display: grid;
    grid-template-columns: repeat(7, 1fr);
    gap: 0.15rem;
}

.date-picker-weekdays span {
    text-align: center;
    font-size: 0.75rem;
    color: var(--text-dim);
}

.widget .date-picker-day {
    background: none;
    border: none;
    border-radius: 4px;
    color: var(--text);
    padding: 0.3rem 0;
    font-size: 0.85rem;
}

.widget .date-picker-day:hover:not(:disabled) {
    background: rgb(91 140 255 / 15%);
}

/* A day the field's `min` puts out of reach — a departure before its own
   arrival. Still drawn, so the month reads as a month, just not clickable. */
.widget .date-picker-day:disabled {
    color: var(--text-dim);
    opacity: 0.35;
    cursor: default;
}

.widget .date-picker-day.outside-month {
    color: var(--text-dim);
    opacity: 0.5;
}

.widget .date-picker-day.today {
    box-shadow: inset 0 0 0 1px var(--accent);
}

.widget .date-picker-day.selected {
    background: var(--accent);
    color: #fff;
}

.date-picker-clear,
.time-picker-clear {
    width: 100%;
    margin-top: 0.5rem;
}

.time-picker {
    position: relative;
    flex: 0 0 5rem;
}

/* Same treatment as `.date-picker-trigger` (see above), just narrower —
   this only ever shows "HH:MM", not a full date. */
.widget .time-picker-trigger {
    width: 100%;
    text-align: center;
    background: var(--bg);
    border: 1px solid var(--panel-border);
    border-radius: 6px;
    color: var(--text);
    padding: 0.4rem 0.5rem;
}

.widget .time-picker-trigger:hover {
    background: var(--bg);
    box-shadow: none;
}

/* A column, so "Effacer" sits under both wheels rather than beside them as a
   third one; `.time-picker-columns` keeps the hours/minutes pair side by
   side. */
.time-picker-popup {
    display: flex;
    flex-direction: column;
    background: var(--panel);
    border: 1px solid var(--panel-border);
    border-radius: var(--radius);
    padding: 0.75rem;
    /* Same reasoning as `.date-picker-popup` — programmatically focused on
       open so Escape can be intercepted locally, not part of the tab
       order. */
    outline: none;
}

.time-picker-columns {
    display: flex;
    gap: 0.5rem;
}

.time-picker-column {
    display: flex;
    flex-direction: column;
    align-items: center;
}

.time-picker-column-label {
    font-size: 0.75rem;
    color: var(--text-dim);
    margin-bottom: 0.4rem;
}

.time-picker-list {
    display: flex;
    flex-direction: column;
    gap: 0.15rem;
    max-height: 200px;
    overflow-y: auto;
    padding-right: 0.2rem;
}

.widget .time-picker-option {
    background: none;
    border: none;
    border-radius: 4px;
    color: var(--text);
    padding: 0.3rem 0.8rem;
    font-size: 0.85rem;
}

.widget .time-picker-option:hover {
    background: rgb(91 140 255 / 15%);
}

.widget .time-picker-option.selected {
    background: var(--accent);
    color: #fff;
}

/* A flat fill at full `--accent` (#5b8cff) read as too vivid/aggressive for
   a button this size and this frequent (confirmed on two browsers, so not
   the earlier caching confusion) — muted by blending it toward `--panel`
   instead of using it at full strength, same hue family but calmer. */
/* `.modal-panel` added alongside `.widget` on this whole button/input rule
   family — every rule here previously assumed a modal only ever appears
   nested inside a `.widget` (true for every pre-auth modal, since they're
   all opened from within one), but the new auth/admin/account screens open
   modals (`AccountSettingsModal`, admin's create/reset-password modals)
   with no `.widget` ancestor at all. `.auth-card` joined the family for the
   same reason — the login funnel's "Se connecter"/"Continuer"/etc. buttons
   used to fall back to unstyled browser-native buttons entirely.
   `.fullscreen-panel` joined later for the exact same reason again — the
   radio widget's "Rechercher"/"Ajouter" buttons inside its `FullscreenPanel`
   were unstyled, since `FullscreenPanel` isn't nested inside a `.widget`
   either. */
.widget button,
.modal-panel button,
.auth-card button,
.fullscreen-panel button {
    background: color-mix(in srgb, var(--accent) 55%, var(--panel) 45%);
    border: 1px solid color-mix(in srgb, var(--accent) 40%, black 25%);
    border-radius: 8px;
    color: white;
    padding: 0.45rem 0.8rem;
    font-weight: 600;
    cursor: pointer;
    box-shadow: 0 1px 2px rgb(0 0 0 / 20%);
    transition: background 0.15s ease, transform 0.05s ease, box-shadow 0.15s ease;
}

.widget button.ghost,
.modal-panel button.ghost,
.fullscreen-panel button.ghost {
    background: transparent;
    color: var(--text-dim);
    border: 1px solid var(--panel-border);
    font-weight: 400;
    box-shadow: none;
}

.widget button.ghost.active,
.modal-panel button.ghost.active,
.fullscreen-panel button.ghost.active {
    color: var(--text);
    border-color: var(--accent);
}

/* Recolored variant of the base fill for destructive confirmations (e.g.
   the radio player's "supprimer cette radio" confirm step) — same
   base/hover shape as the accent fill above, just around `--danger`
   instead, so a real "this deletes something" action doesn't read as just
   another primary button. */
.widget button.danger,
.modal-panel button.danger,
.auth-card button.danger,
.fullscreen-panel button.danger {
    background: color-mix(in srgb, var(--danger) 55%, var(--panel) 45%);
    border-color: color-mix(in srgb, var(--danger) 40%, black 25%);
}

.widget button.danger:hover,
.modal-panel button.danger:hover,
.auth-card button.danger:hover,
.fullscreen-panel button.danger:hover {
    background: color-mix(in srgb, var(--danger) 68%, var(--panel) 32%);
}

/* Adding `:hover` here (vs. the plain `.widget button` above) raises this
   to 2 classes + 1 element — one class-level selector more than any
   `.widget .some-custom-button` override, so it wins the `background`
   property back on hover even when such an override intends a transparent
   button. Any custom button class (`.movie-poster-button`,
   `.chip-add-event`, `.event-item-button`, `.ghost`...) needs its own
   `:hover` rule re-asserting `background` explicitly, not just at the base
   selector — bit the poster button once already, see git history. */
.widget button:hover,
.modal-panel button:hover,
.auth-card button:hover,
.fullscreen-panel button:hover {
    background: color-mix(in srgb, var(--accent) 68%, var(--panel) 32%);
    box-shadow: 0 2px 4px rgb(0 0 0 / 25%);
}

.widget button:active,
.modal-panel button:active,
.auth-card button:active,
.fullscreen-panel button:active {
    transform: translateY(1px);
    background: color-mix(in srgb, var(--accent) 48%, var(--panel) 52%);
    box-shadow: 0 1px 1px rgb(0 0 0 / 20%);
}

.widget button.ghost:hover,
.modal-panel button.ghost:hover,
.fullscreen-panel button.ghost:hover {
    box-shadow: none;
    background: transparent;
}

.widget button:disabled,
.modal-panel button:disabled,
.auth-card button:disabled,
.fullscreen-panel button:disabled {
    opacity: 0.55;
    cursor: not-allowed;
    transform: none;
}

/* Same "restore the plain-text look" override as `.ghost` above — the
   funnel's "utiliser un code de récupération"-style secondary actions use
   `.link-button`, which the blanket `.auth-card button` rule would
   otherwise turn into a filled pill. `.modal-panel` gets the same override
   for the account-settings step-up prompt's "utiliser mon code TOTP à la
   place" / "utiliser ma clé de sécurité à la place" links, which sit inside
   a `Modal` rather than `.auth-card`. */
.auth-card button.link-button,
.modal-panel button.link-button {
    background: none;
    border: none;
    box-shadow: none;
    color: var(--accent);
    padding: 0;
    font-weight: 400;
    font-size: 0.85rem;
}

.auth-card button.link-button:hover,
.modal-panel button.link-button:hover {
    background: none;
    box-shadow: none;
    text-decoration: underline;
}

/* Compact rows rather than icon-on-top tiles — there can be many of these
   once a category grows, so each one needs to stay small. */
.shortcuts-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
    gap: 0.35rem;
}

.shortcut-item {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.3rem;
    padding: 0.25rem 0.4rem;
    border: 1px solid var(--panel-border);
    border-radius: 6px;
    min-width: 0;
}

.shortcut-item a {
    display: flex;
    align-items: center;
    gap: 0.4rem;
    min-width: 0;
    color: var(--text);
    text-decoration: none;
    font-size: 0.8rem;
}

.shortcut-item a span {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.shortcut-favicon {
    width: 16px;
    height: 16px;
    flex: none;
    border-radius: 3px;
}

.widget .shortcut-item button.ghost,
.widget .shortcut-item button.shortcut-edit-button {
    flex: none;
    padding: 0 0.35rem;
    font-size: 0.75rem;
    line-height: 1.6;
}

.shortcuts-sidebar-group {
    margin-bottom: 0.6rem;
}

.shortcuts-sidebar-category {
    margin: 0 0 0.3rem;
    color: var(--text-dim);
    font-size: 0.7rem;
    text-transform: uppercase;
    letter-spacing: 0.04em;
}

.shortcuts-sidebar-list {
    display: flex;
    flex-direction: column;
    gap: 0.35rem;
}

/* Fully self-contained (no `.ghost`, unlike most other inline buttons in
   this file) — a category header should read as plain text with a click
   affordance, not as a bordered button. `.ghost` was tried first but its
   border (`.widget button.ghost`, 2 classes + 1 element) out-specifies a
   plain 2-class override here, leaking through as an unwanted box outline
   — same trap as the button-hover bug fixed earlier in this file. */
.widget .shortcuts-category-toggle {
    display: flex;
    align-items: center;
    gap: 0.3rem;
    width: 100%;
    padding: 0;
    background: none;
    border: none;
    box-shadow: none;
    text-align: left;
    color: var(--text-dim);
    cursor: pointer;
}

.widget .shortcuts-category-toggle:hover {
    background: none;
    box-shadow: none;
    color: var(--text);
}

.widget .shortcuts-category-toggle:hover .shortcuts-sidebar-category,
.widget .shortcuts-category-toggle:hover .movie-section-title {
    color: var(--text);
}

.shortcuts-category-chevron {
    display: inline-block;
    color: var(--text-dim);
    font-size: 0.65rem;
    transition: transform 0.15s ease;
}

.shortcuts-category-chevron.collapsed {
    transform: rotate(-90deg);
}

.shortcuts-sidebar-list.collapsed-group,
.shortcuts-grid.collapsed-group {
    display: none;
}

.shortcuts-category-block {
    margin-bottom: 1.25rem;
}

.shortcuts-category-block .shortcuts-grid {
    margin-top: 0.6rem;
}

.category-manager {
    margin-bottom: 1rem;
    padding-bottom: 1rem;
    border-bottom: 1px solid var(--panel-border);
}

.category-manager-list {
    display: flex;
    flex-direction: column;
    gap: 0.4rem;
    margin-top: 0.6rem;
}

.category-manager-row {
    display: flex;
    align-items: center;
    gap: 0.3rem;
}

.category-manager-row.dragging {
    opacity: 0.4;
}

.category-drag-handle {
    flex: none;
    cursor: grab;
    color: var(--text-dim);
    font-size: 0.75rem;
    line-height: 1;
    letter-spacing: -0.1em;
    padding: 0 0.2rem;
}

.category-manager-row input {
    flex: 1;
    min-width: 0;
}

.widget .category-manager-row button {
    flex: none;
    padding: 0.2rem 0.5rem;
    font-size: 0.75rem;
}

.category-manager-list form.stacked-form {
    flex-direction: row;
    margin-top: 0.4rem;
}

.category-manager-list form.stacked-form input {
    flex: 1;
}

.shortcuts-sidebar-list .shortcut-item {
    border: none;
    padding: 0;
}

.shortcuts-sidebar-list a {
    font-size: 0.85rem;
}

.todo-item.done .todo-title {
    text-decoration: line-through;
    color: var(--text-dim);
}

/* Same rule as `.todo-item.done` above, mirrored for shopping-list items
   (checked = bought) — kept separate rather than sharing the `.todo-item`
   class since the two aren't the same domain, just the same look. */
.shopping-item.done .todo-title {
    text-decoration: line-through;
    color: var(--text-dim);
}

/* Fullscreen-only second row (see `ShoppingItemRow`'s `show_details`),
   holding the comment and/or link chips — a flat sibling `<li>` right
   after the item's own row, not nested inside it (see the component's own
   comment for why: the row itself is a `<button>`, and a `<button>` can't
   legally contain the real `<a>` links below). `.widget li`'s default
   `justify-content: space-between` would shove a lone comment (no links,
   or vice versa) to opposite ends of the row for no reason — this needs
   its own start-aligned, wrapping layout instead. */
.widget li.shopping-item-meta,
.fullscreen-panel li.shopping-item-meta {
    justify-content: flex-start;
    flex-wrap: wrap;
    gap: 0.5rem;
    padding-left: 1.85rem;
    margin-top: -0.2rem;
}

.shopping-item-comment {
    max-width: 260px;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font-size: 0.8rem;
    font-style: italic;
    color: var(--text-dim);
}

.shopping-item-links {
    display: flex;
    align-items: center;
    gap: 0.4rem;
    flex-wrap: wrap;
}

.shopping-item-link {
    max-width: 160px;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.shopping-item-link svg {
    margin-right: 0.2rem;
}

.shopping-toolbar-left {
    display: flex;
    align-items: center;
    gap: 1rem;
    flex-wrap: wrap;
}

.shopping-total {
    font-size: 0.85rem;
    font-weight: 600;
    color: var(--text);
}

.shopping-links-editor {
    display: flex;
    flex-direction: column;
    gap: 0.4rem;
}

.shopping-link-input-row {
    display: flex;
    align-items: center;
    gap: 0.4rem;
}

.shopping-link-input-row input {
    flex: 1;
    min-width: 0;
}

.shopping-add-item-form {
    display: flex;
    align-items: center;
    gap: 0.4rem;
}

.shopping-add-item-form input[type="text"] {
    flex: 1;
    min-width: 0;
}

/* `.widget input[type="text"]`/`.fullscreen-panel input[type="text"]`
   (above) set `flex: 1` at (0,2,1) specificity (container class + type
   attribute + element) — a plain `.shopping-quantity-input` class alone is
   only (0,1,0) and loses outright, same trap as `.widget form.stacked-form`
   elsewhere in this file. Matching that rule's own shape (container class +
   type attribute + own class) reaches (0,3,1), which wins unambiguously. */
.widget input[type="text"].shopping-quantity-input,
.fullscreen-panel input[type="text"].shopping-quantity-input {
    flex: 0 0 44px;
    padding-left: 0.4rem;
    padding-right: 0.4rem;
}

/* `.fullscreen-panel form` (above) forces a column layout on every `<form>`
   in that context — needs the usual `.fullscreen-panel .some-class`
   out-specificity treatment (see `.widget form.stacked-form` for the same
   trap on the compact-widget side) to keep this one-line quick-add row
   inline instead of stacking its three fields. */
.fullscreen-panel .shopping-add-item-form {
    flex-direction: row;
    align-items: center;
}

/* --- Tasks ---
   `.task-item` is the shared shell for both one-off (`.todo-item`) and
   recurring (`.recurring-task-item`) rows — same list item look for both,
   directly serving the "harmonize visuals between the two task kinds" ask;
   the type-specific classes only add what's actually different (the done
   strikethrough above only makes sense for `.todo-item`, recurring tasks
   have no "done" state). */

/* A todo row is a column now — the row proper, then its note, then the
   subtasks it reveals — where `.widget li`'s blanket `display: flex` would
   lay all three side by side. Recurring rows keep the flat shape. */
.widget li.todo-item,
.fullscreen-panel li.todo-item {
    display: block;
}

.task-item-line {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.5rem;
}

.task-item.subtask {
    margin-left: 1.5rem;
}

/* The note under a task's title. Clamped in the compact widget, where the
   list is a glance rather than a read; shown whole in fullscreen. */
.todo-notes {
    margin: 0.1rem 0 0.2rem 1.6rem;
    color: var(--text-dim);
    font-size: 0.8rem;
    white-space: pre-wrap;
}

.widget .todo-notes {
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
    white-space: normal;
}

/* The panel's list navigation — the same one-line dropdown the shopping
   list picks its list with, rather than the row of tab buttons this started
   as. `select` itself is themed with every other one (see `.modal-panel
   select`). */
.task-list-picker {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    margin-bottom: 0.8rem;
}

.task-list-picker label {
    color: var(--text-dim);
    font-size: 0.85rem;
}

.task-list-picker select {
    flex: 1;
    min-width: 0;
    max-width: 22rem;
}

/* "Prévenir X avant" — three narrow number fields under one label, since a
   single unit can't say "1 jour et 2 heures". */
.task-lead-time {
    display: flex;
    flex-direction: column;
    gap: 0.25rem;
}

.task-lead-time-row {
    display: flex;
    gap: 0.4rem;
}

.task-lead-time-field {
    display: flex;
    align-items: center;
    gap: 0.25rem;
    min-width: 0;
}

.task-lead-time-field input {
    width: 4.5rem;
    min-width: 0;
}

.task-list-visible {
    display: flex;
    align-items: center;
    gap: 0.3rem;
    white-space: nowrap;
    color: var(--text-dim);
    font-size: 0.8rem;
}

/* The count pill doubles as the disclosure for the subtasks the compact
   widget keeps folded away — `.widget button` would otherwise paint it
   accent-blue like a primary action. */
.widget button.task-subtask-toggle,
.fullscreen-panel button.task-subtask-toggle {
    background: rgb(255 255 255 / 8%);
    border: none;
    box-shadow: none;
    color: var(--text-dim);
    cursor: pointer;
}

.widget button.task-subtask-toggle:hover,
.fullscreen-panel button.task-subtask-toggle:hover {
    background: rgb(255 255 255 / 14%);
    color: var(--text);
}

.todo-subtask-list {
    list-style: none;
    margin: 0;
    padding: 0;
}

/* Cascades into `.todo-title`/`.task-item-button` below via `color:
   inherit` — badges (`.pill`/`.weather-meta`) keep their own color since
   they set it explicitly, only the title picks this up. */
.task-item.overdue {
    color: var(--danger);
}

/* `.widget button` (below) sets a blue background on every button; this
   needs the usual out-specificity treatment (see `.widget .event-item-button`
   above, which this mirrors — a full-width clickable row opening an edit
   modal, just laid out as a flex row instead of block so title + badges +
   subtask-count pill can sit inline). */
.widget .task-item-button {
    flex: 1;
    min-width: 0;
    display: flex;
    align-items: center;
    gap: 0.4rem;
    padding: 0;
    background: none;
    border: none;
    text-align: left;
    cursor: pointer;
    color: inherit;
    font: inherit;
}

.widget .task-item-button:hover {
    color: var(--accent);
    background: none;
}

.task-item-button .todo-title {
    flex: 1;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.task-fullscreen-toolbar {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.5rem;
    margin-bottom: 1rem;
}

.pill {
    font-size: 0.75rem;
    padding: 0.1rem 0.5rem;
    border-radius: 999px;
    border: 1px solid var(--panel-border);
    color: var(--text-dim);
}

a.pill {
    display: inline-block;
    text-decoration: none;
    cursor: pointer;
}

a.pill:hover {
    color: var(--text);
    border-color: var(--accent);
}

.pill.high {
    color: var(--danger);
    border-color: var(--danger);
}

/* --- Weather --- */

.weather-hero {
    display: flex;
    align-items: center;
    gap: 1rem;
}

.weather-hero-icon {
    font-size: 3rem;
    line-height: 1;
}

.weather-temp {
    font-size: 2.25rem;
    font-weight: 600;
    line-height: 1.1;
}

.weather-meta {
    color: var(--text-dim);
    font-size: 0.8rem;
}

.weather-alerts {
    display: flex;
    flex-direction: column;
    gap: 0.35rem;
    margin-top: 0.85rem;
}

.weather-alert {
    display: flex;
    align-items: flex-start;
    gap: 0.4rem;
    padding: 0.45rem 0.6rem;
    border-radius: 8px;
    /* The default is the amber the locally-computed "Repères" have always
       used. The three modifiers below are the official vigilance colours and
       only ever apply to `OfficialAlerts` rows. */
    border: 1px solid #f5a623;
    background: rgb(245 166 35 / 12%);
    color: #f5a623;
    font-size: 0.8rem;
}

/* Météo-France's own three. Not the app's palette: these colours are the
   warning — somebody who has seen an orange map on television has to
   recognise this at a glance, and a tastefully desaturated orange would be a
   different statement than the one being relayed. */
.weather-alert--jaune {
    border-color: #ffd200;
    background: rgb(255 210 0 / 12%);
    color: #ffd200;
}

.weather-alert--orange {
    border-color: #ff8000;
    background: rgb(255 128 0 / 14%);
    color: #ffa040;
}

.weather-alert--rouge {
    border-color: #e00000;
    background: rgb(224 0 0 / 16%);
    color: #ff6b6b;
}

.weather-alert-body {
    display: flex;
    flex-direction: column;
    gap: 0.1rem;
    min-width: 0;
}

.weather-alert-meta {
    /* Inherits the row's warning colour, dimmed, rather than taking
       `--text-dim`: a grey second line under a red first one reads as two
       separate messages. */
    opacity: 0.8;
    font-size: 0.75rem;
}

.weather-alert-icon {
    flex: none;
}

.weather-attribution {
    font-size: 0.7rem;
    opacity: 0.7;
}

.weather-section-label {
    margin: 1.1rem 0 0;
    font-size: 0.75rem;
    font-weight: 600;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--text-dim);
}

.weather-details-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
    gap: 0.5rem;
    margin-top: 0.6rem;
}

.weather-detail-item {
    display: flex;
    align-items: center;
    gap: 0.6rem;
    padding: 0.55rem 0.65rem;
    border: 1px solid var(--panel-border);
    border-radius: 8px;
}

.weather-detail-item > div {
    display: flex;
    flex-direction: column;
    gap: 0.1rem;
    min-width: 0;
}

.weather-detail-icon {
    flex: none;
    font-size: 1.25rem;
    line-height: 1;
}

.hourly-strip,
.daily-row {
    display: flex;
    gap: 0.5rem;
    overflow-x: auto;
    margin-top: 0.6rem;
    padding-bottom: 0.25rem;
}

.hourly-item,
.daily-item {
    flex: none;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 0.2rem;
    font-size: 0.8rem;
    min-width: 52px;
    padding: 0.5rem 0.4rem;
    border-radius: 8px;
    background: rgba(255, 255, 255, 0.04);
}

.hourly-item-hour,
.daily-item span:first-child {
    color: var(--text-dim);
    font-size: 0.75rem;
}

.hourly-item-icon,
.daily-item-icon {
    font-size: 1.35rem;
}

.weather-site-tabs {
    display: flex;
    flex-wrap: wrap;
    gap: 0.5rem;
    margin-bottom: 1rem;
}

.weather-site-tabs button.active {
    background: var(--accent);
    color: white;
    border-color: var(--accent);
}

/* --- Rain in the next two hours ---

   Capped rather than stretched. `.fullscreen-panel` runs to 1800px, and eight
   quarter-hours spread across a 34" display is a chart whose bars are further
   apart than they are tall — the shape stops reading as a sequence. The cap is
   roughly where the hourly strip above it ends up, so the two read as one
   column of weather rather than as two unrelated widths (user report
   2026-09-03). */
.minutely-strip {
    display: flex;
    /* `stretch`, not `flex-end`. Only the bars that land on the hour carry a
       label, so with `flex-end` the labelled items were one line taller than
       the rest and their tracks sat one line higher — the misalignment the
       same report describes. Every item is now the full height of the row and
       the alignment happens inside it, where the label's reserved line
       (`.minutely-item-hour`) keeps the tracks on one baseline whether or not
       there is anything to print. */
    align-items: stretch;
    gap: 0.3rem;
    margin-top: 0.6rem;
    max-width: 520px;
}

.minutely-item {
    flex: 1 1 0;
    display: flex;
    flex-direction: column;
    gap: 0.25rem;
    min-width: 0;
}

/* The track is what makes the bars comparable: without a fixed-height box
   behind them, a row of short bars sits at whatever height the tallest one
   happens to be, and the strip changes shape every refresh. */
.minutely-bar-track {
    width: 100%;
    height: 46px;
    display: flex;
    align-items: flex-end;
    border-radius: 4px;
    background: rgba(255, 255, 255, 0.04);
}

.minutely-bar {
    width: 100%;
    border-radius: 4px;
    background: linear-gradient(180deg, #7cb3ff, #3f6fd8);
}

/* A dry quarter-hour still draws a hairline, so the strip reads as eight
   steps of which some are empty rather than as a gap in the data. */
.minutely-bar--dry {
    height: 2px !important;
    background: rgba(255, 255, 255, 0.12);
}

/* The line is reserved whether or not this bar is the one on the hour — see
   `align-items` above. `min-height` and not `height`, so a wrapped label
   under a narrow bar grows the row instead of spilling out of it. */
.minutely-item-hour {
    min-height: 1rem;
    font-size: 0.7rem;
    line-height: 1rem;
    color: var(--text-dim);
    white-space: nowrap;
    text-align: center;
}

/* --- The official ATMO index --- */

.atmo-block {
    display: flex;
    align-items: center;
    gap: 0.6rem;
    margin-top: 0.6rem;
    padding: 0.5rem 0.6rem;
    border-radius: 8px;
    background: rgba(255, 255, 255, 0.04);
}

.atmo-block > div {
    display: flex;
    flex-direction: column;
    min-width: 0;
}

.atmo-badge {
    flex: none;
    display: grid;
    place-items: center;
    width: 2rem;
    height: 2rem;
    border-radius: 50%;
    font-weight: 700;
    /* The badge's background is the AASQA's published colour, which ranges
       from a pale yellow-green to a deep purple — dark text is the only one
       readable across the whole ramp, and it is what their own map uses. */
    color: #101319;
}

/* --- The rain radar ---

   Bounded on both axes, for the same reason as the strip above: at the
   panel's full 1800px a 320px-tall map is a letterbox, and a weather radar
   read through a letterbox shows a band of sky either side of the front
   instead of the front (user report 2026-09-03). Squarer and taller instead.

   `clamp` rather than a media query: the constraint is the *box*, not the
   device. 55vh is what makes it fill a laptop screen and still leave the
   controls under it visible; the 340px floor keeps it usable on a phone in
   landscape, where 55vh is nothing. */
.weather-radar {
    width: 100%;
    max-width: 760px;
    height: clamp(340px, 55vh, 620px);
    margin-top: 0.6rem;
    border-radius: 10px;
    overflow: hidden;
    /* Leaflet positions its panes absolutely inside this, and without a
       stacking context of its own the fullscreen panel's own layers end up
       on the wrong side of the tiles. */
    isolation: isolate;
}

/* Play/pause, the scrubber and the clock, on one line under the map and no
   wider than it. */
.radar-timeline {
    display: flex;
    align-items: center;
    gap: 0.6rem;
    max-width: 760px;
    margin-top: 0.5rem;
}

.radar-timeline input[type="range"] {
    flex: 1 1 auto;
    min-width: 0;
    accent-color: var(--accent);
}

/* The lightning switch and the legend, on their own line under the timeline
   rather than in it: the row above is one continuous control (play, scrub,
   clock) and a checkbox wedged into it would be read as part of the
   scrubber. */
.radar-legend {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 0.4rem 1rem;
    max-width: 760px;
    margin-top: 0.45rem;
    font-size: 0.8rem;
    color: var(--text-dim);
}

.radar-lightning,
.radar-legend-item {
    display: inline-flex;
    align-items: center;
    gap: 0.35rem;
}

.radar-lightning {
    cursor: pointer;
}

.radar-swatch {
    flex: none;
    width: 34px;
    height: 9px;
    border-radius: 5px;
    /* A hairline, because both ramps have a pale end that would otherwise
       dissolve into the panel behind them. */
    box-shadow: inset 0 0 0 1px rgb(255 255 255 / 18%);
}

/* Both gradients are the *measured* ramps, not decorative approximations:
   sampled from a real RainViewer tile and a real EUMETSAT one on 2026-09-03.
   A legend that did not match the map would be worse than none. */
.radar-swatch--rain {
    background: linear-gradient(
        90deg,
        #005588,
        #0088bf,
        #00a3e0,
        #6cd1eb,
        #ffee00,
        #ffb700,
        #ff4400,
        #d91b00
    );
}

.radar-swatch--lightning {
    background: linear-gradient(90deg, #be2de6, #ffd2ff);
}

/* Tabular figures and a floor on the width: the label swings between "11:40"
   and "13:20 · maintenant", and without this the slider beside it would
   resize on every tick of the animation. */
.radar-timeline-stamp {
    flex: none;
    min-width: 8.5ch;
    font-size: 0.8rem;
    font-variant-numeric: tabular-nums;
    color: var(--text-dim);
    text-align: right;
}

/* The checkbox in "Mes villes". `flex: none` so a long town name takes the
   slack instead of squeezing the label onto two lines. */
.location-alerts {
    flex: none;
    display: flex;
    align-items: center;
    gap: 0.3rem;
    font-size: 0.8rem;
    color: var(--text-dim);
    cursor: pointer;
}

/* --- Fullscreen-only weather polish ---
   Everything below is scoped under `.weather-mood-*`, a class only ever
   added when `WeatherView` renders `detailed=true` (see weather.rs) — the
   compact sidebar widget's plain `.weather-hero`/`.hourly-item`/etc. never
   match these selectors, so it stays exactly as it was. */

.weather-view.weather-mood-clear {
    background: linear-gradient(
        135deg,
        rgb(245 166 35 / 16%),
        rgb(91 140 255 / 4%)
    );
}

.weather-view.weather-mood-cloudy {
    background: linear-gradient(
        135deg,
        rgb(148 163 184 / 14%),
        rgb(91 140 255 / 3%)
    );
}

.weather-view.weather-mood-rain {
    background: linear-gradient(
        135deg,
        rgb(56 132 255 / 16%),
        rgb(30 64 175 / 5%)
    );
}

.weather-view.weather-mood-snow {
    background: linear-gradient(
        135deg,
        rgb(186 230 253 / 14%),
        rgb(148 163 184 / 4%)
    );
}

.weather-view.weather-mood-storm {
    background: linear-gradient(
        135deg,
        rgb(139 92 246 / 16%),
        rgb(30 27 75 / 6%)
    );
}

.weather-view.weather-mood-fog {
    background: linear-gradient(
        135deg,
        rgb(148 163 184 / 12%),
        rgb(100 116 139 / 3%)
    );
}

.weather-view[class*="weather-mood-"] {
    margin: -0.5rem -0.75rem 0;
    padding: 1.25rem 1.25rem 1.5rem;
    border-radius: calc(var(--radius) + 4px);
}

.weather-view[class*="weather-mood-"] .weather-hero-icon {
    font-size: 3.75rem;
}

.weather-view[class*="weather-mood-"] .weather-temp {
    font-size: 2.75rem;
}

.weather-view[class*="weather-mood-"] .hourly-item:first-child,
.weather-view[class*="weather-mood-"] .daily-item:first-child {
    background: rgb(91 140 255 / 14%);
    box-shadow: inset 0 0 0 1px rgb(91 140 255 / 40%);
}

/* --- RATP --- */

/* One label + badge row per transit mode (Métro/RER+Transilien/Tram in the
   compact widget; Métro/RER/Transilien/Tram/Téléphérique/Bus, each broken
   out separately, in the fullscreen view — per user request 2026-08-09,
   round 8). */
.ratp-mode-row {
    display: flex;
    flex-direction: column;
    gap: 0.3rem;
}

/* The compact widget's `.widget-body` has no `gap` of its own (shared by
   every widget, not just this one), so consecutive rows (Métro, then RER)
   otherwise sit flush against each other — this nudges each row after the
   first down a little, scoped to the compact widget only via the
   `.widget-body >` ancestor (the fullscreen view's own rows already have
   `.ratp-fullscreen-body`'s 1rem `gap`, no extra spacing needed there). Per
   user request 2026-08-10 — "espace légèrement les lignes de métro du
   label RER". */
.widget-body > .ratp-mode-row + .ratp-mode-row {
    margin-top: 0.5rem;
}

/* Only wraps `.ratp-mode-label` itself when a row also has a
   `header_extra` action (the Bus row's own "configurer" button, round 10)
   — keeps that button pinned to the row's right edge without needing to
   touch every other `TransitModeRow` that doesn't have one. */
.ratp-mode-label-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.5rem;
}

.ratp-mode-label {
    margin: 0;
    font-size: 0.75rem;
    font-weight: 600;
    color: var(--text-dim);
    text-transform: uppercase;
    letter-spacing: 0.02em;
}

/* Always wraps, never scrolls — a horizontally-scrolling `overflow-x: auto`
   variant briefly existed here (round 8, to keep the compact widget no
   taller than the weather widget) but implicitly clipped `.ratp-tooltip`
   too (`overflow-x` set to anything but `visible` computes the other axis
   to `auto` as well per the CSS spec, even though only `overflow-y` was
   ever meant to stay visible), hiding a line's service messages on hover —
   found live 2026-08-09, round 10. Removed rather than patched (e.g. via
   `overflow: visible` on just the tooltip, which wouldn't have escaped an
   `overflow-x: auto` ancestor's clipping anyway): the compact widget only
   shows Métro + RER now (round 9 dropped Tram/Transilien from it), so the
   original height concern is largely moot, and the user's own call was
   this wasn't an elegant fix regardless. */
.ratp-lines {
    display: flex;
    flex-wrap: wrap;
    /* Flex's default `stretch` would make every badge sharing a row match
       the row's tallest box — degraded/cut lines are already
       `box-shadow`-only (paint, not layout, doesn't change box size), but
       `flex-start` closes off that whole class of bug outright rather than
       relying on staying box-shadow-only forever (per user request
       2026-08-09, round 8 — a badge that got taller than it is wide would
       read as a stretched, less-rounded "stadium" shape since
       `border-radius: 999px` on `.ratp-badge` computes off the smaller
       dimension). */
    align-items: flex-start;
    gap: 0.6rem;
}

.ratp-fullscreen-body {
    display: flex;
    flex-direction: column;
    gap: 1rem;
}

/* The "choisir mes lignes de bus" search+pick UI (round 9) — reuses
   `.stacked-form`/`.radio-search-*`/`.radio-station-row` wholesale from
   `player.rs`'s own radio-station search, same shape (search a big
   external catalog, save just the ones you want). */
.ratp-bus-picker {
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
    padding-bottom: 0.5rem;
    border-bottom: 1px solid var(--panel-border);
}

/* The small delete button living inside a tracked bus line's own badge
   (`MyBusLineRow`) — `.ratp-badge` already centers its children and gives
   them a gap, this just resets the button chrome so it doesn't look like a
   normal `.icon-button` circle sitting awkwardly inside a pill. */
.ratp-badge .icon-button {
    width: 18px;
    height: 18px;
    min-width: 18px;
    border: none;
    color: inherit;
}

.ratp-summary {
    /* A plain fixed top margin, not `margin-top: auto` — auto resolves to
       *zero* free space (not the base 0.75rem) whenever the compact
       widget's grid-stretched height happens to be driven by its own
       Métro+RER content rather than a taller `WeatherWidget` sibling (see
       `.main-grid`'s `align-items: stretch`), e.g. whenever Métro wraps to
       a second line or several RER lines are disrupted at once — exactly
       the case where a breathing-room gap before the divider matters most.
       Bug found live 2026-08-11: the RER row visibly touching/overlapping
       this divider. Any leftover stretched height now just becomes blank
       space below the summary line instead of being absorbed above it —
       a fine trade-off for never losing the gap.
    */
    margin: 0.75rem 0 0;
    padding-top: 0.75rem;
    border-top: 1px solid var(--panel-border);
}

.ratp-badge {
    position: relative;
    display: flex;
    align-items: center;
    gap: 0.4rem;
    padding: 0.4rem 0.7rem;
    border-radius: 999px;
    font-weight: 700;
    font-size: 0.95rem;
    cursor: default;
    transition: box-shadow 0.15s ease;
}

.ratp-badge.status-degraded {
    box-shadow:
        0 0 0 3px #f5a623,
        0 0 0 1px rgba(0, 0, 0, 0.3);
}

.ratp-badge.status-cut {
    box-shadow:
        0 0 0 3px #e05252,
        0 0 0 1px rgba(0, 0, 0, 0.3);
}

.ratp-count {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: 20px;
    height: 20px;
    padding: 0 0.35rem;
    border-radius: 999px;
    background: #f5a623;
    color: white;
    font-size: 0.75rem;
    font-weight: 700;
    border: 2px solid rgba(0, 0, 0, 0.2);
}

.ratp-count.cut {
    background: #e05252;
}

.ratp-tooltip {
    display: none;
    position: absolute;
    top: calc(100% + 0.4rem);
    left: 0;
    z-index: 30;
    min-width: 260px;
    max-width: 340px;
    max-height: 280px;
    overflow-y: auto;
    background: var(--panel);
    border: 1px solid var(--panel-border);
    border-radius: 8px;
    padding: 0.6rem 0.75rem;
    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
    color: var(--text);
    font-weight: 400;
    font-size: 0.8rem;
}

/* Set by `LineBadge` on hover/focus when opening left (default) would push
   the tooltip past the viewport's right edge — see ratp.rs. */
.ratp-badge.tooltip-flip .ratp-tooltip {
    left: auto;
    right: 0;
}

.ratp-badge:hover .ratp-tooltip,
.ratp-badge:focus-within .ratp-tooltip,
.ratp-badge:focus .ratp-tooltip {
    display: block;
}

.ratp-tooltip ul {
    list-style: none;
    margin: 0.35rem 0 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 0.45rem;
}

.ratp-tooltip li {
    padding-left: 0.5rem;
    display: flex;
    flex-direction: column;
    gap: 0.15rem;
}

/* --- Transit itineraries (the fullscreen panel's "Itinéraire" tab) --- */

.transit-tab-row {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    flex-wrap: wrap;
}

.journey-panel {
    display: flex;
    flex-direction: column;
    gap: 0.9rem;
}

/* Départ over arrivée, with the swap button beside both — stacked rather
   than side by side because each field opens a suggestion list underneath
   itself, and two lists competing for the same horizontal band would cover
   each other. */
.journey-endpoints {
    display: flex;
    align-items: center;
    gap: 0.5rem;
}

.journey-endpoint-fields {
    flex: 1 1 auto;
    min-width: 0;
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
}

/* The positioning context the suggestion list hangs off, and the row the
   field shares with its "keep this place" star. */
.journey-field {
    position: relative;
    display: flex;
    align-items: center;
    gap: 0.4rem;
}

.journey-field input {
    flex: 1 1 auto;
    min-width: 0;
}

/* Floats over what follows rather than pushing it down: the list appears and
   disappears on every keystroke, and a form that reflowed each time would be
   unusable to type into. */
.journey-suggestions {
    position: absolute;
    z-index: 40;
    top: calc(100% + 0.25rem);
    left: 0;
    right: 0;
    max-height: 17rem;
    overflow-y: auto;
    list-style: none;
    margin: 0;
    padding: 0.25rem;
    background: var(--panel);
    border: 1px solid var(--panel-border);
    border-radius: 8px;
    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
}

.widget .journey-suggestions li {
    display: block;
}

.journey-suggestions button {
    width: 100%;
    display: flex;
    align-items: center;
    gap: 0.5rem;
    padding: 0.4rem 0.5rem;
    border: none;
    border-radius: 6px;
    background: none;
    color: var(--text);
    text-align: left;
    cursor: pointer;
}

.journey-suggestions button:hover,
.journey-suggestions button:focus-visible {
    background: var(--panel-border);
}

.journey-suggestions svg {
    flex: 0 0 auto;
    color: var(--accent);
}

.journey-suggestion-label {
    flex: 1 1 auto;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.journey-suggestion-lines {
    display: flex;
    gap: 0.2rem;
    flex: 0 0 auto;
}

/* Small enough that six of them fit beside a station name without pushing
   its label out of the row. */
.journey-suggestion-badge {
    padding: 0.1rem 0.35rem;
    font-size: 0.7rem;
}

.journey-suggestion-kind {
    flex: 0 0 auto;
    font-size: 0.7rem;
    color: var(--muted);
    text-transform: uppercase;
    letter-spacing: 0.02em;
}

.journey-when {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    flex-wrap: wrap;
}

.journey-when-mode {
    display: flex;
    align-items: center;
    gap: 0.4rem;
}

/* `.date-picker` is `flex: 1` by default, which on this row let the field
   swallow every pixel the mode tabs and the clock left over. A date is eight
   characters wide wherever it appears. */
.journey-when .date-picker {
    flex: 0 0 11rem;
}

.journey-now {
    font-size: 0.8rem;
}

/* What the two typed lines were understood as — deliberately quiet, since it
   is a confirmation to glance at rather than the answer itself. */
.journey-endpoints-header {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    flex-wrap: wrap;
    margin: 0;
    font-size: 0.8rem;
    color: var(--text-dim);
}

.journey-endpoints-arrow {
    color: var(--muted);
}

/* Results beside the map. The map column is fixed and the list takes what is
   left, rather than an even split: a list of three itineraries has a natural
   width and a map is happy at any. Below the breakpoint the map drops under
   the list — side by side, neither would be wide enough to read. */
.journey-results {
    display: grid;
    /* Even halves rather than a fixed map column: at a fullscreen panel's
       width a 22rem map was a stamp beside a list with far more room than its
       own content ever needs. The widest thing the list holds is a stop name
       under an unfolded ride, which fits in half a panel with room to spare —
       so the other half goes to the map, which uses every pixel it gets. */
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    gap: 1rem;
    align-items: start;
}

@media (max-width: 900px) {
    .journey-results {
        grid-template-columns: minmax(0, 1fr);
    }
}

/* No map to show — "aucun itinéraire", or nothing searched yet. Without this
   the grid keeps holding 22rem open for a column that is display:none, and
   the message sits against a wide empty gutter. */
.journey-results:has(.journey-map.empty) {
    grid-template-columns: minmax(0, 1fr);
}

/* Follows the list down a long set of results instead of scrolling off. */
.journey-results-list {
    min-width: 0;
}

.journey-map {
    position: sticky;
    top: 0;
}

.journey-map > div {
    /* Taller than it was narrow: a wide, short frame letterboxes a trip that
       runs north-south, which most of them do here. */
    height: 28rem;
    border: 1px solid var(--panel-border);
    border-radius: 10px;
    /* Leaflet paints tiles into the corners; without this they square off the
       rounded border. */
    overflow: hidden;
}

/* Nothing searched yet: the container has to keep its size so Leaflet has
   something to bind to, but an empty grey box beside an empty list reads as
   something broken. */
.journey-map.empty {
    display: none;
}

/* The two ends of the drawn trip. Deliberately plain dots — this map answers
   "roughly where does this go", and anything more would invite a click it
   does not respond to. */
.transit-map-start,
.transit-map-end {
    border-radius: 50%;
    border: 2px solid #ffffff;
    box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.5);
}

.transit-map-start {
    background: #3ddc84;
}

.transit-map-end {
    background: #e05252;
}

/* Sort, modes and the way into the profile — one row under the time
   controls, since all three change what the answer is rather than how it
   reads. */
.journey-options {
    display: flex;
    align-items: center;
    gap: 0.75rem;
    flex-wrap: wrap;
}

.journey-sort {
    display: flex;
    align-items: center;
    gap: 0.4rem;
    font-size: 0.8rem;
    color: var(--text-dim);
}

/* Label above its control rather than beside it: "Titre de transport" beside
   a select wrapped onto two lines and left the select overflowing the panel. */
.journey-profile-field {
    display: flex;
    flex-direction: column;
    align-items: stretch;
    gap: 0.3rem;
    font-size: 0.8rem;
    color: var(--text-dim);
}

.journey-profile-field select {
    width: 100%;
}

/* Separates the three groups the profile holds — the fare, the walking, and
   the things kept — which ran together as one column of controls. */
.modal-panel .ratp-mode-label {
    margin-top: 0.75rem;
    padding-top: 0.75rem;
    border-top: 1px solid var(--panel-border);
}

/* Pinned trips and recent searches. */
.journey-chips {
    display: flex;
    gap: 0.4rem;
    flex-wrap: wrap;
    margin-bottom: 0.75rem;
}

/* A pill holding two buttons: the trip itself, and — when it is pinned — the
   cross that lets it go. Nested rather than side by side so the two read as
   one object. */
.journey-chip {
    display: inline-flex;
    align-items: center;
    border: 1px solid var(--panel-border);
    border-radius: 999px;
    overflow: hidden;
}

.journey-chip.pinned {
    border-color: var(--accent);
}

.widget .journey-chip-run,
.widget .journey-chip-unpin {
    display: inline-flex;
    align-items: center;
    gap: 0.3rem;
    padding: 0.25rem 0.6rem;
    border: none;
    background: none;
    color: var(--text-dim);
    font-size: 0.78rem;
    cursor: pointer;
}

.journey-chip.pinned .journey-chip-run {
    color: var(--text);
}

.widget .journey-chip-unpin {
    padding-left: 0.35rem;
    padding-right: 0.5rem;
    font-size: 1rem;
    line-height: 1;
}

.widget .journey-chip-run:hover,
.widget .journey-chip-unpin:hover {
    background: var(--panel-border);
    color: var(--text);
}

/* Recent searches, once revealed: set back from the pinned row above so the
   two read as "kept" and "happened to look at", not one list of eight. */
.journey-chips-history {
    margin-top: -0.25rem;
    padding-left: 0.5rem;
    border-left: 2px solid var(--panel-border);
}

.widget .journey-history-toggle {
    display: inline-flex;
    align-items: center;
    gap: 0.3rem;
    padding: 0.25rem 0.6rem;
    border: 1px dashed var(--panel-border);
    border-radius: 999px;
    background: none;
    color: var(--muted);
    font-size: 0.78rem;
    cursor: pointer;
}

.widget .journey-history-toggle:hover {
    color: var(--text);
    border-color: var(--accent);
}

.journey-steps {
    display: flex;
    gap: 0.5rem;
    margin-top: 0.75rem;
}

/* IDFM's own name for a proposal — "Le plus rapide". A quiet tag, not a
   badge: it ranks the results against each other and says nothing about the
   network. */
.journey-tag {
    padding: 0.1rem 0.45rem;
    border: 1px solid var(--panel-border);
    border-radius: 999px;
    font-size: 0.7rem;
    color: var(--text-dim);
    text-transform: uppercase;
    letter-spacing: 0.02em;
}

/* Pushed to the end of the header line, where a price is looked for. */
.journey-fare {
    margin-left: auto;
    font-size: 0.85rem;
    font-variant-numeric: tabular-nums;
}

.journey-fare.estimated {
    color: var(--text-dim);
}

/* Only what is in force while the trip runs — the filtering is server-side,
   so anything drawn here is worth the amber. */
.journey-disruptions {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 0.25rem;
}

.widget .journey-disruptions li {
    display: flex;
    align-items: baseline;
    justify-content: flex-start;
    gap: 0.4rem;
    font-size: 0.8rem;
    color: #f5a623;
}

.journey-disruption-dot {
    flex: 0 0 auto;
    width: 0.5rem;
    height: 0.5rem;
    border-radius: 50%;
    background: #f5a623;
}

/* Distance walked and CO2 — deliberately the quietest line of the card. */
.journey-footnote {
    margin: 0;
    font-size: 0.75rem;
    color: var(--muted);
}

.journey-footnote:empty {
    display: none;
}

.journey-field-star {
    flex: 0 0 auto;
}

.journey-profile-field select,
.journey-sort select {
    flex: 0 0 auto;
}

.journey-profile-check {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    font-size: 0.85rem;
}

.journey-profile-note {
    margin: 0;
    font-size: 0.75rem;
    color: var(--muted);
}

.journey-saved-list {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 0.4rem;
}

.widget .journey-saved-list li,
.modal-panel .journey-saved-list li {
    display: flex;
    align-items: center;
    justify-content: flex-start;
    gap: 0.5rem;
}

.journey-saved-label {
    flex: 1 1 auto;
    min-width: 0;
}

.journey-saved-detail {
    flex: 1 1 auto;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font-size: 0.75rem;
    color: var(--muted);
}

.journey-list {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 0.75rem;
}

/* Two classes, to out-specificity `.widget li` — which makes every list item
   in a widget a centred, space-between flex row. That is right for the one-
   line rows it was written for and wrong for a stacked card: it centred every
   line of an itinerary in the middle of its own width. Same
   out-specificity trick documented throughout this file. */
.widget .journey-row {
    display: flex;
    flex-direction: column;
    align-items: stretch;
    justify-content: flex-start;
    gap: 0.6rem;
    padding: 0.75rem;
    border: 1px solid var(--panel-border);
    border-radius: 10px;
    background: var(--panel);
}

/* Hugs the left of a card it would otherwise be centred across. */
.widget .journey-row .show-more-toggle {
    align-self: flex-start;
}

.journey-header {
    display: flex;
    align-items: baseline;
    gap: 0.75rem;
    flex-wrap: wrap;
}

.journey-times {
    display: flex;
    align-items: baseline;
    gap: 0.4rem;
    font-size: 1.05rem;
}

.journey-times-sep,
.journey-summary {
    color: var(--text-dim);
    font-size: 0.85rem;
}

/* Wraps rather than scrolls, same reasoning as `.ratp-lines`: a leg pushed
   off the right edge of a strip is a step of the itinerary nobody sees. */
.journey-legs {
    display: flex;
    align-items: center;
    gap: 0.4rem;
    flex-wrap: wrap;
}

.journey-leg {
    display: flex;
    align-items: center;
    gap: 0.35rem;
    font-size: 0.8rem;
    color: var(--text-dim);
}

.journey-leg-walk,
.journey-leg-wait {
    padding: 0.3rem 0.6rem;
    border: 1px dashed var(--panel-border);
    border-radius: 999px;
}

.journey-leg-mode {
    font-size: 0.7rem;
    text-transform: uppercase;
    letter-spacing: 0.02em;
}

/* A hair smaller than the traffic board's badges: a leg strip lines up four
   or five of them, where that board shows one per line at reading size. */
.journey-leg-transit .ratp-badge {
    padding: 0.25rem 0.55rem;
    font-size: 0.85rem;
}

.journey-leg-duration {
    font-variant-numeric: tabular-nums;
}

/* The unfolded itinerary: a time column against a description column, which
   is what makes the whole trip scannable down the left edge. */
.journey-detail {
    list-style: none;
    margin: 0.25rem 0 0;
    padding: 0.6rem 0 0;
    border-top: 1px solid var(--panel-border);
    display: flex;
    flex-direction: column;
    gap: 0.6rem;
}

.widget .journey-step {
    display: flex;
    gap: 0.75rem;
    align-items: baseline;
    justify-content: flex-start;
}

.journey-step-time {
    flex: 0 0 3rem;
    font-variant-numeric: tabular-nums;
    font-weight: 600;
    font-size: 0.85rem;
}

.journey-step-body {
    flex: 1 1 auto;
    min-width: 0;
    display: flex;
    flex-direction: column;
    gap: 0.25rem;
}

.journey-step-title {
    font-size: 0.9rem;
}

.journey-step-line {
    display: flex;
    align-items: center;
    gap: 0.4rem;
    flex-wrap: wrap;
}

.journey-step-line .ratp-badge {
    padding: 0.25rem 0.55rem;
    font-size: 0.85rem;
}

.journey-step-direction,
.journey-step-meta {
    font-size: 0.8rem;
    color: var(--text-dim);
}

/* Indented and ruled: the stops passed through hang off the ride above them
   rather than reading as steps of the itinerary in their own right. */
.journey-step-stops {
    list-style: none;
    margin: 0.1rem 0 0;
    padding: 0 0 0 0.75rem;
    border-left: 2px solid var(--panel-border);
    display: flex;
    flex-direction: column;
    gap: 0.2rem;
}

/* Also `.widget li`'s doing: without this the time and the stop name were
   pushed to opposite ends of the column instead of sitting side by side. */
.widget .journey-step-stops li {
    display: flex;
    gap: 0.5rem;
    justify-content: flex-start;
    align-items: baseline;
    font-size: 0.8rem;
    color: var(--text-dim);
}

.journey-step-stop-time {
    flex: 0 0 2.5rem;
    font-variant-numeric: tabular-nums;
}

.journey-step-end .journey-step-title {
    font-weight: 600;
}

/* --- Auth (login, forced onboarding) --- */

.auth-screen {
    width: 100%;
    min-height: 100vh;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 1rem;
    background:
        radial-gradient(circle at 25% 20%, rgb(91 140 255 / 14%), transparent 45%),
        radial-gradient(circle at 78% 78%, rgb(91 140 255 / 9%), transparent 50%),
        var(--bg);
}

.auth-loading {
    width: 100%;
    min-height: 100vh;
    display: flex;
    align-items: center;
    justify-content: center;
    color: var(--text-dim);
}

.auth-card {
    width: 100%;
    max-width: 420px;
    background: var(--panel);
    border: 1px solid var(--panel-border);
    border-radius: calc(var(--radius) + 4px);
    padding: 2rem;
    box-shadow: 0 24px 60px rgb(0 0 0 / 45%), 0 2px 8px rgb(0 0 0 / 30%);
}

.auth-card-header {
    display: flex;
    flex-direction: column;
    align-items: center;
    text-align: center;
    margin-bottom: 1.5rem;
}

.auth-card-icon {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 44px;
    height: 44px;
    margin-bottom: 0.75rem;
    border-radius: 50%;
    background: color-mix(in srgb, var(--accent) 18%, var(--panel) 82%);
    color: var(--accent);
    font-size: 1.3rem;
}

.auth-card h1 {
    margin: 0;
    font-size: 1.4rem;
    letter-spacing: 0.01em;
}

.auth-card-subtitle {
    margin: 0.3rem 0 0;
    color: var(--text-dim);
    font-size: 0.88rem;
}

.auth-card .stacked-form {
    display: flex;
    flex-direction: column;
    align-items: stretch;
    gap: 0.65rem;
}

.auth-card p {
    color: var(--text-dim);
    font-size: 0.88rem;
    line-height: 1.45;
    margin: 0 0 0.5rem;
}

.auth-card p.error {
    color: var(--danger);
}

/* Meme regle que `.auth-card p.error` juste au-dessus, pour les formulaires
   qui vivent dans un modal (« Mon compte » et ses voisins) : `.error` seul
   n'a jamais eu de couleur ailleurs que dans la carte de connexion et le
   corps d'un widget, si bien qu'un message d'erreur y apparaissait dans la
   couleur du texte ordinaire — impossible a distinguer de l'explication qui
   le precede. */
.modal-panel p.error {
    color: var(--danger);
}

@keyframes auth-step-in {
    from {
        opacity: 0;
        transform: translateY(4px);
    }

    to {
        opacity: 1;
        transform: translateY(0);
    }
}

.auth-card .auth-step {
    animation: auth-step-in 0.25s ease;
}

/* Les villes proposées à la première connexion. Même forme que les
   suggestions de la fenêtre de réglages (`.media-prefs-suggestions`), mais
   pas la même classe : celles-là vivent dans une fenêtre déjà large, où une
   liste haute ne pousse rien ; ici la carte d'authentification est centrée à
   l'écran et grandirait sous la liste, d'où la hauteur plus courte. */
.onboarding-suggestions {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 0.2rem;
    max-height: 180px;
    overflow-y: auto;
}

.onboarding-suggestions button {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    width: 100%;
    text-align: left;
    gap: 0.1rem;
}

.onboarding-suggestion-detail {
    font-size: 0.75rem;
    color: var(--text-dim);
}

.totp-enroll {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 0.5rem;
    margin-bottom: 0.75rem;
}

.totp-enroll img {
    width: 190px;
    height: 190px;
    padding: 10px;
    background: #fff;
    border-radius: 8px;
    box-shadow: 0 8px 24px rgb(0 0 0 / 35%);
}

.totp-secret {
    font-family: monospace;
    font-size: 0.85rem;
    color: var(--text-dim);
    word-break: break-all;
    text-align: center;
    padding: 0.35rem 0.6rem;
    background: var(--bg);
    border: 1px solid var(--panel-border);
    border-radius: 6px;
}

.recovery-codes {
    list-style: none;
    counter-reset: recovery-code;
    margin: 0.75rem 0;
    padding: 0.85rem;
    background: var(--bg);
    border: 1px solid var(--panel-border);
    border-radius: 8px;
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 0.4rem;
    font-family: monospace;
}

.recovery-codes li {
    counter-increment: recovery-code;
    padding: 0.35rem 0.5rem;
    background: var(--panel);
    border: 1px solid var(--panel-border);
    border-radius: 6px;
    font-size: 0.85rem;
    letter-spacing: 0.02em;
}

.recovery-codes li::before {
    content: counter(recovery-code) ". ";
    color: var(--text-dim);
}

.checkbox-row {
    display: flex;
    align-items: center;
    gap: 0.4rem;
}

.checkbox-row input[type="checkbox"] {
    accent-color: var(--accent);
    width: 16px;
    height: 16px;
}

.link-button {
    background: none;
    border: none;
    color: var(--accent);
    cursor: pointer;
    padding: 0;
    font-size: 0.85rem;
    text-align: left;
}

/* --- Admin --- */

.admin-screen {
    width: 100%;
    min-height: 100vh;
    padding: 1.5rem;
}

.admin-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    margin-bottom: 1rem;
}

.admin-header-actions {
    display: flex;
    align-items: center;
    gap: 0.5rem;
}

.admin-user-table {
    width: 100%;
    border-collapse: collapse;
}

.admin-user-table th,
.admin-user-table td {
    text-align: left;
    padding: 0.85rem 0.6rem;
    border-bottom: 1px solid var(--panel-border);
}

.admin-row-actions {
    display: flex;
    flex-wrap: wrap;
    gap: 0.4rem;
}

.admin-session-list {
    list-style: none;
    margin: 0;
    padding: 0.5rem 0;
    display: flex;
    flex-direction: column;
    gap: 0.4rem;
    color: var(--text-dim);
    font-size: 0.85rem;
}

.admin-session-list li {
    display: flex;
    align-items: center;
    gap: 0.4rem;
}

.admin-tabs {
    display: flex;
    gap: 0.4rem;
    margin-bottom: 1rem;
    border-bottom: 1px solid var(--panel-border);
}

/* Le reste de l'écran d'administration s'en remet aux boutons par défaut du
   navigateur — `.ghost` n'est stylé que sous `.widget`/`.modal-panel`/
   `.fullscreen-panel`. Ces deux-là se suffisent donc à eux-mêmes. */
.admin-tab {
    appearance: none;
    padding: 0.5rem 0.9rem;
    background: transparent;
    border: none;
    border-bottom: 2px solid transparent;
    border-radius: var(--radius) var(--radius) 0 0;
    color: var(--text-dim);
    font: inherit;
    cursor: pointer;
}

.admin-tab:hover {
    color: var(--text);
}

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

/* --- Admin: flux de requêtes sortantes --- */

.traffic-empty {
    color: var(--text-dim);
    padding: 1rem 0;
}

.traffic-table td {
    vertical-align: top;
}

.traffic-number {
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
}

.traffic-subtle {
    color: var(--text-dim);
    font-size: 0.85em;
}

.traffic-source-button {
    appearance: none;
    padding: 0;
    background: transparent;
    border: none;
    color: var(--text);
    font: inherit;
    font-weight: 600;
    text-align: left;
    cursor: pointer;
}

.traffic-source-button:hover {
    color: var(--accent);
    text-decoration: underline;
}

.traffic-close {
    appearance: none;
    padding: 0.3rem 0.7rem;
    background: transparent;
    border: 1px solid var(--panel-border);
    border-radius: var(--radius);
    color: var(--text-dim);
    font: inherit;
    cursor: pointer;
}

.traffic-close:hover {
    color: var(--text);
}

.traffic-row-open {
    background: color-mix(in srgb, var(--accent) 10%, transparent);
}

/* Le signalement ne repose pas sur la seule couleur : la ligne porte aussi
   le libellé « ⚠ Plus de 10 % d'erreurs sur 24 h ». La barre latérale n'est
   qu'un repère de balayage. */
.traffic-row-warned td:first-child {
    box-shadow: inset 3px 0 0 var(--danger);
}

.traffic-warning,
.traffic-rate-warned {
    color: var(--danger);
}

.traffic-warning {
    display: block;
    font-size: 0.78rem;
    margin-top: 0.15rem;
}

.traffic-chips {
    display: flex;
    flex-wrap: wrap;
    gap: 0.3rem;
}

.traffic-chip {
    display: inline-flex;
    align-items: center;
    gap: 0.3rem;
    padding: 0.1rem 0.4rem;
    border-radius: 999px;
    border: 1px solid var(--panel-border);
    background: var(--panel);
    font-size: 0.78rem;
    font-variant-numeric: tabular-nums;
}

.traffic-chip-error {
    border-color: color-mix(in srgb, var(--danger) 55%, var(--panel-border));
    color: var(--danger);
}

.traffic-chip-count {
    color: var(--text-dim);
}

.traffic-detail {
    margin-top: 1.5rem;
    padding: 1rem;
    border: 1px solid var(--panel-border);
    border-radius: var(--radius);
    background: var(--panel);
}

.traffic-detail-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.5rem;
}

.traffic-detail-header h2 {
    margin: 0;
    font-size: 1.1rem;
}

.traffic-chart {
    position: relative;
    margin: 0.5rem 0 1.5rem;
}

.traffic-chart-title {
    margin: 0.75rem 0 0.35rem;
    font-size: 0.85rem;
    font-weight: 600;
    color: var(--text-dim);
}

/* Le cadre d'un graphe : une gouttière à gauche pour les graduations, et le
   dessin qui occupe tout le reste. Sa hauteur est posée en ligne par le
   composant (`frame_height`), le dessin lui-même étant plus court de la
   marge où débordent les graduations extrêmes. */
.traffic-plot-frame {
    position: relative;
}

/* La zone de dessin proprement dite. C'est elle qui porte les décalages, et
   pas le `<svg>` : un SVG est un élément *remplacé*, donc `top`/`bottom` ne
   le dimensionnent pas — il garde le rapport de son `viewBox` et déborde
   (mesuré : 232 px de haut pour un cadre de 160 px, les barres du premier
   graphe débordant sur le second). Un `<div>` ordinaire, lui, se laisse
   dimensionner, et le SVG le remplit à 100 %. */
.traffic-plot-area,
.traffic-y-axis {
    position: absolute;
    top: 0.44rem;
    bottom: 0.44rem;
}

.traffic-plot-area {
    left: 3.4rem;
    right: 0.75rem;
}

/* Étiré en largeur (`preserveAspectRatio="none"`) : il ne porte que de la
   géométrie. Tout le texte est en HTML par-dessus — voir `PLOT_WIDTH`. */
.traffic-plot {
    display: block;
    width: 100%;
    height: 100%;
    overflow: visible;
}

.traffic-plot-overlay {
    position: absolute;
    inset: 0;
    pointer-events: none;
}

.traffic-y-axis {
    left: 0;
    width: 3.4rem;
}

.traffic-y-axis .traffic-axis-label {
    position: absolute;
    right: 0.5rem;
    transform: translateY(-50%);
}

.traffic-x-axis {
    position: relative;
    height: 1.2rem;
    margin: 0.2rem 0.75rem 0 3.4rem;
}

.traffic-x-axis .traffic-axis-label {
    position: absolute;
    transform: translateX(-50%);
}

.traffic-axis-label {
    color: var(--text-dim);
    font-size: 0.72rem;
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
}

.traffic-gridline {
    stroke: var(--panel-border);
    stroke-width: 1;
    vector-effect: non-scaling-stroke;
}

.traffic-baseline {
    stroke: color-mix(in srgb, var(--text-dim) 55%, transparent);
}

.traffic-threshold {
    stroke: var(--danger);
    stroke-width: 1;
    stroke-opacity: 0.7;
    vector-effect: non-scaling-stroke;
}

/* Posé sur le dessin, donc au-dessus des barres : sans son propre fond il
   devient illisible dès qu'une journée dépasse le seuil — c'est-à-dire
   précisément quand on le lit. */
.traffic-threshold-label {
    position: absolute;
    right: 0;
    transform: translateY(-100%);
    margin-bottom: 2px;
    padding: 0 0.25rem;
    border-radius: 3px;
    background: color-mix(in srgb, var(--panel) 88%, transparent);
    color: var(--danger);
    font-size: 0.7rem;
    white-space: nowrap;
}

.traffic-bar {
    fill: var(--accent);
}

.traffic-bar-error {
    fill: var(--danger);
}

.traffic-hover-band {
    fill: transparent;
}

.traffic-hover-band-active {
    fill: color-mix(in srgb, var(--text) 8%, transparent);
}

.traffic-tooltip {
    position: absolute;
    top: 0;
    transform: translateX(-50%);
    display: flex;
    flex-direction: column;
    gap: 0.1rem;
    padding: 0.4rem 0.6rem;
    border: 1px solid var(--panel-border);
    border-radius: var(--radius);
    background: var(--bg);
    font-size: 0.78rem;
    white-space: nowrap;
    pointer-events: none;
    z-index: 2;
}

.traffic-recent-table {
    table-layout: fixed;
}

.traffic-recent-table th:nth-child(1),
.traffic-recent-table td:nth-child(1) {
    width: 7.5rem;
}

.traffic-recent-table th:nth-child(2),
.traffic-recent-table td:nth-child(2) {
    width: 7rem;
}

.traffic-recent-table th:nth-child(3),
.traffic-recent-table td:nth-child(3) {
    width: 6rem;
}

.traffic-recent-table td {
    padding: 0.4rem 0.6rem;
    font-size: 0.85rem;
    white-space: nowrap;
}

.traffic-row-failed td {
    background: color-mix(in srgb, var(--danger) 8%, transparent);
}

.traffic-method {
    display: inline-block;
    margin-right: 0.4rem;
    color: var(--text-dim);
    font-size: 0.75rem;
}

.traffic-url {
    max-width: 0;
    width: 100%;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
    font-size: 0.78rem;
}

/* --- Account settings: "mes villes" --- */

.location-list {
    list-style: none;
    margin: 0 0 0.75rem;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 0.4rem;
}

.location-row {
    display: flex;
    align-items: center;
    gap: 0.5rem;
}

.location-label {
    flex: 1;
}

.sidebar-account-bar {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    padding: 0.5rem 0.25rem 0;
    margin-top: 0.5rem;
    border-top: 1px solid var(--panel-border);
}

.sidebar-username {
    flex: 1;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    color: var(--text-dim);
    font-size: 0.85rem;
}

/* The per-subscription notification setting on a podcast's own page — the
   RSS equivalent reuses `.rss-config-detail-label`, which this section has no
   counterpart to. */
.podcast-notify-field {
    display: flex;
    align-items: center;
    gap: 0.6rem;
    margin-bottom: 0.75rem;
    color: var(--text-dim);
    font-size: 0.85rem;
}

/* The offer to enable system notifications, as a sticky strip beside
   `.offline-banner` and `.update-banner` — same role, same placement, and the
   same reason for being a strip rather than a modal: it is worth noticing and
   not worth blocking on. */
.notification-banner {
    position: sticky;
    top: 0;
    z-index: 50;
    display: flex;
    align-items: center;
    gap: 0.75rem;
    padding: 0.5rem 1rem;
    background: var(--panel);
    border-bottom: 1px solid var(--panel-border);
    font-size: 0.9rem;
}

/* Takes the slack so the two buttons sit hard right. */
.notification-banner > span {
    flex: 1;
    min-width: 0;
}

.notification-banner > button:not(.icon-button) {
    flex: none;
    padding: 0.25rem 0.8rem;
    border: 1px solid var(--accent);
    border-radius: var(--radius);
    background: none;
    color: var(--accent);
    cursor: pointer;
}

/* --- Notifications --- */

/* The badge is positioned against this button, so it needs a containing
   block; `.icon-button` supplies everything else. */
.notification-bell {
    position: relative;
}

/* Overlaps the bell's top-right rather than sitting beside it: the account
   bar is a fixed-width strip shared with the username, and a badge that
   took its own column would push the two buttons around as the count
   changed. `min-width` + `border-radius: 999px` keeps "9" a circle and
   "99+" a lozenge. */
.notification-badge {
    position: absolute;
    top: -0.15rem;
    right: -0.25rem;
    min-width: 1.05rem;
    padding: 0 0.2rem;
    border-radius: 999px;
    background: var(--accent);
    color: #ffffff;
    font-size: 0.7rem;
    line-height: 1.05rem;
    text-align: center;
    /* The bell's own colour is inherited from the button, which dims on
       hover — the badge is a count, not part of the icon. */
    pointer-events: none;
}

/* ---- Why every button rule below is scoped to `.fullscreen-panel` --------
   `.widget button, .modal-panel button, .auth-card button, .fullscreen-panel
   button` fills every button in a panel with `color-mix(--accent 55%)` and
   white text. It is (0,1,1); a bare `.notification-chip` is (0,1,0), so it
   loses, and the whole first pass at this panel's styling silently did
   nothing — the chips and rows kept rendering as filled blue buttons, which
   is exactly the "trop de bleu" that prompted the redesign. Scoping to
   (0,2,0) is how `.icon-button` and `.contact-relation-link` already escape
   the same rule; the file's own comment on the latter warns about it. */

.notification-filters {
    display: flex;
    flex-wrap: wrap;
    gap: 0.4rem;
    margin-bottom: 0.75rem;
}

/* Filled on selection rather than accent text on an accent border. The panel
   already spends its blue on the unread dots, and outlining every chip in it
   made the filter row compete with the list it filters. */
.fullscreen-panel .notification-chip {
    padding: 0.25rem 0.7rem;
    border: 1px solid transparent;
    border-radius: 999px;
    background: none;
    color: var(--text-dim);
    font-size: 0.85rem;
    cursor: pointer;
}

.fullscreen-panel .notification-chip:hover {
    color: var(--text);
}

.fullscreen-panel .notification-chip.selected {
    background: var(--panel-border);
    color: var(--text);
}

/* Sits under the filter chips and above the list: an action about the
   device, not about any one notification. */
.notification-push {
    margin-bottom: 0.75rem;
}

/* The panel's two quiet buttons — the header's "tout marquer comme lu" and
   the device toggle. Both are `.link-button`, which is (0,1,0) and loses to
   `.fullscreen-panel button`, so both rendered as filled blue buttons.
   Overriding `color` alone is not enough and was the bug a screenshot caught
   after the computed-style check passed: the losing rule sets `background`
   too, so the fill survived a colour-only override. Every property that rule
   sets has to be put back.
   
   A dedicated class for the header one rather than `.fullscreen-header
   .link-button`, which would silently restyle any future panel that puts a
   link-button in its header. */
.notification-push .link-button,
.fullscreen-header .notification-mark-all {
    padding: 0;
    border: none;
    background: none;
    box-shadow: none;
    border-radius: 0;
    color: var(--text-dim);
    font-weight: 400;
}

.notification-push .link-button:hover,
.fullscreen-header .notification-mark-all:hover {
    color: var(--text);
}

.notification-list {
    display: flex;
    flex-direction: column;
    gap: 0.3rem;
    margin: 0;
    padding: 0;
    list-style: none;
}

/* Read is the resting state and recedes: no fill, no border, dimmed text. An
   unread row is the one *raised* out of the list. Doing it the other way
   round — styling only the unread edge, as this first did — left both states
   looking like the same card with a 3px difference nobody could see. */
.notification-row {
    display: flex;
    align-items: stretch;
    border: 1px solid transparent;
    border-radius: var(--radius);
    background: none;
}

.notification-row.unread {
    background: var(--panel);
    border-color: var(--panel-border);
}

.fullscreen-panel .notification-row-button {
    display: flex;
    flex: 1;
    min-width: 0;
    flex-direction: column;
    gap: 0.2rem;
    padding: 0.6rem 0.4rem 0.6rem 0.8rem;
    border: none;
    background: none;
    color: inherit;
    font: inherit;
    text-align: left;
    cursor: pointer;
}

.notification-row-head {
    display: flex;
    align-items: baseline;
    gap: 0.6rem;
}

.notification-row-title {
    flex: 1;
    min-width: 0;
    color: var(--text-dim);
}

.notification-row.unread .notification-row-title {
    color: var(--text);
    font-weight: 600;
}

/* The dot is the only blue in a row, and together with the weight above it
   carries the whole unread signal — one small mark instead of a coloured
   edge on every line. */
.notification-row.unread .notification-row-title::before {
    content: "";
    display: inline-block;
    width: 0.45rem;
    height: 0.45rem;
    margin-right: 0.45rem;
    border-radius: 50%;
    background: var(--accent);
    vertical-align: middle;
}

.notification-row-time {
    flex: none;
    color: var(--text-dim);
    font-size: 0.8rem;
}

.notification-row-body {
    color: var(--text-dim);
    font-size: 0.85rem;
    /* Two lines of a summary, never the whole article: the panel is a list
       to scan, and the link is one tap away. */
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
}

/* Sized by `.fullscreen-panel .icon-button` — a 26px circle — so this adds
   spacing and nothing else. An earlier version set `padding: 0 0.6rem` here,
   which fought that fixed width and squeezed the glyph out of its own box,
   and dimmed the whole thing to `opacity: 0.35` on top of `--text-dim`,
   which on this background is not a subtle control but an invisible one.
   Restraint here has to come from colour, not from opacity. */
.fullscreen-panel .notification-row-dismiss {
    flex: none;
    align-self: center;
    margin-right: 0.5rem;
}

.fullscreen-panel .notification-row-dismiss:hover {
    color: var(--danger);
    border-color: var(--danger);
}

/* --- Radio player --- */

/* Not `.widget` — `.sidebar .widget { flex: 1 }` would make it fight
   `ShortcutsWidget` for the sidebar's vertical space, but this is
   deliberately a slim strip (station name + play/pause), same compactness
   as `.sidebar-account-bar` right below it. `.playing` tints it faintly
   toward `--accent` so "something is actually playing" is visible at a
   glance even with the sidebar collapsed to just this bar. */
.radio-player {
    position: relative;
    flex: 0 0 auto;
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.5rem;
    padding: 0.5rem 0.6rem;
    background: var(--panel);
    border: 1px solid var(--panel-border);
    border-radius: var(--radius);
    transition: border-color 0.2s ease, background 0.2s ease;
}

.radio-player.playing {
    border-color: color-mix(in srgb, var(--accent) 45%, var(--panel-border) 55%);
    background: color-mix(in srgb, var(--accent) 7%, var(--panel) 93%);
}

/* The video-podcast stage, above the transport line — per user
   request 2026-09-01. Hidden until the `<video>` element is the one actually
   playing, which is what its own `.visible` tracks: the strip's `.video-mode`
   is about the strip's layout, this is about the element.

   Width-capped rather than height-capped, and the two numbers are the same
   number: 71vh of width at 16/9 is 40vh of height. Capping the height alone
   would leave a full-width black box with a letterboxed video inside it on
   the audio page, where the strip is as wide as the window. In the sidebar
   the column is narrower than either cap and `width: 100%` is what wins.

   `object-fit: contain` over black for everything that is not 16/9 — a
   filmed podcast is whatever aspect its camera was, and cropping someone's
   head off to fill the box is worse than a margin. */
.player-video {
    display: none;
}

.player-video.visible {
    display: block;
    flex: 1 1 100%;
    width: 100%;
    max-width: min(100%, 71vh);
    max-height: 40vh;
    aspect-ratio: 16 / 9;
    margin-inline: auto;
    object-fit: contain;
    background: #000;
    border-radius: calc(var(--radius) - 2px);
}

/* Native fullscreen takes the element out of the strip's layout entirely,
   so every cap above has to come back off — otherwise the video sits as a
   40vh letterbox in the middle of a black screen.

   This is the only fullscreen path, and it is the bare `<video>` that goes:
   the element wears `controls` while it is there (see `CompactPlayerBar`),
   which is what makes the browser draw its own player — the thumb-sized one
   on a phone, and the one Firefox has on the desktop. Nothing here has to
   lay out a transport, because none of it is ours. */
.player-video:fullscreen {
    width: 100%;
    height: 100%;
    max-width: none;
    max-height: none;
    aspect-ratio: auto;
    border-radius: 0;
}

/* Lit while the picture is on, so the button says which mode is running
   rather than only offering to change it. */
.player-video-toggle.active {
    color: var(--accent);
}

/* The transport line: cover, the two titles, and the controls. Its own
   element rather than letting the strip's children wrap freely, because the
   scrubber below has to be able to take a whole line of its own — a
   `flex-basis: 100%` sibling only lands on a new row if what precedes it
   also fills one. */
.radio-player-main {
    flex: 1 1 100%;
    display: flex;
    align-items: center;
    gap: 0.5rem;
    min-width: 0;
}

/* Nothing is loaded, so the controls are dead and the strip should not
   pretend otherwise. */
.radio-player.idle {
    color: var(--text-dim);
}

/* The standalone audio page's player, pinned to the bottom of the window —
   per user request 2026-08-29. Not `position: fixed`: `.audio-page` is a
   flex column the height of the viewport whose middle child scrolls, so
   being the last child *is* being docked, and the layout keeps working
   inside a fullscreen panel where a fixed element would escape it.

   The safe-area inset moves here from `.audio-page-body` with it: this is
   now the element a phone's gesture bar would sit on top of. */
.radio-player.docked {
    flex: 0 0 auto;
    margin-top: 0.5rem;
    padding-bottom: calc(0.5rem + env(safe-area-inset-bottom, 0px));
}

/* Smaller in the sidebar than on the audio page: the dashboard's strip is
   one row of a narrow column, and 40px of cover art there costs the episode
   title most of its width. */
.radio-player:not(.docked) .radio-now-playing-favicon {
    width: 28px;
    height: 28px;
    border-radius: 6px;
}

.radio-player .radio-now-playing-info.placeholder {
    color: var(--text-dim);
}

/* The strip is not the now-playing *card* it took its design from: it has no
   tinted panel of its own, because it already sits in one. */
.radio-player .radio-now-playing-name {
    font-size: 0.9rem;
}

.radio-player.docked .radio-now-playing-name {
    font-size: 1rem;
}

/* The marquee's *window*: fixed to whatever the text column is wide, and
   clipping. The element that moves is the one inside it — see
   `bar::TrackMarquee` for why the two cannot be the same element. */
.radio-player .radio-now-playing-track {
    display: block;
    max-width: 100%;
    overflow: hidden;
}

/* Belt and braces: nothing in the text column may push the player's own box
   wider, whatever a station decides to put in a track title. */
.radio-player .radio-now-playing-info {
    overflow: hidden;
}

/* Overlay shown briefly (see `handle_volume_wheel`) while scrolling the
   wheel over the compact bar to adjust volume — covers the whole bar
   (`inset: 0`) rather than sitting alongside its normal content, since
   there's no spare room in this deliberately slim strip for a permanent
   volume readout. Always mounted (`class:visible` toggle, not
   conditionally rendered) so the opacity transition actually plays on the
   way out, not just in. */
.radio-volume-overlay {
    position: absolute;
    inset: 0;
    display: flex;
    align-items: center;
    gap: 0.5rem;
    padding: 0 0.6rem;
    background: var(--panel);
    border-radius: inherit;
    opacity: 0;
    pointer-events: none;
    transition: opacity 0.15s ease;
}

.radio-volume-overlay.visible {
    opacity: 1;
}

.radio-volume-overlay-bar {
    flex: 1;
    height: 4px;
    background: var(--panel-border);
    border-radius: 999px;
    overflow: hidden;
}

.radio-volume-overlay-fill {
    height: 100%;
    background: var(--accent);
    transition: width 0.1s ease;
}

.radio-volume-overlay-value {
    flex: none;
    font-size: 0.75rem;
    color: var(--text-dim);
}

/* Seamless-loop marquee for the title+station combo, which regularly runs
   longer than the sidebar is wide — two copies of the label back to back,
   sliding by exactly `-50%` so the animation always lands on the second
   (identical) copy's start with no jump, whatever the label's actual
   length. Only used once a track title is available (see the `has_title`
   check at the call site); a bare station name stays static, ellipsis-
   truncated like before. */
.radio-player-name-marquee {
    display: inline-flex;
    width: max-content;
}

/* Only once the text has been measured to be wider than its window. A title
   that already fits should sit still — animating it is motion that carries
   no information. The duration is set inline per title, from its measured
   width (see `bar::TrackMarquee`), so the pace is the same whatever the
   length — the `12s` here is only the fallback the inline value overrides. */
.radio-player-name-marquee.scrolling {
    animation: radio-marquee 12s linear infinite;
}

.radio-player-name-marquee span {
    white-space: nowrap;
    padding-right: 2.5rem;
}

/* Nothing is moving, so there is no seam to cover and the second copy would
   just read as the title written twice. The first one goes back to being an
   ordinary truncated line. */
.radio-player-name-marquee:not(.scrolling) {
    max-width: 100%;
}

.radio-player-name-marquee:not(.scrolling) .radio-marquee-echo {
    display: none;
}

.radio-player-name-marquee:not(.scrolling) span {
    padding-right: 0;
    overflow: hidden;
    text-overflow: ellipsis;
}

@keyframes radio-marquee {
    from {
        transform: translateX(0);
    }

    to {
        transform: translateX(-50%);
    }
}

.radio-player-error {
    margin: 0.35rem 0 0;
    font-size: 0.8rem;
}

/* Small "now playing" bars — CSS-only bounce animation, staggered per bar
   via `:nth-child` delays. Shown next to the station name in both the
   compact bar and the fullscreen now-playing card, only while `playing` is
   true. */
.radio-eq {
    display: inline-flex;
    align-items: flex-end;
    gap: 2px;
    height: 12px;
    flex: 0 0 auto;
}

.radio-eq-bar {
    width: 3px;
    height: 3px;
    background: var(--accent);
    border-radius: 1px;
    animation: radio-eq-bounce 0.9s ease-in-out infinite;
}

.radio-eq-bar:nth-child(2) {
    animation-delay: 0.2s;
}

.radio-eq-bar:nth-child(3) {
    animation-delay: 0.4s;
}

@keyframes radio-eq-bounce {
    0%,
    100% {
        height: 3px;
    }

    50% {
        height: 12px;
    }
}

/* Persistent "now playing" card at the top of the fullscreen panel —
   favicon, name, play/pause, and volume, so the one station actually
   playing doesn't get lost among search results and the saved-stations
   list below it. */
.radio-now-playing {
    display: flex;
    align-items: center;
    gap: 0.75rem;
    padding: 0.75rem 1rem;
    margin-bottom: 1.25rem;
    background: color-mix(in srgb, var(--accent) 8%, var(--panel) 92%);
    border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--panel-border) 65%);
    border-radius: var(--radius);
}

.radio-now-playing-favicon {
    width: 40px;
    height: 40px;
    border-radius: 8px;
    flex: 0 0 auto;
    object-fit: contain;
    background: var(--bg);
}

.radio-now-playing-info {
    flex: 1;
    min-width: 0;
    display: flex;
    flex-direction: column;
    gap: 0.15rem;
}

.radio-now-playing-label {
    font-size: 0.72rem;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: var(--text-dim);
    /* A show's name goes on this line for a podcast, and some of them are
       long enough to wrap to five lines in a narrow card — which then
       squeezed the episode title beside it down to a single letter. */
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* The now-playing card's scrubber. Was an inline `style` on the element,
   which no media query could override — see `PodcastNowPlayingCard`. */
.podcast-seek {
    flex: 1 1 auto;
    min-width: 80px;
}

.radio-now-playing-name {
    font-size: 1rem;
    font-weight: 600;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.radio-now-playing-track {
    font-size: 0.85rem;
    color: var(--text-dim);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.radio-volume {
    display: flex;
    align-items: center;
    gap: 0.4rem;
    flex: 0 0 auto;
    color: var(--text-dim);
}

.radio-volume input[type="range"] {
    width: 90px;
    accent-color: var(--accent);
}

/* Boxed off from the rest of the panel so the search form/results read as
   one distinct tool rather than spilling straight into the page — the
   bounded, scrollable result list keeps a broad query (e.g. "france") from
   pushing "Mes radios" off-screen. */
.radio-search-box {
    padding: 0.85rem 1rem 1rem;
    margin-bottom: 1.25rem;
    background: var(--bg);
    border: 1px solid var(--panel-border);
    border-radius: var(--radius);
}

/* The input and its conditional "clear" button share one row — the button
   only rendered at all once there's something to close (a typed query,
   results, or a "no results" state), rather than a fixed slot that could
   crowd/overlap the input when empty. */
.radio-search-input-row {
    display: flex;
    align-items: center;
    gap: 0.5rem;
}

.radio-search-input-row input[type="text"] {
    flex: 1;
}

/* The country filter sits on its own line under the query rather than
   beside it: on a phone the two side by side leave the text input too
   narrow to read a station name back in, and the filter is the one of the
   pair that is usually left alone. */
.radio-search-country {
    margin-top: 0.5rem;
    width: 100%;
}

/* Collapsed to a single button until used — adding a station by hand is the
   rare path (the catalogue covers almost everything), and two more text
   fields permanently above "Mes radios" would push the list most people
   actually came for further down every visit. */
.radio-manual-box {
    margin-bottom: 1.25rem;
}

/* Last thing in the "Ajouter une radio" dialog, where that gap is padding
   the panel already provides. */
.modal-panel .radio-manual-box:last-child {
    margin-bottom: 0;
}

.radio-manual-toggle {
    display: flex;
    align-items: center;
    gap: 0.4rem;
    width: 100%;
    justify-content: center;
}

.radio-manual-actions {
    display: flex;
    gap: 0.5rem;
    justify-content: flex-end;
}

/* Takes the place of `.radio-station-name`, so it has to claim the same
   share of the row — without `min-width: 0` the input's default intrinsic
   width would push the buttons past the right edge instead of shrinking. */
.radio-rename-form {
    display: flex;
    align-items: center;
    gap: 0.3rem;
    flex: 1;
    min-width: 0;
}

.radio-rename-input {
    flex: 1;
    min-width: 0;
}

/* A section title with its own controls on the right: "Titres joués" and its
   "Effacer", "Mes radios" and the gear that opens the add dialog. */
.radio-history-header,
.radio-section-header {
    display: flex;
    align-items: center;
    gap: 0.6rem;
}

/* The heading takes the slack so the button beside it sits hard right. */
.radio-history-header .movie-section-title,
.radio-section-header .movie-section-title {
    flex: 1;
}

.radio-history-list {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 0.3rem;
    /* Bounded and scrollable for the same reason as `.radio-search-results`
       above: a hundred entries (the API's page size) would otherwise turn
       the tab into one long scroll with the stations far off the top. */
    max-height: 300px;
    overflow-y: auto;
}

.radio-history-row {
    display: flex;
    flex-direction: column;
    gap: 0.1rem;
    padding: 0.4rem 0.6rem;
    background: var(--panel);
    border: 1px solid var(--panel-border);
    border-radius: 8px;
}

.radio-history-title {
    font-size: 0.9rem;
    /* One line, clipped: an ICY `StreamTitle` is free-form and some stations
       pack the show name, the artist and a sponsor into it. */
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.radio-history-meta {
    font-size: 0.75rem;
    color: var(--text-dim);
}

.radio-search-results {
    list-style: none;
    margin: 0.75rem 0 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 0.3rem;
    max-height: 260px;
    overflow-y: auto;
}

.radio-search-hint {
    margin: 0.6rem 0 0;
    color: var(--text-dim);
    font-size: 0.85rem;
}

/* --- What the radio is playing, resolved (`components::player::track`) --- */

/* First thing in the radios tab, above the search box: while a station is
   on, this is the part of the screen that is actually being looked at. It
   draws nothing at all between records or on a talk station, so the search
   box is at the top whenever there is nothing to say. */
.track-panel {
    margin-bottom: 1rem;
    padding: 0.75rem;
    background: var(--panel);
    border: 1px solid var(--panel-border);
    border-radius: 10px;
}

.track-panel-head {
    display: flex;
    align-items: center;
    gap: 0.75rem;
}

.track-cover {
    /* Deliberately larger than `.radio-now-playing-favicon`'s 40px: this is
       the sleeve of the record, not a station's icon in a list, and the
       archive serves it at 500px. */
    width: 72px;
    height: 72px;
    flex: 0 0 auto;
    border-radius: 6px;
    object-fit: cover;
    background: var(--bg);
}

.track-identity {
    flex: 1;
    min-width: 0;
    display: flex;
    flex-direction: column;
    gap: 0.1rem;
}

/* All three clip rather than wrap. A resolved title is short by
   construction, but `MusicBrainz` credits collaborations in full ("X feat. Y
   & Z"), which wraps to three lines in a sidebar and pushes the lyrics off
   the bottom of a phone. */
.track-artist,
.track-title,
.track-album {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.track-artist {
    font-size: 0.78rem;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: var(--text-dim);
}

.track-title {
    font-size: 1rem;
    font-weight: 600;
}

.track-album {
    font-size: 0.8rem;
    color: var(--text-dim);
}

.track-save.saved,
.track-save.saved:hover {
    /* Kept: the star stays lit, and the button is disabled, so it must not
       also read as greyed out. */
    color: var(--accent);
    opacity: 1;
}

/* The record button, beside the star. Disabled while a capture is running —
   pressing it again would arm the same track twice — so, exactly like
   `.track-save.saved` above, it must not also read as greyed out. */
.track-capture.armed,
.track-capture.armed:hover {
    color: var(--danger);
    opacity: 1;
}

/* Red only while recording, which is the one moment the convention means
   something. A resting record button is an ordinary icon button. */
.track-capture.armed {
    animation: track-capture-pulse 1.6s ease-in-out infinite;
}

@keyframes track-capture-pulse {
    50% {
        opacity: 0.45;
    }
}

/* The sentence under the header: what is being recorded, and the one thing
   the button cannot say — that stopping playback loses it. Full width of the
   panel rather than inline, because it is a sentence and the header row
   already holds a cover, three lines and two buttons. */
.track-capture-status {
    margin: 0.4rem 0 0;
    font-size: 0.75rem;
    line-height: 1.35;
    color: var(--text-dim);
}

.track-capture-status.armed {
    color: var(--danger);
}

.track-capture-status.stored {
    color: var(--accent);
}

.track-lyrics {
    margin-top: 0.75rem;
    padding-top: 0.6rem;
    border-top: 1px solid var(--panel-border);
}

/* A floor under the following window, so the panel does not jump every time
   a short line follows a long one — six lines at this line-height, which is
   what `LINES_BEFORE`/`LINES_AFTER` render. */
.track-lyrics-window {
    min-height: 9rem;
}

/* "Tout afficher": the whole file, scrolling on its own rather than growing
   the panel past the screen. */
.track-lyrics-all {
    max-height: 260px;
    overflow-y: auto;
}

.track-lyrics-line {
    margin: 0;
    padding: 0.1rem 0;
    font-size: 0.9rem;
    line-height: 1.35;
    color: var(--text-dim);
    /* The highlight moves between lines four times a second at most, and a
       hard swap reads as flicker. */
    transition: color 0.2s ease, opacity 0.2s ease;
    opacity: 0.65;
}

.track-lyrics-line.current {
    color: var(--text);
    font-weight: 600;
    opacity: 1;
}

/* Unsynchronised lyrics: nothing to follow, so this is a plain block that
   keeps the file's own line breaks and scrolls on its own. */
.track-lyrics-plain {
    margin: 0.75rem 0 0;
    padding-top: 0.6rem;
    border-top: 1px solid var(--panel-border);
    font-size: 0.9rem;
    line-height: 1.4;
    color: var(--text-dim);
    white-space: pre-line;
    max-height: 260px;
    overflow-y: auto;
}

.track-lyrics-note {
    margin: 0.75rem 0 0;
    font-size: 0.85rem;
    color: var(--text-dim);
}

.track-lyrics-controls {
    display: flex;
    align-items: center;
    gap: 0.4rem;
    margin-top: 0.5rem;
}

.track-lyrics-offset {
    font-size: 0.78rem;
    color: var(--text-dim);
    font-variant-numeric: tabular-nums;
}

.saved-track-list {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 0.3rem;
    /* Same bound and the same reason as `.radio-history-list`. */
    max-height: 300px;
    overflow-y: auto;
}

.saved-track-row {
    display: flex;
    align-items: center;
    gap: 0.6rem;
    padding: 0.4rem 0.6rem;
    background: var(--panel);
    border: 1px solid var(--panel-border);
    border-radius: 8px;
}

.saved-track-name {
    flex: 1;
    min-width: 0;
    display: flex;
    flex-direction: column;
    gap: 0.1rem;
}

.saved-track-artist {
    font-size: 0.75rem;
    color: var(--text-dim);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.saved-track-title {
    font-size: 0.9rem;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* Under the title rather than beside it: album, station and date together
   are longer than the title, and side by side they left each other a few
   characters on a phone. */
.saved-track-meta {
    font-size: 0.75rem;
    color: var(--text-dim);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.radio-station-list {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 0.4rem;
}

.radio-station-row {
    display: flex;
    align-items: center;
    gap: 0.6rem;
    padding: 0.5rem 0.6rem;
    background: var(--panel);
    border: 1px solid var(--panel-border);
    border-radius: 8px;
    transition: border-color 0.15s ease, background 0.15s ease;
}

.radio-station-row:hover {
    border-color: color-mix(in srgb, var(--accent) 30%, var(--panel-border) 70%);
}

.radio-station-row.playing,
.radio-station-row.active {
    border-color: var(--accent);
    background: color-mix(in srgb, var(--accent) 10%, var(--panel) 90%);
}

.radio-favicon {
    width: 22px;
    height: 22px;
    border-radius: 4px;
    flex: 0 0 auto;
    object-fit: contain;
}

.radio-station-name {
    flex: 1;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font-size: 0.9rem;
}

.radio-station-country {
    color: var(--text-dim);
    font-size: 0.8rem;
    flex: 0 0 auto;
}

/* Inline playlist rename (`podcasts::PodcastPlaylistRow`), per user request
   2026-08-08 ("permet à l'user de renommer une playlist") — takes over
   `.radio-station-name`'s own flex slot in place, so the row's layout
   doesn't jump when switching between the display and edit states. */
.playlist-rename-input {
    flex: 1;
    min-width: 0;
    font-size: 0.9rem;
    padding: 0.2rem 0.4rem;
}

/* The delete confirmation step — a distinct "Annuler"/"Confirmer" pair
   replacing the row's trash icon, not a same-button double-click (the
   second click needs to land on a deliberately different, clearly
   destructive control, not silently confirm on the same spot). Shared by
   any row-level quick-delete (radio stations, bought shopping items —
   see `MyRadioStationRow`/`ShoppingItemRow`), not radio-specific despite
   the original name. */
.confirm-remove {
    display: flex;
    align-items: center;
    gap: 0.4rem;
    flex: 0 0 auto;
}

.confirm-remove-text {
    font-size: 0.8rem;
    color: var(--text-dim);
    white-space: nowrap;
}

/* Podcast episode progress — a strip *inside* `.radio-player` itself
   (only rendered while a podcast episode, not a radio station, is playing
   — see `player::CompactPlayerBar`), not a separate element below it; per
   user request 2026-08-08 first for "le même emplacement de widget avec
   simplement un indicateur de progression en plus", then again explicitly
   ("fait en sorte que l'indicateur soit intégré au widget, pas en
   dessous") after an initial version rendered it as a sibling underneath.
   `.radio-player` gets `flex-wrap: wrap` and this gets `flex-basis: 100%`
   — the standard flexbox trick for "wrap this item onto its own new line"
   without switching the whole row to `flex-direction: column` (which would
   also stack the button/name/expand-button group vertically). No
   horizontal padding of its own: `.radio-player`'s own padding already
   insets it to line up with the button row above. Also reused at a larger
   size by `podcasts::PodcastEpisodeRow`'s own inline indicator. */
.player-progress-row {
    flex-basis: 100%;
    display: flex;
    align-items: center;
    gap: 0.5rem;
}

.player-progress-bar {
    flex: 1;
    height: 4px;
    border-radius: 2px;
    background: var(--panel-border);
    overflow: hidden;
}

.player-progress-fill {
    height: 100%;
    background: var(--accent);
    transition: width 0.2s linear;
}

.player-progress-time {
    font-size: 0.72rem;
    color: var(--text-dim);
    white-space: nowrap;
    flex: 0 0 auto;
}

.podcast-episode-list {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 0.4rem;
    max-height: 420px;
    overflow-y: auto;
}

.podcast-episode-row {
    display: flex;
    align-items: center;
    gap: 0.6rem;
    padding: 0.5rem 0.6rem;
    background: var(--panel);
    border: 1px solid var(--panel-border);
    border-radius: 8px;
}

.podcast-episode-row.playing {
    border-color: var(--accent);
    background: color-mix(in srgb, var(--accent) 10%, var(--panel) 90%);
}

.podcast-episode-row-info {
    flex: 1;
    min-width: 0;
    display: flex;
    flex-direction: column;
    gap: 0.2rem;
}

.podcast-episode-row-title {
    display: flex;
    align-items: center;
    gap: 0.3rem;
    font-size: 0.9rem;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* Completed episodes ("retiens les épisodes terminés et marque-les comme
   tels", per user request 2026-08-08) — dimmed title text plus a checkmark
   icon (`IconCheck`, rendered as this span's first child in
   `podcasts::EpisodeRowInfo`) rather than the earlier plain " · Lu" text
   suffix, which was easy to miss scanning a long episode list. */
.podcast-episode-row-title.completed {
    color: var(--text-dim);
}

.podcast-episode-row-title.completed svg {
    color: var(--accent);
    flex: 0 0 auto;
}

.podcast-episode-row-meta {
    font-size: 0.78rem;
    color: var(--text-dim);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* Podcast search results — accordion-expandable per user request
   2026-08-08 ("un peu en mode accordéon"): `.podcast-search-result-detail`
   is only mounted while `expanded` (see `podcasts::PodcastSearchResultRow`)
   showing a bigger cover than the collapsed row's small `.radio-favicon`
   plus the full description. */
.podcast-search-result-detail {
    display: flex;
    gap: 0.75rem;
    padding: 0.6rem 0.6rem 0.75rem;
}

.podcast-search-result-artwork {
    width: 96px;
    height: 96px;
    border-radius: 8px;
    flex: 0 0 auto;
    object-fit: cover;
}

.podcast-search-result-description {
    margin: 0;
    font-size: 0.85rem;
    color: var(--text-dim);
    max-height: 120px;
    overflow-y: auto;
}

/* Subscription tiles — bigger cover art with listening-progress counts and
   a "new episodes" badge, replacing the earlier thin `.radio-station-row`
   list; per user request 2026-08-08 ("des vignettes plus grandes... un
   nombre d'épisodes écoutés/non écoutés, un symbole pour indiquer que de
   nouveaux épisodes ont été trouvés"). */
.podcast-tile-grid {
    display: flex;
    flex-wrap: wrap;
    gap: 0.9rem;
}

/* `.podcast-tile-grid .podcast-tile` (two classes), not `.podcast-tile`
   alone — needed to out-specificity `.fullscreen-panel button`'s
   element+class selector (blue fill by default), same trap documented on
   `.widget .icon-button` above. */
.podcast-tile-grid .podcast-tile {
    width: 130px;
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: 0.3rem;
    padding: 0;
    background: none;
    border: none;
    text-align: left;
    color: inherit;
    font: inherit;
    cursor: pointer;
    position: relative;
}

.podcast-tile-artwork {
    width: 100%;
    aspect-ratio: 1 / 1;
    border-radius: 8px;
    object-fit: cover;
    background: var(--panel-border);
}

.podcast-tile-artwork-placeholder {
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 0.5rem;
    font-size: 0.75rem;
    color: var(--text-dim);
    text-align: center;
}

.podcast-tile-new-badge {
    position: absolute;
    top: 0.4rem;
    right: 0.4rem;
    background: rgba(15, 17, 21, 0.85);
}

.podcast-tile-title {
    width: 100%;
    font-size: 0.85rem;
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
}

.podcast-tile-meta {
    font-size: 0.72rem;
    color: var(--text-dim);
}

/* Per user request 2026-08-08 ("les cartes/boutons ne sont pas très bien
   stylées, en particulier au hover") — a subtle lift + accent-tinted
   artwork ring, same "hover = accent border + slight raise" language used
   by `.radio-station-row:hover` elsewhere in this file. `transition` on the
   tile itself (not just the artwork) so the raise and the ring animate
   together. */
.podcast-tile-grid .podcast-tile {
    transition: transform 0.15s ease;
}

/* `background: none` is the actual fix here, not the `transform` above —
   `.fullscreen-panel button:hover` (1 class + 1 element + `:hover`) already
   fills every button with a blue `background` on hover, and a sibling rule
   that never re-declares that property doesn't "win it back" just by being
   more specific elsewhere; the property itself has to be re-asserted (see
   this file's own `.widget button:hover` doc comment, same trap, bit this
   exact tile once already 2026-08-08). */
.podcast-tile-grid .podcast-tile:hover {
    transform: translateY(-2px);
    background: none;
    box-shadow: none;
}

.podcast-tile-grid .podcast-tile:hover .podcast-tile-artwork,
.podcast-tile-grid .podcast-tile:hover .podcast-tile-artwork-placeholder {
    box-shadow: 0 0 0 2px var(--accent);
}

.podcast-tile-grid .podcast-tile:hover .podcast-tile-title {
    color: var(--accent);
}

/* A favorited episode's row title (`podcasts::EpisodeRowInfo`) — a small
   filled star ahead of the title text, distinct from the completed
   checkmark it can appear alongside. */
.podcast-episode-row-favorite {
    color: var(--accent);
    display: inline-flex;
    flex: 0 0 auto;
}

/* `podcasts::PodcastSubscriptionsTab`'s archive toggle/button and the
   selected-podcast header row it sits in (back button + archive button
   side by side) — per user request 2026-08-08 ("archiver un abonnement"). */
.podcast-archive-toggle {
    margin-bottom: 0.6rem;
}

.podcast-detail-header-row {
    display: flex;
    flex-wrap: wrap;
    justify-content: space-between;
    gap: 0.5rem;
}

/* `podcasts::EpisodeDetailModal`'s body — full description + metadata,
   per user request 2026-08-08 ("consulter la description d'un épisode"). */
.podcast-episode-detail-header {
    display: flex;
    gap: 0.75rem;
}

.podcast-episode-detail-artwork {
    width: 96px;
    height: 96px;
    border-radius: 8px;
    flex: 0 0 auto;
    object-fit: cover;
}

.podcast-episode-detail-info {
    display: flex;
    flex-direction: column;
    gap: 0.2rem;
    min-width: 0;
}

.podcast-episode-detail-podcast {
    font-size: 0.8rem;
    color: var(--text-dim);
}

.podcast-episode-detail-title {
    margin: 0;
    font-size: 1.05rem;
}

/* Rendered via `inner_html` — real (server-sanitized, see `util::
   sanitize_html`) HTML now, not plain text, so no `white-space: pre-line`
   here (the source HTML's own `<p>`/`<br>` tags already carry whatever line
   breaks it has; `pre-line` on top of that just doubles the gaps). `p`/`a`
   margins/color are reset since the raw feed HTML has none of its own. */
.podcast-episode-detail-description {
    margin: 0.9rem 0;
    font-size: 0.9rem;
    color: var(--text-dim);
    max-height: 240px;
    overflow-y: auto;
}

.podcast-episode-detail-description p {
    margin: 0 0 0.6rem;
}

.podcast-episode-detail-description a {
    color: var(--accent);
}

.podcast-search-result-description a {
    color: var(--accent);
}

/* "Lu"/"En cours" badges (`podcasts::EpisodeDetailBody`), per user request
   2026-08-08 ("affiche un indicateur s'il est déjà lu et un autre s'il est
   en cours de lecture") — mutually exclusive, so at most one ever shows. */
.podcast-episode-detail-badges {
    display: flex;
    gap: 0.4rem;
    margin-top: 0.3rem;
}

/* An episode already in a playlist (`podcasts::EpisodeRowInfo`) — a small
   icon ahead of the title, always visible (not just inside the "add to
   playlist" dropdown), per user request 2026-08-08 ("voir qu'un épisode est
   dans une playlist sans avoir à cliquer sur le bouton +"). Same treatment
   as `.podcast-episode-row-favorite`. */
/* Was `var(--text-dim)` originally — nearly invisible next to the row's own
   dim meta text at this size, per user report 2026-08-08 ("je ne vois pas
   l'indicateur"). `--accent` matches the completed checkmark/favorite star's
   own "meaningful icon = accent color" language elsewhere on this row. */
.podcast-episode-row-in-playlist {
    color: var(--accent);
    display: inline-flex;
    flex: 0 0 auto;
    font-size: 1.1em;
}

.podcast-episode-detail-actions {
    display: flex;
    flex-wrap: wrap;
    gap: 0.5rem;
    align-items: center;
}

.modal-panel .podcast-episode-detail-actions button.ghost.active {
    color: var(--accent);
    border-color: var(--accent);
}

/* Drag-and-drop playlist reordering (`podcasts::PodcastPlaylistRow`), per
   user request 2026-08-08 ("réordonner les playlists en glissé/déposé") —
   distinct from the up/down chevrons used to reorder episodes *within* a
   playlist, since dragging a whole row reads more naturally for a short
   flat list of playlists. The default playlist has no handle (not
   draggable — always sorts first regardless, see `list_playlists`). */
.podcast-playlist-drag-handle {
    cursor: grab;
    color: var(--text-dim);
    flex: 0 0 auto;
}

.radio-station-row.dragging {
    opacity: 0.4;
}

/* Episode-within-playlist drag-and-drop (`podcasts::PodcastPlaylistEpisodesTab`),
   per user request 2026-08-08 ("glisser-déposé pour réordonner les éléments
   au sein d'une playlist") — same drag-handle/dragging-opacity language as
   the playlist-level reorder above, just on `.podcast-episode-row` instead
   of `.radio-station-row`. */
.podcast-episode-row.dragging {
    opacity: 0.4;
}

/* The speed-cycle button in `podcasts::PodcastNowPlayingCard` — fixed
   width so the label (`1×`, `1.25×`...) changing doesn't reflow the rest
   of the now-playing card's row. `.radio-now-playing .playback-speed-button`
   (two classes), not `.playback-speed-button` alone — needed to
   out-specificity `.fullscreen-panel button.ghost`'s own padding, same
   trap documented on `.widget .icon-button` above. */
.radio-now-playing .playback-speed-button {
    flex: 0 0 auto;
    width: 3.2rem;
    text-align: center;
    padding: 0.3rem 0;
    font-size: 0.8rem;
}

/* A native `<details>` disclosure rather than a hand-rolled popover — see
   `podcasts::AddToPlaylistMenu`'s own doc comment for why. `position:
   relative` + absolutely-positioned `.add-to-playlist-menu-list` keeps the
   dropdown from pushing the rest of the row/list layout around when open. */
.add-to-playlist-menu {
    position: relative;
    flex: 0 0 auto;
}

.add-to-playlist-menu > summary {
    list-style: none;
    cursor: pointer;
}

.add-to-playlist-menu > summary::-webkit-details-marker {
    display: none;
}

.add-to-playlist-menu-list {
    position: absolute;
    top: calc(100% + 0.3rem);
    right: 0;
    z-index: 5;
    min-width: 180px;
    max-height: 220px;
    overflow-y: auto;
    padding: 0.4rem;
    display: flex;
    flex-direction: column;
    gap: 0.2rem;
    background: var(--panel);
    border: 1px solid var(--panel-border);
    border-radius: 8px;
    box-shadow: 0 8px 24px rgb(0 0 0 / 35%);
}

/* `BookDetailBody`'s preview lightbox — prev/next/close anchored to the
   image's own frame (`.lightbox-frame` is the `position: relative`
   wrapper), not the viewport edges. Originally viewport-`position: fixed`
   at the screen edges; changed per user request 2026-08-08 ("moins
   déportées sur les extrémités de l'écran") — which also happened to fix a
   real click-target bug found via live testing (viewport-anchored buttons
   far from the image were unreliably hit-testable over the fixed backdrop
   on a real mouse click; anchoring them to a normal `position: relative`
   frame instead is a more conventional, robust stacking setup). */
.lightbox-frame {
    position: relative;
    display: inline-flex;
    max-width: 90vw;
    max-height: 90vh;
}

/* Bigger and higher-contrast than the plain `.icon-button` these otherwise
   inherit (still matched via `.modal-panel .icon-button`, since the
   lightbox is a DOM descendant of the detail modal) — a subtle outlined
   circle reads fine on a normal panel background but needs more presence
   floating directly over a photo. Sized up from an initial 44px per user
   request 2026-08-08 ("plus grosses"). Selector is scoped through
   `.lightbox-frame` (rather than bare `.lightbox-nav`) so its specificity
   (0,2,0) ties `.modal-panel .icon-button`'s (0,2,0) instead of losing to
   it outright — a bare single-class selector here was silently overridden,
   leaving the buttons at the base 26px `.icon-button` size and making real
   mouse clicks land wrong relative to their apparent (CSS-intended) bounds,
   the same footgun documented on `.widget .icon-button` above. */
.lightbox-frame .lightbox-nav {
    position: absolute;
    z-index: 1;
    width: 56px;
    height: 56px;
    min-width: 56px;
    font-size: 1.6rem;
    background: rgb(0 0 0 / 55%);
    border-color: rgb(255 255 255 / 30%);
    color: rgb(255 255 255 / 85%);
}

.lightbox-frame .lightbox-nav:hover {
    background: rgb(0 0 0 / 75%);
    border-color: rgb(255 255 255 / 60%);
    color: #fff;
}

/* `top: calc(50% - 28px)` (28px = half the 56px button height) rather than
   the more idiomatic `top: 50%; transform: translateY(-50%)` — live testing
   found the transform version had `pointerdown`/`pointerup` land on two
   different elements (the button, then the image underneath) for the same
   synthetic click at the same coordinates, a hit-test/compositor lag
   specific to the transformed, absolutely-positioned button that doesn't
   affect `.lightbox-close` (no transform, unaffected). A plain `top` offset
   sidesteps it entirely. `left`/`right: calc(-56px - 1rem)` places the
   button just outside the image's own edge (56px = the button's width,
   1rem = a 16px gap, per user request 2026-08-08 bumped up from an initial
   0.5rem/8px) rather than overlapping it — per user request 2026-08-08
   ("elles empiètent sur l'image, elles devraient être légèrement à côté").
   Safe on this dashboard's desktop-width layout, where `.lightbox-frame`
   sits nowhere near the viewport edge; not revisited for narrow/mobile
   viewports since the rest of this widget isn't optimized for those
   either. */
.lightbox-prev {
    left: calc(-56px - 1rem);
    top: calc(50% - 28px);
}

.lightbox-next {
    right: calc(-56px - 1rem);
    top: calc(50% - 28px);
}

.lightbox-close {
    top: 0.75rem;
    right: 0.75rem;
}

/* --- RSS --- */

/* Resets default `<button>`/`<a>` chrome so the whole sidebar widget card
   acts as one big link (per user request 2026-08-09 — "afficherait juste le
   nombre d'articles non vus"), keeping `.widget`'s own background/border/
   padding/radius (applied alongside this class, not replaced by it). Now an
   `<a href>` rather than a `<button>` (round 8 — "puisse être ouvert dans un
   autre onglet via un clic molette"), so this also undoes `<a>`'s own
   defaults (underline, link color) that `button.widget-link-card` never had
   to. */
.widget-link-card {
    display: flex;
    text-align: left;
    text-decoration: none;
    cursor: pointer;
    color: inherit;
    font: inherit;
    transition: border-color 0.15s ease;
}

.widget-link-card:hover {
    border-color: var(--accent);
}

/* `.sidebar .widget` (main.css, near `.sidebar`) gives every sidebar widget
   `flex: 1` — right for `ShortcutsWidget`/`PlayerWidget`, which want to
   share the remaining vertical space, but this widget only ever shows one
   compact row and would otherwise stretch to match them. `.read-later-widget`
   is the exact same one-row shape (per user request 2026-08-10 — "le widget
   lire plus tard ne devrait pas être plus haut que celui des RSS"): without
   this override it was competing for `flex: 1` space against
   `ShortcutsWidget`/`PlayerWidget` too, both stretching it taller than RSS
   *and* starving `ShortcutsWidget`'s own share (breaking its internal
   `.shortcuts-sidebar-list` scroll region in the process). */
.sidebar .rss-widget,
.sidebar .read-later-widget {
    flex: 0 0 auto;
}

.rss-widget-row {
    display: flex;
    align-items: center;
    gap: 0.5rem;
}

.rss-widget-label {
    font-weight: 600;
    flex: 1;
}

/* The dedicated page itself (`rss::RssPage`), mounted at its own real URL
   (`/rss`, see `crate::router`) — reuses `.fullscreen-panel`'s own button/
   form/input/link styling but overrides its visual box properties (flat,
   full-width, no border) same as round 1. Round 2 (2026-08-09 —
   "un fixed header et 3 zones redimensionnables et scrollables
   indépendamment") additionally pins this to the viewport height and hides
   its own overflow, since scrolling now happens independently inside each
   of the three panes below, not on the page itself. */
.rss-page {
    width: 100%;
    /* Takes what the shell leaves. `min-height: 0` is what lets it be
       *smaller* than its content, which is the precondition for its own body
       scrolling instead of the document — necessary, but on its own not
       sufficient: see the viewport cap below. */
    flex: 1 1 auto;
    min-height: 0;
    max-width: none;
    margin: 0;
    padding: 0.6rem;
    background: none;
    border: none;
    border-radius: 0;
    box-sizing: border-box;
    overflow: hidden;
}

/* What actually pins the reader to the viewport, and the reason its three
   panes scroll on their own (per user request 2026-08-30 — only the article
   list did).

   `.app-shell` sets `min-height: 100dvh` and no maximum, so it grows with its
   content; `.fullscreen-panel`'s `height: 100%` then resolves against an
   indefinite parent and comes out as `auto`. Every box down the chain sized
   itself to its content and the *document* scrolled instead. Measured at a
   420px viewport before this rule: shell 644px, `.rss-page-body` 584px,
   sidebar 582px for 582px of feeds — nothing to scroll, because nothing was
   ever too small for its contents. The article list was the sole exception,
   and only because the resize handle gives it an explicit pixel height.

   One definite height here is all it takes: the body below is `flex: 1 1 0`,
   so it now resolves against a real number, and `align-items: stretch` hands
   the sidebar and the reading pane a height their `overflow-y: auto` can
   finally mean something against.

   Desktop only, deliberately. On a phone the reader shows one pane at a time
   and letting the page scroll as a whole is the native behaviour there; the
   `max-width: 900px` block further down owns that layout. */
@media (min-width: 901px) {
    .rss-page {
        height: 100vh;
        height: 100dvh;
    }
}

.rss-page-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.75rem;
    margin-bottom: 0.6rem;
    flex: none;
}

.rss-page-header h1 {
    margin: 0;
    font-size: 1.3rem;
}

/* The three-pane body — feed sidebar, then a vertical resize handle, then a
   main column (article list / horizontal resize handle / reading pane).
   Each pane's own width/height is driven by an inline style bound to a
   signal (`RssPage`'s `sidebar_width`/`article_list_height`), dragged via
   `RssResizeHandleV`/`RssResizeHandleH`. */
.rss-page-body {
    flex: 1;
    min-height: 0;
    display: flex;
    align-items: stretch;
    overflow: hidden;
}

.rss-feed-sidebar,
.rss-article-list-panel,
.rss-reading-pane-container {
    background: var(--panel);
    border: 1px solid var(--panel-border);
    border-radius: var(--radius);
}

.rss-feed-sidebar {
    flex: 0 0 auto;
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
    padding: 0.75rem;
    overflow-y: auto;
    min-height: 0;
}

/* Thin draggable dividers between panes — `col-resize`/`row-resize` cursors
   signal the drag direction, a subtle accent highlight on hover/active
   makes the strip discoverable without permanently taking up much space. */
.rss-resize-handle {
    flex: 0 0 auto;
    background: transparent;
    transition: background-color 0.15s ease;
}

.rss-resize-handle:hover,
.rss-resize-handle:active {
    background: var(--accent);
}

.rss-resize-handle-v {
    width: 6px;
    margin: 0 0.35rem;
    cursor: col-resize;
    border-radius: 3px;
}

.rss-resize-handle-h {
    height: 6px;
    margin: 0.35rem 0;
    cursor: row-resize;
    border-radius: 3px;
}

.rss-main-column {
    flex: 1 1 auto;
    display: flex;
    flex-direction: column;
    min-width: 0;
    min-height: 0;
    overflow: hidden;
}

.rss-article-list-panel {
    flex: 0 0 auto;
    min-width: 0;
    overflow-y: auto;
}

.rss-article-list-panel-inner {
    padding: 0.75rem 1rem;
}

/* Labels. A coloured dot in the nav (where the name carries the meaning and
   the colour only has to distinguish) and a filled chip on an article row
   (where it has to be findable while scanning). */
.rss-label-dot {
    width: 0.6rem;
    height: 0.6rem;
    border-radius: 50%;
    flex: 0 0 auto;
}

.rss-article-labels {
    display: flex;
    flex-wrap: wrap;
    gap: 0.25rem;
    margin: 0.15rem 0;
}

.rss-article-label {
    /* White on an arbitrary user-chosen colour is the one combination that
       stays legible across the whole hue range without computing contrast:
       the text-shadow carries it on the light end. */
    color: #fff;
    text-shadow: 0 0 2px rgba(0, 0, 0, 0.55);
    font-size: 0.7rem;
    line-height: 1.3;
    padding: 0.05rem 0.4rem;
    border-radius: 0.7rem;
    white-space: nowrap;
}

/* The label form is one line — name, colour, create — unlike the other "add"
   panels, which stack because their inputs are all full-width text. A shared
   class would have had to change for all five. */
.rss-config-label-form {
    display: flex;
    align-items: center;
    gap: 0.5rem;
}

.rss-config-label-form input[type="text"] {
    flex: 1 1 auto;
    min-width: 0;
}

.rss-config-label-form input[type="color"] {
    flex: 0 0 auto;
    width: 2.2rem;
    height: 1.8rem;
    padding: 0;
}

.rss-config-label-list {
    list-style: none;
    margin: 0.5rem 0 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 0.35rem;
}

.rss-config-label-row {
    display: flex;
    align-items: center;
    gap: 0.5rem;
}

.rss-config-label-row input[type="text"] {
    flex: 1 1 auto;
    min-width: 0;
}

.rss-config-label-row input[type="color"] {
    flex: 0 0 auto;
    width: 2.2rem;
    height: 1.8rem;
    padding: 0;
}

.rss-bulk-label-select {
    font-size: 0.85em;
}

/* The per-feed reasons an OPML import skipped something. A list rather than
   one line: the URL matters as much as the reason, and both need to be
   copy-pasteable. */
.rss-opml-failures {
    list-style: none;
    margin: 0.4rem 0 0;
    padding: 0;
    max-height: 12rem;
    overflow-y: auto;
    font-size: 0.85em;
}

.rss-opml-failures li {
    display: flex;
    flex-direction: column;
    gap: 0.1rem;
    padding: 0.25rem 0;
    border-top: 1px solid var(--border, rgba(128, 128, 128, 0.25));
}

.rss-opml-failures code {
    word-break: break-all;
}

/* The full-text toggle, above the body it swaps out. Inline with its own
   failure note so a page with nothing extractable explains itself where the
   button is, rather than in an error box somewhere else. */
.rss-reading-pane-actions {
    display: flex;
    align-items: center;
    gap: 0.75rem;
    flex-wrap: wrap;
    margin: 0.5rem 0 0.25rem;
}

/* "Réservé aux abonnés", next to the full-text toggle once an extraction has
   come back saying the page served only part of the article
   (`schema.org/isAccessibleForFree`). Shaped like `.pill` rather than reusing
   it: a badge whose whole job is to be noticed should not read as dim as the
   metadata around it. `--accent`, not `--danger` — a paywall is a fact about
   the article, not an error. */
.rss-paywall-badge {
    font-size: 0.75rem;
    padding: 0.1rem 0.5rem;
    border-radius: 999px;
    border: 1px solid var(--accent);
    color: var(--accent);
    white-space: nowrap;
}

/* Sits under the last row rather than floating: the pane scrolls, so a
   sticky button would cover an article at every scroll position. */
.rss-article-list-more {
    display: flex;
    justify-content: center;
    padding: 0.5rem 0 0.25rem;
}

.rss-reading-pane-container {
    flex: 1 1 auto;
    min-height: 0;
    overflow-y: auto;
    padding: 0.75rem 1.25rem;
}

/* Placed after the two base rules above, not with the viewport cap further
   up: a media query adds no specificity, so an earlier block simply loses to
   whatever the cascade reaches last.

   Now that the column is capped to the viewport, the article list's stored
   pixel height is no longer free — whatever it takes, the reading pane goes
   without. A height saved on a tall window and reopened on a short one
   squeezed that pane to 24px (measured at a 420px viewport), which is
   scrollable in the technical sense and useless in every other.

   So the list yields rather than holding its size, and `min-height` comes off
   `auto` because a flex item's automatic minimum is its content — for a list
   of a hundred articles that would never give back a pixel. Both panes keep
   the same 30px floor the drag handles already refuse to cross
   (`resize::MIN_PANE_PX`, per user request 2026-08-09), so the layout cannot
   settle anywhere you could not have dragged it to. */
@media (min-width: 901px) {
    .rss-article-list-panel {
        flex: 0 1 auto;
        min-height: 30px;
    }

    /* `flex-basis: 0`, and that is the load-bearing part.
       
       With the `auto` basis of the base rule, this pane's hypothetical size
       is its *content* height — 2121px for a tall Perry Bible Fellowship
       strip. The column cannot grant that, and the deficit is shared out
       across everything that may shrink, which since the rule above includes
       the article list. Its height then tracked whatever article happened to
       be open: asked for 360px, it measured 155px, 116px and 174px on three
       consecutive strips (2026-08-31 user report — "elle varie assez
       fortement d'un article à l'autre").
       
       At `0` the pane asks for nothing and simply takes what is left, so the
       list keeps the height it was dragged to and the only remaining deficit
       is the honest one: a stored height taller than the column itself, which
       is what the `min-height` floors below are for. */
    .rss-reading-pane-container {
        flex: 1 1 0;
        min-height: 30px;
    }
}

/* --- Compact nav tree (per user request 2026-08-09, round 2 — "retravaille
   visuellement dossier et flux pour rendre ça un peu plus compact et plus
   lisible") — small rows, tight gaps, no per-row edit/delete chrome (that
   all moved to `RssConfigModal`). */
.rss-feed-list {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 0.15rem;
}

.rss-special-sections {
    margin-bottom: 0.4rem;
    padding-bottom: 0.4rem;
    border-bottom: 1px solid var(--panel-border);
}

.rss-feed-list-item {
    display: flex;
    align-items: center;
    gap: 0.4rem;
    padding: 0.3rem 0.4rem;
    border-radius: 6px;
    font-size: 0.85rem;
    cursor: pointer;
    color: var(--text);
}

.rss-feed-list-item:hover {
    background: color-mix(in srgb, var(--accent) 8%, transparent);
}

.rss-feed-list-item.active {
    background: color-mix(in srgb, var(--accent) 16%, transparent);
    color: var(--accent);
}

/* Muted subscription (per user request 2026-08-09, round 4 — "avoir un
   bouton pour le désactiver/réactiver, il faudra un visuel
   correspondant") — dimmed, same treatment a read article's row gets. */
.rss-feed-list-item.disabled {
    opacity: 0.5;
}

/* Failing-feed indicator (per user request 2026-08-09, round 4 — "affiche
   clairement... les flux en échec depuis plus de 6h"). */
.rss-feed-failing-dot {
    flex: 0 0 auto;
    width: 7px;
    height: 7px;
    border-radius: 50%;
    background: var(--danger);
}

.rss-feed-name {
    flex: 1;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.rss-favicon {
    width: 16px;
    height: 16px;
    border-radius: 3px;
    flex: 0 0 auto;
}

.rss-folder-nav {
    display: flex;
    flex-direction: column;
    gap: 0.1rem;
}

.rss-folder-nav-node > summary {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.4rem;
    padding: 0.3rem 0.4rem;
    font-size: 0.8rem;
    font-weight: 600;
    color: var(--text-dim);
    cursor: pointer;
    list-style: revert;
    border-radius: 6px;
}

/* The rename input (round 6) or plain name span takes the remaining space
   so the unread-count pill (round 7) stays pinned to the right edge
   regardless of name length. */
.rss-folder-name,
.rss-folder-rename-input {
    flex: 1;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* Clicking the name views the folder's articles (round 7) — `cursor:
   pointer` is already inherited from the summary, this just gives the
   clickable text itself the same hover feedback as a feed row. */
.rss-folder-name:hover {
    text-decoration: underline;
}

.rss-folder-nav-node > summary:hover {
    background: color-mix(in srgb, var(--accent) 8%, transparent);
}

.rss-folder-nav-node > summary.active {
    background: color-mix(in srgb, var(--accent) 16%, transparent);
}

/* Nested folders indent themselves relative to their own parent — this
   selector matches at every depth, so arbitrarily deep nesting (per user
   request 2026-08-09, round 2 — "ajoute le support pour les dossiers
   nested") indents correctly without any per-depth CSS or inline style. */
.rss-folder-nav-node .rss-folder-nav-node,
.rss-folder-nav-node .rss-feed-list {
    margin-left: 0.7rem;
}

/* --- Article list --- */
.rss-article-list-toolbar {
    display: flex;
    justify-content: flex-end;
    margin-bottom: 0.75rem;
}

.rss-article-list {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 0.1rem;
}

/* ---- Card layout for video listings (per user request 2026-08-31) ----
   The default in "Vidéos" and in a YouTube feed; `.rss-layout-toggle` in the
   page header switches back to the list.

   Deliberately the *same markup* as the list, re-laid-out. A second row
   component would have meant two copies of the checkbox, the labels, the
   read/star toggles, the swipe-to-read gesture and the keyboard selection —
   and two places to fix anything that turned out wrong in one of them. What
   actually differs between a line and a card is where the parts sit, which
   is a stylesheet's job.

   `auto-fill` with a minimum rather than a fixed column count: the article
   pane is resizable (`RssResizeHandleH`) and sits beside a resizable sidebar,
   so the width available here is a user preference, not a breakpoint. */
.rss-article-list.cards {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
    gap: 0.75rem;
}

.rss-article-list.cards .rss-article-row {
    display: grid;
    /* One column, so the parts stack; the thumbnail spans it edge to edge. */
    grid-template-columns: 1fr;
    align-items: start;
    gap: 0.3rem;
    padding: 0;
    overflow: hidden;
    background: color-mix(in srgb, var(--panel-border) 22%, transparent);
}

.rss-article-list.cards .rss-article-thumb {
    width: 100%;
    border-radius: 6px 6px 0 0;
    /* The card's own rounding would otherwise show through the image's
       corners, which reads as a rendering fault rather than a style. */
    margin-bottom: 0.15rem;
}

/* The controls that make sense on a line — a checkbox column, the star and
   read buttons trailing it — become clutter on a card, where the thumbnail
   is what the eye is here for. They stay in the DOM (multi-select and the
   toggles still work from the keyboard and from the reading pane) but step
   out of the picture. */
.rss-article-list.cards .rss-article-row > input[type="checkbox"] {
    position: absolute;
    opacity: 0;
    pointer-events: none;
}

.rss-article-list.cards .rss-article-row {
    position: relative;
}

.rss-article-list.cards .rss-article-title,
.rss-article-list.cards .rss-article-meta,
.rss-article-list.cards .rss-article-labels {
    padding: 0 0.5rem;
}

/* A grid item's automatic minimum is its *content*, so one unbreakable line
   widens the card's single track past the cell the outer grid gave it and the
   card spills over its neighbour. The offender is the metadata — "ARTE
   Concert · 29/08/2026 13:00" measures 202px against a 164px card on a phone
   (measured 2026-08-31). Zeroing the minimum lets the track keep the cell's
   width and hands the overflow to the rules below, which know what to do
   with it. */
.rss-article-list.cards .rss-article-row > * {
    min-width: 0;
}

/* The date is what must survive: it is the shorter half and the one that
   orders the list. The feed name gives way first. */
.rss-article-list.cards .rss-article-meta {
    flex-wrap: wrap;
}

.rss-article-list.cards .rss-article-feed {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    min-width: 0;
}

.rss-article-list.cards .rss-article-title {
    /* Two lines, then an ellipsis: cards in a row have to share a baseline,
       and a five-line title would push its neighbours' metadata out of
       alignment. */
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
    white-space: normal;
}

.rss-article-list.cards .rss-article-meta {
    padding-bottom: 0.5rem;
}

/* The star and the read dot ride on the thumbnail rather than taking a row
   of their own, appearing on hover (and always, on a touch screen — see the
   coarse-pointer rule further down). */
.rss-article-list.cards .rss-article-row > .icon-button {
    position: absolute;
    top: 0.3rem;
    opacity: 0;
    background: color-mix(in srgb, var(--bg) 70%, transparent);
    border-radius: 4px;
    transition: opacity 0.15s ease;
}

.rss-article-list.cards .rss-article-row > .icon-button:nth-last-of-type(2) {
    right: 2.1rem;
}

.rss-article-list.cards .rss-article-row > .icon-button:last-of-type {
    right: 0.3rem;
}

.rss-article-list.cards .rss-article-row:hover > .icon-button,
.rss-article-list.cards .rss-article-row:focus-within > .icon-button {
    opacity: 1;
}

/* A pointer that cannot hover never reveals them, so on a touch screen they
   are simply always there. */
@media (hover: none) {
    .rss-article-list.cards .rss-article-row > .icon-button {
        opacity: 1;
    }
}

/* Already seen, so the eye can skip it while scanning (per user request
   2026-08-31). Desaturated and dimmed rather than hidden: the video is still
   there to be found again, it just stops competing for attention.

   Driven by the row's own `read` state, which for a video *is* "watched":
   reaching the end is what marks it read (`services::rss::update_progress`).
   That keeps the thumbnail and the title beside it saying the same thing —
   they did not, when this was keyed on the playhead instead.

   The badge and the progress bar are lifted back out of the filter: a
   greyed-out "Terminée" bar would defeat the one element that says *how far*
   the video was actually watched. */
.rss-article-thumb.watched img {
    filter: grayscale(1);
    opacity: 0.45;
}

.rss-article-thumb.watched .rss-article-duration,
.rss-article-thumb.watched .rss-article-progress {
    filter: none;
    opacity: 1;
}

/* Slightly tighter than the original 0.4rem/0.3rem row padding+gap (per
   user request 2026-08-09, round 7 — "quelque chose de légèrement plus
   compact verticalement"). */
.rss-article-row {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    padding: 0.25rem 0.5rem;
    border-radius: 6px;
    cursor: pointer;
    /* How much company the selection keeps when the arrow keys scroll it
       into view (per user request 2026-09-01 — the next article should be on
       screen too, not flush against the edge).

       Expressed here rather than in the `scrollIntoView` call because that is
       what `scroll-margin` is for: `block: "nearest"` scrolls only as far as
       it must, and this widens "must" by one row at each end. A row measures
       about 36px, so 2.75rem clears a whole one — the next article going
       down, the previous one coming back up, without the code having to know
       either number. */
    scroll-margin-block: 2.75rem;
}

.rss-article-row:hover {
    background: color-mix(in srgb, var(--accent) 8%, transparent);
}

.rss-article-row.active {
    background: color-mix(in srgb, var(--accent) 16%, transparent);
}

/* Thumbnail + duration badge shown on rows whose feed is a YouTube channel
   (per user request 2026-08-18). Sized to the existing row height rather
   than turning the list into a card grid — the article list is a narrow
   resizable pane, and a grid would fight that layout. */
.rss-article-thumb {
    position: relative;
    flex: 0 0 auto;
    width: 4rem;
    aspect-ratio: 16 / 9;
    border-radius: 4px;
    overflow: hidden;
    background: color-mix(in srgb, var(--text-dim) 15%, transparent);
}

.rss-article-thumb img {
    width: 100%;
    height: 100%;
    object-fit: cover;
    display: block;
}

.rss-article-duration {
    position: absolute;
    right: 2px;
    bottom: 2px;
    padding: 0 0.2rem;
    border-radius: 3px;
    background: rgba(0, 0, 0, 0.75);
    color: #fff;
    font-size: 0.6rem;
    line-height: 1.4;
}

/* Watched-progress bar along the bottom edge of the thumbnail — the
   YouTube-style convention, and the only one that fits a 4rem-wide cell.
   Absent entirely for a video never started, so "not begun" reads as an
   unmarked thumbnail rather than an empty track. */
.rss-article-progress {
    position: absolute;
    left: 0;
    bottom: 0;
    height: 3px;
    background: #c0392b;
    border-radius: 0 2px 0 0;
}

.rss-article-progress.completed {
    background: var(--accent);
    opacity: 0.85;
}

.rss-article-duration.live {
    background: #c0392b;
    font-weight: 600;
}

/* Stands in for the player on an announced-but-not-yet-broadcast video —
   YouTube has nothing playable to embed for one. */
.rss-video-upcoming {
    margin: 0.75rem 0;
    padding: 0.6rem 0.8rem;
    border-radius: 8px;
    background: color-mix(in srgb, var(--accent) 12%, transparent);
    color: var(--text-dim);
    font-size: 0.9rem;
}

.rss-article-title {
    flex: 1;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.rss-article-meta {
    flex: 0 0 auto;
    color: var(--text-dim);
    font-size: 0.8rem;
    white-space: nowrap;
}

/* A read article's row dims slightly (same "already seen" affordance as
   `.movie-poster-button.watched img`) — the unread/read toggle button
   itself always stays fully legible, only the title/meta text dims. */
.rss-article-row.read .rss-article-title,
.rss-article-row.read .rss-article-meta {
    color: var(--text-dim);
}

.rss-article-row input[type="checkbox"] {
    flex: 0 0 auto;
}

/* Starred toggle's "on" state — same accent-fill treatment as
   `.fullscreen-panel button.ghost.active`, just on an `.icon-button`
   circle instead of a `.ghost` button. */
.fullscreen-panel .icon-button.active {
    color: var(--accent);
    border-color: var(--accent);
}

/* Bulk-selection action bar (per user request 2026-08-09 — "permet de
   sélectionner plusieurs articles"), only mounted while at least one
   article's checkbox is checked. */
/* Search + "Configurer", kept together on the header's right-hand side so
   the surrounding `space-between` still has three children to spread. */
.rss-header-actions {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    min-width: 0;
}

/* "Configurer" breaks onto a second line the moment the row gets tight,
   which drags the whole header taller. It keeps its width; the search box
   beside it is the one with room to give (`min-width: 0` below). */
.rss-header-actions > button {
    white-space: nowrap;
}

/* Deliberately small and quiet at rest: this sits in the page header, next
   to a button, and a full-width field there would read as the page's main
   subject rather than an accessory. It widens only while actually in use,
   which is the one moment the reader needs to see what they typed. */
.rss-header-search {
    width: 9rem;
    max-width: 100%;
    font-size: 0.85rem;
    padding: 0.3rem 0.5rem;
    transition: width 0.15s ease, border-color 0.15s ease;
}

/* Stated rather than left to each control's own metrics. At identical
   font-size and padding a `<select>` still lays out 2px taller than an
   `<input>` (measured: 21.4px of content against 19.4px) — Chrome gives the
   native control its own minimum — which reads as one box sitting low next
   to the other. `box-sizing: border-box` is global here, so this is the
   whole box. */
.rss-header-search,
.rss-page-header .rss-header-sort {
    height: 2rem;
}

.rss-header-search:focus,
.rss-header-search:not(:placeholder-shown) {
    width: 14rem;
}

/* States its colours in full: `input[type="search"]` matches none of the
   existing form rules, and there is no generic `input` rule to inherit from
   — the gap the comment above `.modal-panel select` documents, which
   otherwise renders it in the browser's white against this dark page. The
   select beside it has the opposite problem, see below. */
.rss-header-search {
    font-family: inherit;
    color: var(--text);
    background: var(--bg);
    border: 1px solid var(--panel-border);
    border-radius: 6px;
}

.rss-header-search:focus {
    outline: none;
    border-color: var(--accent);
}

/* Scoped through `.rss-page-header` (0,2,0) rather than the bare class
   (0,1,0). `RssPage`'s root carries `.fullscreen-panel`, so
   `.fullscreen-panel select` (0,1,1) already applies here and its
   `font: inherit` + `padding: 0.5rem 0.6rem` beat a single class outright.
   Measured before this scoping: 16px text in a 42px-tall box, sitting
   between a 31px search field and a 31px "Configurer". Same footgun
   documented on `.lightbox-frame .lightbox-nav` and `.widget .icon-button`. */
.rss-page-header .rss-header-sort {
    font-family: inherit;
    font-size: 0.85rem;
    color: var(--text);
    background: var(--bg);
    border: 1px solid var(--panel-border);
    border-radius: 6px;
    padding: 0.3rem 0.5rem;
    width: auto;
    cursor: pointer;
    /* Stated because this control otherwise resolves to `transition: all`,
       so it animates properties the search box beside it does not. */
    transition: border-color 0.15s ease;
}

/* `.fullscreen-panel select:focus` (0,2,1) adds a glow ring that the search
   box beside it never gets, so out-specifying the base rule is not enough —
   this has to reach (0,3,0) and put the ring back to the plain
   border-colour change its neighbour uses. */
.rss-page-header .rss-header-sort:focus {
    outline: none;
    border-color: var(--accent);
    box-shadow: none;
}

/* WebKit draws its own clear button inside `type="search"`, in a colour
   picked for a light form field — invisible against this panel. */
.rss-header-search::-webkit-search-cancel-button {
    filter: invert(1) grayscale(1);
    opacity: 0.6;
    cursor: pointer;
}

.rss-bulk-bar {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    flex-wrap: wrap;
    margin-bottom: 0.75rem;
    padding: 0.5rem 0.75rem;
    background: color-mix(in srgb, var(--accent) 10%, var(--panel) 90%);
    border: 1px solid var(--panel-border);
    border-radius: 8px;
}

/* The email-client-style reading pane — its own independently scrollable
   pane (`.rss-reading-pane-container`) rather than stacked below the
   article list, per user request 2026-08-09 round 2. */
/* Title + "Envoyer vers Lectures" button on the same row (per user request
   2026-08-11 — the button shouldn't sit on its own separate line). */
.rss-reading-pane-header {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    gap: 0.5rem;
}

.rss-reading-pane-header h2 {
    margin: 0 0 0.3rem;
    font-size: 1.1rem;
}

.rss-reading-pane-header .icon-button {
    flex-shrink: 0;
}

/* Groups the one-click bookmark button with its adjacent collection-picker
   dropdown so `.rss-reading-pane-header`'s `space-between` doesn't spread
   them apart from each other (per user request 2026-08-11 — splitting the
   single send button into two adjacent affordances). */
.rss-reading-pane-send-actions {
    display: flex;
    align-items: flex-start;
    gap: 0.25rem;
    flex-shrink: 0;
}

/* Same `<details>` disclosure shape as `.add-to-playlist-menu` — see that
   block's own comment for why a native disclosure was chosen over a
   hand-rolled popover. */
.rss-send-to-collection-menu {
    position: relative;
    flex: 0 0 auto;
}

.rss-send-to-collection-menu > summary {
    list-style: none;
    cursor: pointer;
}

.rss-send-to-collection-menu > summary::-webkit-details-marker {
    display: none;
}

.rss-send-to-collection-menu-list {
    position: absolute;
    top: calc(100% + 0.3rem);
    right: 0;
    z-index: 5;
    min-width: 180px;
    max-height: 220px;
    overflow-y: auto;
    padding: 0.4rem;
    display: flex;
    flex-direction: column;
    gap: 0.2rem;
    background: var(--panel);
    border: 1px solid var(--panel-border);
    border-radius: 8px;
    box-shadow: 0 8px 24px rgb(0 0 0 / 35%);
}

/* The title doubles as the link to the original article now (per user
   request 2026-08-09) — plain text colour by default, only picking up the
   accent/underline on hover, so it doesn't read as a generic blue link. */
/* Accent-colored by default (not just on hover) — per user follow-up
   2026-08-09, the earlier `color: inherit` treatment made the title link
   too easy to mistake for plain text. */
.rss-reading-pane h2 a {
    color: var(--accent);
    text-decoration: none;
}

.rss-reading-pane h2 a:hover {
    text-decoration: underline;
}

.rss-reading-pane-content {
    margin: 0.75rem 0;
    font-size: 0.9rem;
    line-height: 1.5;
}

/* The feed's own lead image, per user request 2026-08-09 — capped height
   so a huge original doesn't push the whole article out of view. */
.rss-reading-pane-image {
    display: block;
    max-width: 100%;
    max-height: 320px;
    width: auto;
    border-radius: var(--radius);
    margin: 0.75rem 0;
}

/* A webcomic strip *is* the article (`services::rss_comics`, per user request
   2026-08-29), so the lead-image height cap above — which exists to stop a
   news feed's banner from pushing the text off the screen — would be the one
   thing standing between the reader and what they opened. A tall strip like
   the Perry Bible Fellowship's (1000x1301) comes out 246px wide under it,
   which is to say unreadable. Uncapped instead — `max-width` still holds it
   to the column, and `width: auto` above keeps a narrow strip at its own
   size rather than upscaling it into a blur. Left square, too: a comic is a
   drawing with its own edges, not a card. */
.rss-reading-pane-image.comic {
    max-height: none;
    border-radius: 0;
}

/* --- Configuration modal (per user request 2026-08-09, round 2 — "déplace
   l'ajout de flux et de catégories vers une modale de configuration
   dédiée") — add feed/folder, OPML import, and the full management tree
   with drag-and-drop reorder/move. */
.rss-config-modal {
    display: flex;
    flex-direction: column;
    gap: 1rem;
}

/* Wider than a normal `.modal-panel.wide` (per user request 2026-08-09,
   round 3 — "étends un peu la modale de config en largeur") — the
   management tree's rows (drag handle + name input + parent select +
   delete) are cramped at the shared 760px cap other wide modals use.
   `:has()` scopes this to just the RSS config modal without needing a new
   prop on the shared `Modal` component for one caller. */
.modal-panel.wide:has(.rss-config-modal) {
    max-width: 960px;
}


/* Toggles the add-feed/add-folder/import-export panels (per user request
   2026-08-10 — those three used to sit stacked and always expanded above
   the tree, pushing "Organisation" further down every time the modal
   opened; now they're collapsed behind this toolbar, one at a time). */
.rss-config-toolbar {
    display: flex;
    flex-wrap: wrap;
    gap: 0.5rem;
}

.rss-config-section h3 {
    margin: 0 0 0.5rem;
    font-size: 0.95rem;
}

/* Import and export sit side by side (per user request 2026-08-10 —
   "à côté de l'import OPML, ajoute un bouton d'export OPML") rather than
   stacked full-width like the rest of this modal's buttons. */
.rss-opml-buttons {
    display: flex;
    flex-wrap: wrap;
    gap: 0.5rem;
}

.rss-config-tree-section {
    border-top: 1px solid var(--panel-border);
    padding-top: 0.75rem;
}

.rss-config-folder-list,
.rss-config-feed-list {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 0.2rem;
}

.rss-config-folder-row {
    margin: 0.1rem 0;
}

.rss-config-folder-list .rss-config-folder-list,
.rss-config-folder-row .rss-config-feed-list {
    margin-left: 1.1rem;
    margin-top: 0.2rem;
}

.rss-config-row {
    display: flex;
    align-items: center;
    gap: 0.4rem;
    padding: 0.3rem 0.4rem;
    border: 1px solid transparent;
    border-radius: 6px;
    background: color-mix(in srgb, var(--panel-border) 25%, transparent);
}

/* The row being carried. Dimmed *and* outlined: opacity alone reads as
   "disabled" on a tree where rows can genuinely be disabled (the pause
   button), which is exactly the ambiguity to avoid while dragging. */
.rss-config-row.dragging {
    opacity: 0.45;
    outline: 1px dashed var(--accent);
    outline-offset: -1px;
}

/* Where it would land. Two answers, because there are two drops (per user
   request 2026-08-31 — "pas très compréhensible visuellement"):

   `drop-before` is a line drawn on the leading edge, saying the carried row
   slots in *here*, pushing this one down. `drop-into` fills the row instead,
   saying it goes *inside* — which only a folder can offer, and only to a
   feed. A line and a fill rather than two colours: the distinction survives
   being glanced at, and does not depend on telling two accents apart. */
.rss-config-row.drop-before {
    box-shadow: inset 0 3px 0 0 var(--accent);
}

.rss-config-row.drop-into {
    background: color-mix(in srgb, var(--accent) 25%, transparent);
    outline: 1px solid var(--accent);
    outline-offset: -1px;
}

/* The receiving group, lit while a feed is anywhere over it. This is what
   makes an *empty* folder legible as a target: its only content is the
   "Glissez un flux ici" hint, and a hint that never reacts reads as decoration
   rather than as an invitation. */
.rss-config-feed-list.drop-active {
    outline: 1px dashed var(--accent);
    outline-offset: 2px;
    border-radius: 6px;
}

.rss-config-feed-list.drop-active .rss-config-empty-hint {
    color: var(--accent);
    font-style: normal;
}

.rss-config-drag-handle {
    display: flex;
    align-items: center;
    color: var(--text-dim);
    cursor: grab;
    flex: 0 0 auto;
    /* The grip is a 16px icon, which is a small thing to aim at with a mouse
       and the reason the gesture missed as often as it caught. The padding is
       negative-margined back out so the row's own spacing is unchanged. */
    padding: 0.35rem 0.3rem;
    margin: -0.35rem -0.3rem;
    touch-action: none;
}

.rss-config-drag-handle:hover {
    color: var(--text);
}

.rss-config-drag-handle:active {
    cursor: grabbing;
}

.rss-config-name-input {
    flex: 1;
    min-width: 0;
}

.rss-config-parent-select {
    flex: 0 0 auto;
    max-width: 11rem;
}

.rss-config-confirm {
    font-weight: 700;
}

.rss-config-empty-hint {
    padding: 0.3rem 0.4rem;
    font-size: 0.8rem;
    color: var(--text-dim);
    font-style: italic;
    list-style: none;
}

/* Per-feed expanded edit panel (URL, rétention, dernière mise à jour/
   erreur) — per user request 2026-08-09, round 4. */
.rss-config-feed-details {
    display: flex;
    flex-direction: column;
    gap: 0.35rem;
    margin: 0.1rem 0 0.3rem 1.6rem;
    padding: 0.5rem 0.6rem;
    background: color-mix(in srgb, var(--panel-border) 15%, transparent);
    border-radius: 6px;
    list-style: none;
}

.rss-config-detail-label {
    font-size: 0.75rem;
    color: var(--text-dim);
    margin-top: 0.2rem;
}

/* Results of the "ajouter une chaîne YouTube" search — avatar, name and
   subscriber line, then a subscribe button pinned right. */
.rss-channel-results {
    display: flex;
    flex-direction: column;
    gap: 0.4rem;
    margin-top: 0.6rem;
    padding: 0;
    list-style: none;
}

.rss-channel-result {
    display: flex;
    align-items: center;
    gap: 0.6rem;
    padding: 0.4rem;
    border-radius: 6px;
    background: color-mix(in srgb, var(--text-dim) 8%, transparent);
}

.rss-channel-avatar {
    flex: 0 0 auto;
    width: 2.5rem;
    height: 2.5rem;
    border-radius: 50%;
    object-fit: cover;
}

.rss-channel-text {
    display: flex;
    flex-direction: column;
    gap: 0.1rem;
    flex: 1;
    min-width: 0;
}

.rss-channel-title {
    font-weight: 600;
}

/* Two lines at most — search snippets are long and would otherwise push the
   subscribe buttons out of alignment down the list. */
.rss-channel-description {
    font-size: 0.75rem;
    color: var(--text-dim);
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
}

.rss-channel-result button {
    flex: 0 0 auto;
}

/* Inline checkbox row inside `.rss-config-feed-details` — currently only
   the YouTube "Masquer les Shorts" toggle, which is why it sits with the
   other detail labels rather than in its own block. */
.rss-config-detail-check {
    display: flex;
    align-items: center;
    gap: 0.4rem;
    font-size: 0.75rem;
    color: var(--text-dim);
    margin-top: 0.3rem;
}

.rss-config-detail-check input {
    margin: 0;
}

/* Per-feed "Filtres" subsection inside `.rss-config-feed-details` (per user
   request 2026-08-11 — per-feed whitelist/blacklist regex filtering). */
.rss-config-filters {
    display: flex;
    flex-direction: column;
    gap: 0.3rem;
    margin-top: 0.4rem;
}

.rss-config-filters-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
}

.rss-config-filter-list {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 0.25rem;
}

.rss-filter-conditions-editor {
    display: flex;
    flex-direction: column;
    gap: 0.35rem;
}

.rss-filter-condition-row {
    display: flex;
    align-items: center;
    gap: 0.4rem;
}

.rss-filter-condition-row input[type='text'] {
    flex: 1;
    min-width: 0;
}

.rss-filter-condition-negate {
    display: flex;
    align-items: center;
    gap: 0.2rem;
    font-size: 0.8rem;
    color: var(--text-dim);
    white-space: nowrap;
}

.rss-filter-enabled-toggle {
    display: flex;
    align-items: center;
    gap: 0.4rem;
    font-size: 0.85rem;
}

/* Discovered-feed picker shown when "Ajouter un flux" was given a plain
   website URL instead of a feed URL (per user request 2026-08-09, round
   4). */
.rss-discovered-feeds {
    margin-top: 0.5rem;
}

.rss-discovered-feeds-list {
    list-style: none;
    margin: 0.3rem 0 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 0.3rem;
}

/* Right-click context menu on a feed/folder in the main sidebar tree (per
   user request 2026-08-09, round 6) — a full-viewport invisible backdrop
   (dismisses on outside click, same pattern as `.modal-backdrop`) behind a
   small positioned panel. `z-index: 400` — above `.modal-panel`'s own 300,
   in case the menu is ever open at the same time as a modal (it shouldn't
   be in practice, since the modal's own backdrop already blocks clicks
   into the sidebar tree behind it, but this keeps the stacking order
   correct if that ever changes). */
.rss-context-menu-backdrop {
    position: fixed;
    inset: 0;
    z-index: 400;
}

.rss-context-menu {
    position: fixed;
    display: flex;
    flex-direction: column;
    min-width: 220px;
    background: var(--panel);
    border: 1px solid var(--panel-border);
    border-radius: 8px;
    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
    padding: 0.3rem;
}

.rss-context-menu button {
    text-align: left;
    background: none;
    border: none;
    padding: 0.5rem 0.6rem;
    border-radius: 6px;
    color: var(--text);
    cursor: pointer;
    font-size: 0.85rem;
}

.rss-context-menu button:hover {
    background: color-mix(in srgb, var(--accent) 12%, transparent);
}

/* Inline folder rename (per user request 2026-08-09, round 6) — replaces
   the folder name text in place inside `<summary>`. */
.rss-folder-rename-input {
    width: calc(100% - 0.5rem);
    font: inherit;
    font-weight: 600;
    color: var(--text);
    background: var(--panel);
    border: 1px solid var(--accent);
    border-radius: 4px;
    padding: 0.1rem 0.3rem;
}

/* --- "Lire plus tard" (read_later::ReadLaterPage), per user request
   2026-08-10 — a Wallabag-inspired read-it-later sub-app, positioned as a
   sidebar widget just above RSS. Deliberately NOT RSS's 3-pane resizable
   layout (confirmed with the user) — a full-screen card grid + a spacious
   centered reading view instead, since the whole point is calm reading of
   full article text rather than short feed summaries. Reuses
   `.fullscreen-panel`'s own button/form/input styling the same way
   `.rss-page` does. */
.read-later-page {
    width: 100%;
    /* The shell above owns the viewport; this takes what is left.
       `min-height: 0` is what lets it be *smaller* than its content,
       which is the precondition for its own body scrolling instead
       of the document. */
    flex: 1 1 auto;
    min-height: 0;
    max-width: none;
    margin: 0;
    padding: 0.6rem;
    background: none;
    border: none;
    border-radius: 0;
    box-sizing: border-box;
    overflow: hidden;
    display: flex;
    flex-direction: column;
}

.read-later-page-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.75rem;
    margin-bottom: 0.6rem;
    flex: none;
}

.read-later-page-header h1 {
    margin: 0;
    font-size: 1.3rem;
}

.read-later-page-body {
    flex: 1;
    min-height: 0;
    display: flex;
    align-items: stretch;
    gap: 0.75rem;
    overflow: hidden;
}

.read-later-sidebar {
    flex: 0 0 240px;
    display: flex;
    flex-direction: column;
    gap: 0.05rem;
    padding: 0.5rem 0.45rem;
    overflow-y: auto;
    background: var(--panel);
    border: 1px solid var(--panel-border);
    border-radius: var(--radius);
}

/* `.read-later-sidebar button.ghost.read-later-nav-item` (not just
   `.read-later-nav-item`) — needed to out-specificity `.fullscreen-panel
   button`'s own default (solid accent-filled) button styling, same
   specificity trap already documented for `.widget button.ghost`/
   `.modal-panel button.ghost`/`.fullscreen-panel button.ghost` elsewhere in
   this file. Without the extra qualifiers every *non*-active nav item
   rendered as a solid blue button instead of a flat nav-list row. */
.read-later-sidebar button.ghost.read-later-nav-item {
    display: flex;
    align-items: center;
    gap: 0.4rem;
    width: 100%;
    min-width: 0;
    text-align: left;
    border: none;
    border-radius: 7px;
    padding: 0.32rem 0.45rem;
    font: inherit;
    color: var(--text);
}

/* The leading icon (inbox, Recettes' fork and knife) is a marker, not the
   label — dimmed so the names line up as the thing being read. */
.read-later-sidebar button.ghost.read-later-nav-item > svg {
    flex: none;
    opacity: 0.55;
}

.read-later-nav-label {
    flex: 1;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* How many articles the row would show — the same rule the view applies
   (`super::article_counts`), so a collection that doesn't recurse counts
   only its own. */
.read-later-nav-count {
    flex: none;
    font-size: 0.7rem;
    color: var(--text-dim);
    font-variant-numeric: tabular-nums;
}

.read-later-sidebar button.ghost.read-later-nav-item:hover {
    background: color-mix(in srgb, var(--accent) 10%, transparent);
}

.read-later-sidebar button.ghost.read-later-nav-item.active {
    background: color-mix(in srgb, var(--accent) 18%, transparent);
    color: var(--accent);
    font-weight: 600;
}

.read-later-sidebar button.ghost.read-later-nav-item.active > svg,
.read-later-sidebar button.ghost.read-later-nav-item.active .read-later-nav-count {
    color: inherit;
    opacity: 0.8;
}

.read-later-nav-item-row {
    display: flex;
    align-items: center;
    gap: 0.1rem;
}

.read-later-nav-item-row .read-later-nav-item {
    flex: 1;
    min-width: 0;
}

/* The row an article — or another collection — is about to be dropped into
   (user request 2026-08-27). Stated on the row rather than on the button
   inside it, so the twisty and the count light up with the name: the whole
   row is the target. The heading takes the same treatment, it being the only
   way to say "out of every collection, back to the root". */
/* The row in flight, faded like the card and the rows every other
   drag-and-drop surface here fades. */
.read-later-nav-item-row.dragging {
    opacity: 0.4;
}

.read-later-sidebar-heading.drop-target,
.read-later-nav-item-row.drop-target {
    background: color-mix(in srgb, var(--accent) 22%, transparent);
    border-radius: 7px;
    outline: 1px dashed var(--accent);
}

/* Folds a branch (per user request 2026-08-22) — same square footprint
   whether or not a row has one, so every label starts on the same column. */
.read-later-sidebar button.ghost.read-later-nav-twisty {
    flex: none;
    display: flex;
    align-items: center;
    justify-content: center;
    width: 1.15rem;
    height: 1.15rem;
    min-width: 0;
    padding: 0;
    border: none;
    border-radius: 4px;
    font-size: 0.75rem;
    color: var(--text-dim);
}

.read-later-sidebar button.ghost.read-later-nav-twisty:hover {
    color: var(--text);
    background: color-mix(in srgb, var(--accent) 12%, transparent);
}

.read-later-nav-twisty-spacer {
    flex: none;
    width: 1.15rem;
}

.read-later-nav-tree {
    display: flex;
    flex-direction: column;
    gap: 0.05rem;
}

/* Indents nested collections under their parent (per user request
   2026-08-11, "Permet de créer des sous-collections") — mirrors the same
   visual nesting `.rss-folder-nav-node` gets via `<details>` indentation,
   just done with an explicit wrapper since collections use a plain
   recursive `<div>` tree instead of `<details>`. */
.read-later-nav-children {
    display: flex;
    flex-direction: column;
    gap: 0.05rem;
    margin-left: 1.1rem;
    padding-left: 0.15rem;
    border-left: 1px solid color-mix(in srgb, var(--panel-border) 65%, transparent);
}

.read-later-nav-children.folded {
    display: none;
}

.read-later-sidebar-heading {
    display: flex;
    align-items: center;
    justify-content: space-between;
    margin: 0.9rem 0 0.25rem;
    padding: 0 0.45rem;
    font-size: 0.68rem;
    font-weight: 600;
    color: var(--text-dim);
    text-transform: uppercase;
    letter-spacing: 0.06em;
}

.read-later-sidebar-heading .icon-button {
    width: 20px;
    height: 20px;
    min-width: 20px;
    font-size: 0.85rem;
}

/* Bookmarks in and out, at the foot of the collections (per user request
   2026-08-21) — set apart from the tree above it, and pushed to the bottom
   of the panel so it reads as a footer rather than as one more collection.
   `margin-top: auto` needs the sidebar's own `flex-direction: column`. */
/* The gear and "+ Ajouter" in the page header, wrapped so the header's
   `space-between` still sees three children and the title stays centred —
   the same arrangement `.rss-header-actions` uses for the same reason. */
.read-later-header-actions {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    min-width: 0;
}

.read-later-header-actions > button {
    white-space: nowrap;
}

/* Bookmarks import/export. Lived at the foot of the collections tree until
   2026-09-01, squeezed into a 240px column, which is why its controls were
   set at 0.75rem and stacked; inside a dialog it has a dialog's room and
   uses the ordinary sizes. */
.read-later-bookmarks {
    display: flex;
    flex-direction: column;
    gap: 0.6rem;
}

.read-later-bookmarks select {
    width: 100%;
}

.read-later-bookmarks-actions {
    display: flex;
    gap: 0.5rem;
}

.read-later-bookmarks-actions button {
    flex: 1;
}

.read-later-bookmarks .weather-meta {
    margin: 0;
}

.read-later-add-collection-form {
    padding: 0 0.45rem 0.5rem;
    gap: 0.35rem;
}

.read-later-main {
    flex: 1;
    min-width: 0;
    overflow-y: auto;
    padding: 0.25rem;
}

.read-later-empty {
    padding: 2rem;
    text-align: center;
}

/* Shown above the article grid only while viewing a specific (non-reserved)
   collection — the "Modifier" button opens `ReadLaterCollectionEditModal`
   (rename + delete), replacing the old per-nav-item delete button (per user
   request 2026-08-11). */
.read-later-collection-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 0.4rem 0.5rem;
    margin-bottom: 0.5rem;
}

.read-later-collection-header h2 {
    margin: 0;
    font-size: 1.1rem;
}

.read-later-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
    gap: 0.75rem;
}

.read-later-card {
    display: flex;
    flex-direction: column;
    background: var(--panel);
    border: 1px solid var(--panel-border);
    border-radius: var(--radius);
    overflow: hidden;
}

.read-later-card.read {
    opacity: 0.6;
}

/* Same faded "this is the one in flight" language the other drag-and-drop
   surfaces use (`.rss-config-row.dragging`, `.podcast-episode-row.dragging`).
   No `grab` cursor to go with it: the card's body is a click target of its
   own (it opens the article), so the pointer it already shows is the truer
   of the two. */
.read-later-card.dragging {
    opacity: 0.4;
}

.read-later-card-body {
    display: flex;
    flex-direction: column;
    cursor: pointer;
    flex: 1;
}

.read-later-card-image {
    width: 100%;
    height: 140px;
    object-fit: cover;
    display: block;
}

.read-later-card-text {
    padding: 0.65rem 0.75rem;
    display: flex;
    flex-direction: column;
    gap: 0.3rem;
}

.read-later-card-text h3 {
    margin: 0;
    font-size: 1rem;
    line-height: 1.3;
}

.read-later-card-excerpt {
    margin: 0;
    font-size: 0.85rem;
    color: var(--text-dim);
    display: -webkit-box;
    -webkit-line-clamp: 3;
    -webkit-box-orient: vertical;
    overflow: hidden;
}

/* Tags. Flat outlined chips, not the filled pills `.rss-article-label` uses:
   those carry a user-chosen colour and have to stay legible on any hue, while
   a tag here has no colour at all (see migration 0107) and only has to read as
   "a word that is not part of the article". The "#" is drawn in the markup
   rather than as a `::before` so it is copied with the name. */
.read-later-card-tags {
    display: flex;
    flex-wrap: wrap;
    gap: 0.25rem;
    margin: 0.1rem 0 0;
}

.read-later-tag {
    font-size: 0.7rem;
    line-height: 1.3;
    padding: 0.05rem 0.4rem;
    border-radius: 0.7rem;
    border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
    background: color-mix(in srgb, var(--accent) 8%, transparent);
    color: var(--accent);
    white-space: nowrap;
}

/* The heading above the article list of a tag view — the "#" again, and the
   same weight as the collection name it stands in for. */
.read-later-tag-heading {
    color: var(--accent);
}

/* Said once, under an empty tag list. Indented to the depth of the rows it
   replaces so it reads as part of the section rather than as page text. */
.read-later-tags-empty {
    margin: 0.15rem 0.5rem 0;
    font-size: 0.75rem;
}

.read-later-card-actions {
    display: flex;
    align-items: center;
    gap: 0.35rem;
    padding: 0.5rem 0.75rem;
    border-top: 1px solid var(--panel-border);
}

.read-later-card-actions select {
    flex: 1;
    min-width: 0;
}

.read-later-reading {
    flex: 1;
    min-width: 0;
    overflow-y: auto;
    display: flex;
    flex-direction: column;
    align-items: center;
    padding: 1rem;
}

/* The share-target landing (`/lectures/partage`). Takes the whole body like
   the reader does, and for the same reason: it is one decision, arrived at
   from outside the app, and a collections tree beside it would be noise
   around it. Narrow and centred — everything on it is one column wide, and on
   a phone (which is where sharing actually happens) it is the whole screen. */
.read-later-share {
    flex: 1;
    min-width: 0;
    overflow-y: auto;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 0.75rem;
    padding: 1.5rem 1rem;
}

.read-later-share > * {
    width: 100%;
    max-width: 32rem;
}

.read-later-share-status h2 {
    margin: 0.25rem 0 0.15rem;
    font-size: 1.15rem;
    line-height: 1.3;
}

.read-later-share-status p {
    margin: 0;
}

/* The one line that says the link is safe — which is the whole promise of
   this screen, and has to be legible before anything else on it. */
.read-later-share-saved {
    color: var(--accent);
    font-weight: 600;
}

/* A percentage, not a fixed px cap (per user request 2026-08-10 — first
   asked to widen the reading column since its 720px margins were "beaucoup
   trop grandes", then that a fixed px width was the wrong lever entirely:
   "je ne pense pas que la max-width devrait être fixe, mais plutot exprimée
   en pourcentage" — a flat pixel value stays cramped on a narrow window and
   under-uses a wide one the same amount regardless of viewport, where a
   percentage scales with whatever space `.read-later-reading` actually
   has). `justify-content: flex-end` pushes "Modifier" (and the optional
   view-toggle before it) against the right edge. */
.read-later-reading-toolbar {
    width: 100%;
    max-width: 85%;
    display: flex;
    align-items: center;
    justify-content: flex-end;
    gap: 0.75rem;
    margin-bottom: 1rem;
}

/* "Vue recette"/"Vue normale" toggle (per user request 2026-08-11) — only
   rendered when a recipe article also has plain extracted content to
   switch to. */
.read-later-view-toggle {
    display: flex;
    gap: 0.35rem;
    margin-right: auto;
}

.read-later-view-toggle button.active {
    background: color-mix(in srgb, var(--accent) 20%, transparent);
    color: var(--accent);
    border-color: transparent;
}

.read-later-reading-article {
    width: 100%;
    max-width: 85%;
    background: var(--panel);
    border: 1px solid var(--panel-border);
    border-radius: var(--radius);
    padding: 2rem;
    box-sizing: border-box;
}

.read-later-reading-article h1 {
    margin: 0 0 0.35rem;
    font-size: 1.6rem;
    line-height: 1.3;
}

.read-later-reading-image {
    display: block;
    max-width: 100%;
    max-height: 420px;
    width: auto;
    border-radius: var(--radius);
    margin: 1rem 0 0;
}

.read-later-reading-content {
    margin-top: 1.5rem;
    font-size: 1.05rem;
    line-height: 1.7;
}

.read-later-reading-content img {
    max-width: 100%;
    height: auto;
    border-radius: var(--radius);
}

.read-later-reading-content p {
    margin: 0 0 1rem;
}

.read-later-extraction-failed {
    margin-top: 1.5rem;
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: 0.6rem;
}

/* Recipe layout (`read_later::RecipeView`) — per user request 2026-08-11,
   shown instead of the generic content dump for articles saved into the
   reserved Recettes collection. */
.read-later-recipe-meta {
    margin-top: 1rem;
}

.read-later-recipe-columns {
    display: flex;
    gap: 2rem;
    flex-wrap: wrap;
    margin-top: 0.75rem;
}

.read-later-recipe-ingredients {
    flex: 1 1 240px;
}

.read-later-recipe-steps {
    flex: 2 1 360px;
}

.read-later-recipe-ingredients h2,
.read-later-recipe-steps h2 {
    font-size: 1rem;
    margin: 0 0 0.5rem;
}

.read-later-recipe-ingredients ul,
.read-later-recipe-steps ol {
    margin: 0;
    padding-left: 1.25rem;
    line-height: 1.6;
}

.read-later-recipe-ingredients li,
.read-later-recipe-steps li {
    margin-bottom: 0.4rem;
}

.read-later-recipe-full-article {
    margin-top: 1.5rem;
}

.read-later-edit-title {
    margin: -0.25rem 0 0.75rem;
    font-weight: 600;
}

/* ---------------------------------------------------------------- contacts */

.contact-search {
    width: 100%;
    box-sizing: border-box;
    margin-bottom: 0.6rem;
    background: var(--bg);
    border: 1px solid var(--panel-border);
    border-radius: 6px;
    color: var(--text);
    padding: 0.4rem 0.5rem;
}

.contact-search:focus {
    outline: none;
    border-color: var(--accent);
    box-shadow: 0 0 0 3px rgb(91 140 255 / 20%);
}

.contact-list {
    list-style: none;
    margin: 0;
    padding: 0;
}

/* Scoped the same way `.ghost` is: `.widget button` and `.fullscreen-panel
   button` paint every button accent-blue, and an unqualified `.contact-row`
   loses to them on specificity — which left the whole list looking like a
   column of primary buttons. */
.widget .contact-row,
.fullscreen-panel .contact-row {
    display: flex;
    align-items: center;
    gap: 0.6rem;
    width: 100%;
    background: none;
    border: none;
    border-radius: 6px;
    box-shadow: none;
    color: var(--text);
    font-weight: 400;
    padding: 0.35rem 0.4rem;
    text-align: left;
    cursor: pointer;
}

.widget .contact-row:hover,
.fullscreen-panel .contact-row:hover,
.widget .contact-row.selected,
.fullscreen-panel .contact-row.selected {
    background: rgb(255 255 255 / 6%);
}

/* Same story for the form's selects, which have no generic rule to inherit
   from and would otherwise render in the browser's own white. */
.contact-form select,
.contact-field select {
    background: var(--bg);
    border: 1px solid var(--panel-border);
    border-radius: 6px;
    color: var(--text);
    padding: 0.5rem 0.6rem;
    font: inherit;
    min-width: 0;
}

.contact-form select:focus,
.contact-field select:focus {
    outline: none;
    border-color: var(--accent);
    box-shadow: 0 0 0 3px rgb(91 140 255 / 20%);
}

.contact-row-text {
    display: flex;
    flex-direction: column;
    min-width: 0;
}

.contact-row-company {
    font-size: 0.8rem;
    color: var(--muted);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* The disc is a fixed-size flex item so a long name can never squeeze it
   into an ellipse. */
.contact-avatar {
    flex: 0 0 auto;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 32px;
    height: 32px;
    border-radius: 50%;
    overflow: hidden;
    background: var(--panel-border);
    font-size: 0.75rem;
    font-weight: 600;
    color: var(--text);
}

/* Companies get a square-ish disc, so a one-letter initial is never mistaken
   for a person at a glance. */
.contact-avatar.company {
    border-radius: 8px;
    background: var(--bg);
    border: 1px solid var(--panel-border);
}

.contact-avatar.large {
    width: 96px;
    height: 96px;
    font-size: 1.8rem;
}

.contact-avatar img {
    width: 100%;
    height: 100%;
    object-fit: cover;
}

/* Two panes at a fixed left width: the list is a column of names, and giving
   it a share of the width instead would leave the card cramped on a laptop. */
.contacts-layout {
    display: grid;
    grid-template-columns: 280px 1fr;
    gap: 1rem;
    align-items: start;
    height: 100%;
    min-height: 0;
}

.contacts-pane-list {
    display: flex;
    flex-direction: column;
    min-height: 0;
    max-height: 100%;
    border-right: 1px solid var(--panel-border);
    padding-right: 0.75rem;
}

.contacts-pane-list .events-view-tabs {
    margin-bottom: 0.5rem;
}

.contacts-pane-list .contact-list {
    overflow-y: auto;
    flex: 1 1 auto;
    min-height: 0;
}

.contacts-pane-card {
    overflow-y: auto;
    max-height: 100%;
    min-height: 0;
}

/* Inside the transfer modal now, rather than under the list: these act on the
   whole address book, and sat oddly beneath a list showing a filtered part of
   it. */
.transfer-actions {
    display: flex;
    flex-direction: column;
    align-items: stretch;
    gap: 0.5rem;
}

.transfer-actions .button-like {
    text-align: center;
}

/* An `<a download>` and a `<label>` wrapping a file input have to sit next to
   real buttons without looking like links. The `.ghost` rules elsewhere in
   this file are all written as `button.ghost`, which neither of these is, so
   the appearance is restated here rather than widened there — a bare `.ghost`
   would repaint every ghost button in the app. */
.button-like {
    display: inline-block;
    padding: 0.3rem 0.6rem;
    border-radius: 6px;
    font-size: 0.85rem;
    text-decoration: none;
    cursor: pointer;
    background: transparent;
    color: var(--text-dim);
    border: 1px solid var(--panel-border);
    font-weight: 400;
    box-shadow: none;
}

.button-like:hover {
    color: var(--text);
    border-color: var(--accent);
}

.transfer-actions input[type="file"] {
    display: none;
}

.contact-card-header {
    display: grid;
    /* Avatar, name, and the edit button tucked into the corner. The third
       column is one icon wide, so unlike the row of text buttons it replaced
       it never squeezes the name into one word per line. */
    grid-template-columns: auto minmax(0, 1fr) auto;
    gap: 0.5rem 1rem;
    align-items: start;
    margin-bottom: 1rem;
}

.contact-card-edit {
    justify-self: end;
}

/* On their own row under the name rather than in a third column: sharing the
   width with the identity left the name wrapping one word per line as soon as
   the pane narrowed. */
.contact-card-actions {
    grid-column: 1 / -1;
    justify-content: flex-start;
}

.contact-card-identity h3 {
    margin: 0 0 0.25rem;
}

.contact-card-meta {
    margin: 0;
    font-size: 0.85rem;
    color: var(--muted);
}

.contact-card-actions {
    display: flex;
    flex-wrap: wrap;
    gap: 0.4rem;
}

.contact-card-section {
    margin-bottom: 1rem;
}

.contact-card-section h4 {
    margin: 0 0 0.35rem;
    font-size: 0.75rem;
    text-transform: uppercase;
    letter-spacing: 0.05em;
    color: var(--muted);
}

.contact-card-section ul {
    list-style: none;
    margin: 0;
    padding: 0;
}

.contact-card-section li {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    padding: 0.15rem 0;
}

.contact-card-notes {
    margin: 0;
    white-space: pre-wrap;
}

.contact-tag {
    font-size: 0.7rem;
    color: var(--muted);
    border: 1px solid var(--panel-border);
    border-radius: 999px;
    padding: 0.05rem 0.45rem;
}

/* Scoped like `.contact-row` above, and for the same reason: `.widget button`
   and `.fullscreen-panel button` paint every button accent-blue, which turned
   each relation into a chunky primary button instead of a link. */
.widget .contact-relation-link,
.fullscreen-panel .contact-relation-link,
.modal-panel .contact-relation-link {
    background: none;
    border: none;
    box-shadow: none;
    color: var(--accent);
    cursor: pointer;
    padding: 0;
    font: inherit;
    font-weight: 400;
}

.widget .contact-relation-link:hover,
.fullscreen-panel .contact-relation-link:hover,
.modal-panel .contact-relation-link:hover {
    background: none;
    text-decoration: underline;
}

.contact-card-footer {
    border-top: 1px solid var(--panel-border);
    padding-top: 0.6rem;
}

/* Takes the delete button's place, `margin-left: auto` and all: the question
   belongs where the button that raised it was, not shunted back to the left
   next to Enregistrer. */
.contact-delete-confirm {
    display: inline-flex;
    align-items: center;
    justify-content: flex-end;
    gap: 0.5rem;
    flex-wrap: wrap;
    margin-left: auto;
    text-align: right;
}

.contact-qr {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 0.6rem;
}

.contact-qr img {
    width: 260px;
    height: 260px;
    background: #fff;
    border-radius: 8px;
    padding: 0.5rem;
}

.contact-form {
    display: flex;
    flex-direction: column;
    gap: 0.9rem;
}

.contact-form-photo {
    display: flex;
    align-items: center;
    gap: 0.75rem;
}

.contact-photo-preview,
.contact-photo-placeholder {
    width: 72px;
    height: 72px;
    border-radius: 50%;
    object-fit: cover;
    background: var(--bg);
}

/* An empty photo slot reads as a place to drop one — a dashed outline and a
   plus — rather than as a grey disc that might be a failed image. */
.contact-photo-placeholder {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    border: 1px dashed var(--panel-border);
    color: var(--muted);
    font-size: 1.4rem;
}

.contact-form-photo-actions input[type="file"] {
    display: none;
}

/* Pushed away from Enregistrer/Annuler: it is the one button here that
   cannot be taken back. */
.contact-delete-button {
    margin-left: auto;
}

.contact-form-photo-actions {
    display: flex;
    flex-direction: column;
    gap: 0.35rem;
    font-size: 0.8rem;
}

/* Fields in rows of their own, collapsing to one column when the pane is too
   narrow for a pair to stay legible. `single` is for the row that holds one
   field alone (the sex), which `auto-fit` would otherwise stretch across the
   whole width. */
.contact-form-grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
    gap: 0.5rem;
}

.contact-form-grid.single {
    grid-template-columns: minmax(180px, 240px);
}

/* A label above its control, so "Nom" and "Nom de jeune fille" stay told
   apart once both are filled in — which a placeholder cannot do, since it
   disappears exactly when the answer is needed. */
.contact-field {
    display: flex;
    flex-direction: column;
    gap: 0.15rem;
    min-width: 0;
}

.contact-field > span {
    font-size: 0.7rem;
    text-transform: uppercase;
    letter-spacing: 0.05em;
    color: var(--muted);
}

.contact-form-section h4 {
    margin: 0 0 0.4rem;
    font-size: 0.75rem;
    text-transform: uppercase;
    letter-spacing: 0.05em;
    color: var(--muted);
}

.contact-form-row,
.contact-form-address {
    display: flex;
    flex-wrap: wrap;
    gap: 0.4rem;
    margin-bottom: 0.4rem;
}

.contact-form-row > input,
.contact-form-address > input {
    flex: 1 1 140px;
    min-width: 0;
}

.contact-form-row > select {
    flex: 0 0 auto;
}

.contact-form-label {
    flex: 0 1 110px !important;
}

.contact-form-postal {
    flex: 0 1 110px !important;
}

.contact-form-add-row {
    display: flex;
    flex-wrap: wrap;
    gap: 0.4rem;
}

.contact-form-actions {
    display: flex;
    gap: 0.5rem;
}

.contact-form textarea {
    width: 100%;
    box-sizing: border-box;
    background: var(--bg);
    border: 1px solid var(--panel-border);
    border-radius: 6px;
    color: var(--text);
    padding: 0.4rem 0.5rem;
    font: inherit;
    resize: vertical;
}

.event-participants {
    display: flex;
    flex-direction: column;
    gap: 0.35rem;
}

.event-participant-chips {
    display: flex;
    flex-wrap: wrap;
    gap: 0.35rem;
}

.event-participant-chips .contact-tag {
    display: inline-flex;
    align-items: center;
    gap: 0.25rem;
}

.event-participant-chips .contact-tag button {
    padding: 0 0.15rem;
    font-size: 0.9rem;
    line-height: 1;
}

.event-item-participants {
    color: var(--muted);
    font-size: 0.8rem;
}

/* What the widget shows instead of a list nobody scrolls: how much is on file
   and what is coming up. */
.contact-stats ul {
    list-style: none;
    margin: 0.4rem 0 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 0.2rem;
    color: var(--muted);
    font-size: 0.85rem;
}

/* `.widget li` spreads its children to both edges, which is right for a list
   row and wrong for a counter: "2" and "personnes" belong together. */
.contact-stats li {
    justify-content: flex-start;
    gap: 0.25rem;
}

.contact-stats strong {
    color: var(--text);
    font-size: 1rem;
    margin-right: 0.25rem;
}

/* ------------------------------------------------------------------ lieux */

.place-list-select {
    width: 100%;
    box-sizing: border-box;
    margin-bottom: 0.6rem;
    background: var(--bg);
    border: 1px solid var(--panel-border);
    border-radius: 6px;
    color: var(--text);
    padding: 0.4rem 0.5rem;
    font: inherit;
}

.place-list {
    list-style: none;
    margin: 0;
    padding: 0;
}

/* Same out-specificity treatment as `.contact-row`: `.widget button` paints
   every button accent-blue, which would turn the list into a column of
   primary buttons. */
.widget .place-row,
.fullscreen-panel .place-row {
    display: flex;
    align-items: center;
    gap: 0.6rem;
    width: 100%;
    background: none;
    border: none;
    border-radius: 6px;
    box-shadow: none;
    color: var(--text);
    font-weight: 400;
    padding: 0.35rem 0.4rem;
    text-align: left;
    cursor: pointer;
}

.widget .place-row:hover,
.fullscreen-panel .place-row:hover,
.widget .place-row.selected,
.fullscreen-panel .place-row.selected {
    background: rgb(255 255 255 / 6%);
}

.place-row-icon {
    flex: 0 0 auto;
    font-size: 1.1rem;
    line-height: 1;
}

.place-row-text {
    display: flex;
    flex-direction: column;
    min-width: 0;
    flex: 1;
}

.place-row-meta {
    font-size: 0.8rem;
    color: var(--muted);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.place-row-rating {
    flex: 0 0 auto;
    font-size: 0.75rem;
    color: var(--muted);
}

/* The list column, then the map with the selected card beneath it: the map
   needs the height, the card needs to stay visible while panning. */
.places-layout {
    display: grid;
    /* Wider than the 300px it was: the itinerary rows carry a rank, a name,
       two dates and four buttons, which is more than a place row ever had.
       Widened again (user request 2026-08-27, "certains dépassent") — the
       longest stop names still ran under the row's own buttons. */
    grid-template-columns: minmax(320px, 440px) 1fr;
    gap: 1rem;
    height: 100%;
    min-height: 0;
}

.places-pane-list {
    display: flex;
    flex-direction: column;
    min-height: 0;
    border-right: 1px solid var(--panel-border);
    padding-right: 0.75rem;
    overflow-y: auto;
}

.places-pane-map {
    display: grid;
    grid-template-rows: minmax(240px, 3fr) minmax(0, 2fr);
    gap: 0.75rem;
    min-height: 0;
}

/* Leaflet needs a container with a real height; without one it draws a single
   grey square (see `map::invalidate` for the other half of that problem). */
.places-map {
    min-height: 240px;
    border-radius: 8px;
    overflow: hidden;
    background: var(--bg);
    /* Leaflet lays its panes out from z-index 200 (tiles) to 1000 (controls)
       in whatever stacking context it finds itself in — which was this
       page's, so a date picker popup (z-index 350, see
       `.date-picker-backdrop`) opened beside the map came out *under* the
       markers and the zoom buttons. Isolating keeps that whole range inside
       the map box, where it belongs. */
    isolation: isolate;
}

.places-pane-card {
    overflow-y: auto;
    min-height: 0;
}

/* A coloured dot rather than Leaflet's default pin, which would need two PNGs
   from the distribution to say less. */
.place-marker span {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 14px;
    height: 14px;
    margin: 2px;
    border-radius: 50%;
    border: 2px solid rgb(255 255 255 / 85%);
    box-shadow: 0 1px 3px rgb(0 0 0 / 45%);
}

/* A place already visited carries a tick inside its dot. Sized to the dot
   rather than to the text around it — this sits on the map, where 9px is as
   large as a 14px circle can hold and still read as a circle. */
.place-marker-tick {
    font-size: 9px;
    line-height: 1;
    color: #fff;
    text-shadow: 0 1px 2px rgb(0 0 0 / 60%);
}

/* The places a trip's next stop could be picked from. Deliberately the
   quietest thing on the map (user request 2026-08-27, "de manière plus
   discrète") — smaller than a saved place's dot, half faded, no shadow: the
   route is the subject, these are only what is around it. Solid on hover, so
   the one under the pointer is unmistakably the one a click would take. */
.place-candidate-marker span {
    display: block;
    width: 8px;
    height: 8px;
    margin: 3px;
    border-radius: 50%;
    border: 1px solid rgb(255 255 255 / 55%);
    opacity: 0.5;
}

.place-candidate-marker:hover span {
    opacity: 1;
    transform: scale(1.3);
}

/* The geocoder's answers, drawn while the add form is open: a hollow ring
   numbered to match the suggestion list, deliberately unlike a saved place's
   solid dot — nothing here is saved yet. */
.place-preview-marker span {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 20px;
    height: 20px;
    border-radius: 50%;
    background: rgb(15 17 21 / 85%);
    border: 2px dashed var(--accent);
    color: var(--text);
    font-size: 11px;
    font-weight: 600;
    box-shadow: 0 1px 4px rgb(0 0 0 / 50%);
}

.place-preview-marker.highlighted span {
    background: var(--accent);
    border-style: solid;
    color: #fff;
    transform: scale(1.15);
}

/* Leaflet's own tooltip is white-on-white against this theme. */
.leaflet-tooltip {
    background: var(--panel);
    border: 1px solid var(--panel-border);
    color: var(--text);
    box-shadow: 0 2px 8px rgb(0 0 0 / 45%);
    font-size: 0.8rem;
}

.leaflet-tooltip-top::before {
    border-top-color: var(--panel-border);
}

.place-list-chips {
    display: flex;
    flex-wrap: wrap;
    gap: 0.3rem;
    margin-bottom: 0.6rem;
}

.widget .place-list-chip,
.fullscreen-panel .place-list-chip {
    font-size: 0.8rem;
    padding: 0.2rem 0.5rem;
    border-radius: 999px;
}

.fullscreen-panel .place-list-chip.selected {
    border-color: var(--accent);
    color: var(--accent);
}

.place-card-header {
    display: grid;
    grid-template-columns: auto minmax(0, 1fr) auto;
    gap: 0.5rem 0.75rem;
    align-items: start;
    margin-bottom: 0.75rem;
}

.place-card-icon {
    font-size: 1.6rem;
    line-height: 1;
}

.place-card-identity h3 {
    margin: 0 0 0.2rem;
}

.place-card-marks {
    margin: 0.2rem 0 0;
    display: flex;
    gap: 0.5rem;
    align-items: center;
    font-size: 0.85rem;
    color: var(--muted);
}

.place-card-lists,
.place-card-line {
    margin: 0 0 0.5rem;
    display: flex;
    flex-wrap: wrap;
    gap: 0.3rem;
}

.place-card-actions {
    display: flex;
    flex-wrap: wrap;
    gap: 0.4rem;
    margin-bottom: 0.75rem;
}

.place-search-row {
    display: flex;
    gap: 0.4rem;
    margin-bottom: 0.4rem;
}

.place-search-row input {
    flex: 1;
    min-width: 0;
    background: var(--bg);
    border: 1px solid var(--panel-border);
    border-radius: 6px;
    color: var(--text);
    padding: 0.4rem 0.5rem;
    font: inherit;
}

/* The geocoder's answers, listed under the box that produced them. */
.place-suggestions {
    list-style: none;
    margin: 0 0 0.5rem;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 0.2rem;
    max-height: 12rem;
    overflow-y: auto;
}

.fullscreen-panel .place-suggestions button {
    width: 100%;
    display: flex;
    align-items: center;
    gap: 0.4rem;
    text-align: left;
    font-size: 0.85rem;
}

/* The number tying a suggestion to its dot on the map. */
.place-suggestion-rank {
    flex: none;
    width: 1.15rem;
    height: 1.15rem;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    border-radius: 50%;
    border: 1px dashed var(--accent);
    color: var(--accent);
    font-size: 0.7rem;
    font-weight: 600;
}

/* Lieux / Parcours. The same two-button switch the media widget uses for its
   own modes, kept above both pickers so it reads as "what this pane is",
   not as one more filter inside it. */
.places-mode-switch {
    display: flex;
    align-items: center;
    gap: 0.3rem;
    margin-bottom: 0.5rem;
}

.trip-itinerary {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 0.15rem;
    /* `.places-pane-list` is the flex column that scrolls; a nested scroll
       area here would shrink the itinerary to a few rows and hide the rest
       behind a second scrollbar. */
    flex: none;
}

/* The day a run of stops shares. A heading rather than a nested list: the
   stops are numbered across the whole trip, and nesting them per day would
   restart that count visually. */
.trip-day {
    margin-top: 0.5rem;
    padding: 0.15rem 0.4rem;
    color: var(--text-dim);
    font-size: 0.75rem;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    border-bottom: 1px solid var(--panel-border);
}

.trip-stop {
    display: flex;
    flex-direction: column;
    gap: 0.2rem;
}

/* The blanket `.widget li` rule's `align-items: center` left every stop
   shrink-wrapped around its own text and centred, so a long name made its row
   wider than the ones above it and hung past them on both sides (user request
   2026-08-27, "certains dépassent"). Stretched, each row is the pane's width,
   the ↑↓✎× line up down the column, and a long name wraps inside the row
   instead of widening it. Stated at class+element specificity for the reason
   `.widget li.todo-item` is: a bare `.trip-stop` loses to `.widget li`. */
.widget li.trip-stop,
.fullscreen-panel li.trip-stop {
    align-items: stretch;
}

.trip-stop-row {
    display: flex;
    align-items: center;
    gap: 0.3rem;
}

/* Matches the numbered marker on the map — same number, same circle. */
.trip-stop-rank {
    flex: none;
    width: 1.3rem;
    height: 1.3rem;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    border-radius: 50%;
    background: rgb(255 255 255 / 8%);
    color: var(--text-dim);
    font-size: 0.7rem;
    font-weight: 600;
}

.fullscreen-panel .trip-stop-main {
    display: flex;
    align-items: center;
    gap: 0.6rem;
    flex: 1;
    min-width: 0;
    background: none;
    border: none;
    box-shadow: none;
    color: var(--text);
    font-weight: 400;
    padding: 0.35rem 0.4rem;
    border-radius: 6px;
    text-align: left;
    cursor: pointer;
}

.fullscreen-panel .trip-stop-main:hover {
    background: rgb(255 255 255 / 6%);
}

.trip-stop-actions {
    display: flex;
    gap: 0.1rem;
}

.fullscreen-panel .trip-stop-actions button {
    padding: 0.15rem 0.35rem;
    font-size: 0.8rem;
}

.trip-stop-notes {
    margin: 0 0 0 1.6rem;
    color: var(--text-dim);
    font-size: 0.8rem;
}

.trip-stop-editor,
.trip-add-stop {
    display: flex;
    flex-direction: column;
    gap: 0.35rem;
    padding: 0.4rem 0.4rem 0.5rem 1.6rem;
    /* Both hold date+time pairs whose fields have widths of their own; without
       this the editor grew past the pane and spilled out of the panel
       entirely instead of wrapping. */
    min-width: 0;
}

.trip-add-stop {
    padding-left: 0;
    margin-top: 0.6rem;
    border-top: 1px solid var(--panel-border);
    padding-top: 0.6rem;
}

/* The field and the list it filters are one control (see `PlacePicker`), so
   they are one box in the layout too. The list is in the flow rather than
   floated over what follows it: `.places-pane-list` is a scroll box, and this
   form sits at the bottom of it, where an absolutely positioned dropdown
   would be clipped by the very edge it needs to hang past. */
.place-combobox {
    display: flex;
    flex-direction: column;
    gap: 0.3rem;
    min-width: 0;
}

.place-combobox-list {
    list-style: none;
    margin: 0;
    padding: 0.2rem;
    max-height: 13rem;
    overflow-y: auto;
    background: var(--bg);
    border: 1px solid var(--panel-border);
    border-radius: 6px;
}

/* `.widget li`'s blanket flex row would shrink-wrap each option around its
   own name; a picker's rows are the width of the list, whatever they say. */
.widget .place-combobox-list li,
.fullscreen-panel .place-combobox-list li {
    display: block;
}

.widget .place-combobox-option,
.fullscreen-panel .place-combobox-option {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    width: 100%;
    background: none;
    border: none;
    box-shadow: none;
    border-radius: 4px;
    color: var(--text);
    font-weight: 400;
    text-align: left;
    padding: 0.3rem 0.4rem;
    cursor: pointer;
}

.widget .place-combobox-option:hover,
.fullscreen-panel .place-combobox-option:hover,
.widget .place-combobox-option.highlighted,
.fullscreen-panel .place-combobox-option.highlighted {
    background: rgb(91 140 255 / 18%);
}

.place-combobox-empty {
    margin: 0;
    padding: 0.4rem 0.5rem;
    color: var(--text-dim);
    font-size: 0.8rem;
}

.trip-add-dates {
    display: flex;
    flex-wrap: wrap;
    gap: 0.4rem;
}

/* A date and its time read as one field, wrapping together when the pane is
   narrow rather than leaving the hour stranded on its own line. */
.trip-moment {
    display: flex;
    flex-wrap: wrap;
    gap: 0.25rem;
    align-items: center;
    min-width: 0;
}

.trip-moment .date-picker {
    flex: 1 1 9rem;
    min-width: 0;
}

.trip-moment .time-picker {
    flex: 0 0 4.5rem;
}

/* Label above its picker rather than beside it: side by side, the second
   label was squeezed to nothing by two date fields that each want their full
   `jj/mm/aaaa` width. The basis is what makes the pair wrap to one per line
   in a narrow pane rather than overflow it. */
.trip-stop-editor label,
.trip-add-dates label {
    display: flex;
    flex-direction: column;
    gap: 0.15rem;
    flex: 1 1 13rem;
    min-width: 0;
    color: var(--text-dim);
    font-size: 0.75rem;
}

.trip-add-stop,
.trip-stop-editor {
    flex: none;
}

/* Leaflet's own `.leaflet-bar a` styling assumes its light theme; this is the
   one control this app adds itself, so it is restated in the app's colours.
   Both classes, since `.leaflet-bar a` ties on specificity and would win or
   lose on stylesheet order alone. */
.leaflet-bar.places-fullscreen-control a {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 30px;
    height: 30px;
    background: var(--panel);
    color: var(--text);
    font-size: 15px;
    line-height: 1;
    text-decoration: none;
}

.leaflet-bar.places-fullscreen-control a:hover {
    background: var(--panel-border);
    color: var(--text);
}

/* Fullscreen hands the element the whole screen, and the rounded corners and
   the min-height of a pane-sized map stop meaning anything there. */
.places-map:fullscreen {
    border-radius: 0;
    width: 100vw;
    height: 100vh;
}

/* A stop's marker: the same circle as its row's rank, in the trip's colour,
   so the line on the map and the list beside it are read as one thing. */
.place-route-marker span {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 22px;
    height: 22px;
    border-radius: 50%;
    border: 2px solid rgb(255 255 255 / 85%);
    box-shadow: 0 1px 4px rgb(0 0 0 / 50%);
    color: #fff;
    font-size: 11px;
    font-weight: 700;
}

/* One line whatever the number of lists — see `places::lists` for what the
   row of wrapping chips this replaced did to the top of the pane. */
.place-list-picker {
    display: flex;
    align-items: center;
    gap: 0.4rem;
    margin-bottom: 0.6rem;
}

.place-list-picker select {
    flex: 1;
    min-width: 0;
    background: var(--bg);
    border: 1px solid var(--panel-border);
    border-radius: 6px;
    color: var(--text);
    padding: 0.4rem 0.5rem;
    font: inherit;
}

/* The selected list's colour, which is also its markers' — the one thing the
   `<select>` itself cannot show. */
.place-list-dot {
    flex: 0 0 auto;
    width: 12px;
    height: 12px;
    border-radius: 50%;
}

/* The picker's export link. `.button-like` alone is sized for the transfer
   dialog's stacked column; in this row it has to keep the height of the
   ghost button beside it and never be the thing that shrinks when a trip
   has a long name. */
.trip-export {
    flex: 0 0 auto;
    padding: 0.4rem 0.5rem;
    font-size: 0.75rem;
    letter-spacing: 0.03em;
}

.place-lists-manager {
    list-style: none;
    margin: 0 0 0.75rem;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 0.4rem;
    max-height: 22rem;
    overflow-y: auto;
}

.place-lists-manager li,
.place-lists-new {
    display: flex;
    align-items: center;
    gap: 0.4rem;
    flex-wrap: wrap;
}

.place-lists-manager input[type="text"],
.place-lists-new input[type="text"] {
    flex: 1;
    min-width: 6rem;
}

/* A native colour swatch, sized down to sit inline with the name it belongs
   to rather than towering over it. */
.place-list-color {
    flex: 0 0 auto;
    width: 2rem;
    height: 2rem;
    padding: 0;
    border: 1px solid var(--panel-border);
    border-radius: 6px;
    background: none;
    cursor: pointer;
}

.place-list-count {
    font-size: 0.75rem;
    color: var(--muted);
    white-space: nowrap;
}

/* Reordering a trip: the two arrows travel together, and stay their own size
   while the name field beside them takes the row's slack. */
.place-list-order {
    display: flex;
    gap: 0.1rem;
    flex: none;
}

.modal-panel .place-list-order button {
    padding: 0.15rem 0.35rem;
}

/* A trip's row carries one control more than a list's — the ↑/↓ pair — and at
   this modal's fixed width the six of them fit only just. Whether they wrap is
   decided on each item's flex *basis*, not on the width it ends up with, so a
   name field asking for its full share was enough to drop the delete button
   onto a line of its own (user request 2026-08-27) — and a stop count reaching
   two digits was enough to do it again. The name asks for almost nothing and
   takes the slack afterwards; everything else keeps the size it needs. Still
   `wrap`, not `nowrap`: the delete confirmation replaces those buttons with a
   sentence, which has to be allowed to fall onto its own line. */
.place-lists-manager.trips-manager input[type="text"] {
    flex: 1 1 2rem;
    min-width: 0;
}

.place-lists-manager.trips-manager .place-list-count,
.place-lists-manager.trips-manager li > button {
    flex: none;
}

.trips-manager .place-list-count {
    font-size: 0.7rem;
}

/* Confirming a delete swaps four controls for a sentence and two answers,
   which no longer fit after the name: given the whole line deliberately,
   rather than wrapping into it a word at a time and leaving "Oui" and "Non"
   on a line each. */
.place-lists-manager .contact-delete-confirm {
    flex-basis: 100%;
    justify-content: flex-start;
    margin-left: 0;
    /* Small enough that the question and both answers share that line: at the
       body size the sentence alone took nearly all of it. */
    font-size: 0.8rem;
}

/* Streaming shelves (BD widget's second tab, per user request 2026-08-19).
   The platform filter sits under the view tabs and reuses their button
   styling, just smaller — it narrows an already-fetched list rather than
   selecting a different one. */
.streaming-platform-row {
    display: flex;
    align-items: center;
    gap: 0.3rem;
    flex-wrap: wrap;
    margin-bottom: 0.4rem;
}

.streaming-platform-row button {
    font-size: 0.72rem;
    padding: 0.1rem 0.45rem;
}

/* The platform row lists all five services, subscribed to or not, so that
   filtering isn't limited to the user's own selection. This is what still
   makes that selection readable at a glance — a marker, not a state: the
   button's `.active` styling means "currently filtered on", which is a
   different thing entirely. */
.streaming-platform-row button.subscribed::before {
    content: "•";
    margin-right: 0.2rem;
    color: var(--accent);
}

/* Sized off the wrapper, which is what actually reserves the column. */
.streaming-poster {
    width: 100%;
}

/* The card is an `<a>` (it deep-links into the platform), so the browser
   underlines its title and paints it link-coloured. */
.streaming-poster,
.streaming-poster:hover {
    text-decoration: none;
    color: inherit;
}

/* Fixed 2:3 frame so a missing or oddly-sized poster can't make one card
   taller than its neighbours and jog the whole row. `overflow: hidden` is
   also what clips the "jusqu'au" banner to the artwork's bottom edge
   instead of letting it stick out. */
.streaming-poster-frame {
    position: relative;
    display: block;
    width: 100%;
    aspect-ratio: 2 / 3;
    border-radius: 6px;
    overflow: hidden;
    background: color-mix(in srgb, var(--text-dim) 15%, transparent);
}

.streaming-poster-frame img {
    width: 100%;
    height: 100%;
    object-fit: cover;
    display: block;
}

.streaming-poster-leaving {
    position: absolute;
    left: 0;
    right: 0;
    bottom: 0;
    padding: 0.1rem 0.25rem;
    background: rgba(192, 57, 43, 0.9);
    color: #fff;
    font-size: 0.62rem;
    text-align: center;
}

.streaming-poster-meta {
    display: block;
    font-size: 0.68rem;
    color: var(--text-dim);
}


/* The fullscreen streaming view — a wrapping grid, unlike the compact
   card's single scrolling strip. */
.streaming-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(108px, 1fr));
    gap: 0.75rem 0.6rem;
    align-content: start;
    overflow-y: auto;
    padding-bottom: 0.6rem;
}

/* The grid sizes its own columns, so the card fills whatever it is given
   rather than the compact row's fixed 90px. */
.streaming-grid .streaming-poster-wrapper {
    width: 100%;
}

/* Grid items stretch to the row height by default, which spreads a card's
   poster, title and year apart instead of stacking them. */
.streaming-grid .streaming-poster {
    display: flex;
    flex-direction: column;
    gap: 0.15rem;
    align-self: start;
}

/* The card is a button now (it opens the detail sheet), with the badges as
   siblings so they can sit over it without being part of the click target.
   
   The explicit width matters twice over. `.catalog-row` is a flex container
   and this used to be an `inline-block` with no width, so the card sized
   itself to its *content* — a long title made the card wider than its
   poster, pushing the following ones sideways. It only showed on a sparse
   row (per user report 2026-08-20): a full row has enough items for
   `flex-shrink` to pull them all back to the poster's width, which is what
   made it look intermittent.
   
   90px, matching the cinema widget's own posters, is also what keeps the
   card inside `.catalog-row`'s fixed 200px. At 108px the card came to
   212px, and a flex row that centres its items then clipped 11px off the
   *top* of every poster and grew a vertical scrollbar it should never have
   had. */
.streaming-poster-wrapper {
    position: relative;
    display: block;
    width: 90px;
    flex: none;
}

/* The "Séries en cours" tab's own count. Shown only when non-zero (see
   `filter_buttons`) — a badge reading "0" is noise, not information. */
.tab-badge {
    display: inline-block;
    margin-left: 0.3rem;
    padding: 0 0.32rem;
    border-radius: 999px;
    background: var(--accent);
    color: #fff;
    font-size: 0.62rem;
    font-weight: 600;
    line-height: 1.4;
}

/* Where the user is, over the artwork's bottom-left — same corner and same
   clipping as the "jusqu'au" banner, which it never shares a card with. */
.streaming-poster-position {
    position: absolute;
    left: 0;
    bottom: 0;
    padding: 0.1rem 0.3rem;
    border-top-right-radius: 6px;
    background: rgba(0, 0, 0, 0.72);
    color: #fff;
    font-size: 0.62rem;
    font-variant-numeric: tabular-nums;
}

/* The news line only stands out when there is actually news; "à jour" and
   "terminée" stay as quiet as any other meta line. */
.streaming-poster-meta.has-news {
    color: var(--accent);
    font-weight: 600;
}

/* Not a `.poster-badge` variant by accident: it reuses that class for the
   circle and position, and only repaints it. */
.poster-badge-news {
    right: 0.3rem;
    background: var(--accent);
    color: #fff;
    font-weight: 700;
}

/* The derived "à jour" state on a series sheet: it sits among the action
   buttons and has to read like one, but it is a statement, not a control —
   a series' watched state is computed, never ticked. */
.movie-action-state {
    display: inline-flex;
    align-items: center;
    gap: 0.25rem;
    padding: 0.25rem 0.6rem;
    border-radius: 6px;
    color: var(--accent);
    font-size: 0.8rem;
    cursor: default;
}

.show-progress {
    margin: 0.6rem 0;
    padding: 0.5rem 0.6rem;
    border: 1px solid color-mix(in srgb, var(--text-dim) 25%, transparent);
    border-radius: 6px;
    background: color-mix(in srgb, var(--text-dim) 6%, transparent);
}

.show-progress-row {
    display: flex;
    align-items: center;
    gap: 0.4rem;
    flex-wrap: wrap;
    margin-top: 0.3rem;
}

.show-progress-row select {
    font-size: 0.78rem;
    padding: 0.15rem 0.3rem;
}

.show-progress-row button {
    font-size: 0.78rem;
    padding: 0.2rem 0.6rem;
}

/* One row per season, collapsed until asked for. */
.season-accordion {
    margin: 0.6rem 0;
}

.season-entry {
    border-top: 1px solid color-mix(in srgb, var(--text-dim) 20%, transparent);
}

.season-toggle {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 0.6rem;
    width: 100%;
    text-align: left;
    padding: 0.35rem 0.2rem;
}

.season-episode-count {
    color: var(--text-dim);
    font-size: 0.72rem;
    white-space: nowrap;
}

.season-body {
    padding: 0.2rem 0.2rem 0.6rem;
}

.season-overview {
    margin: 0.2rem 0 0.5rem;
    font-size: 0.82rem;
    color: var(--text-dim);
}

.episode-list {
    list-style: none;
    margin: 0;
    padding: 0;
}

/* Three columns: the episode number, everything about it, and a reserved
   slot for the watched tick.

   `display: grid` here has to out-specificity `.widget li` (2 classes vs
   1 class + 1 element), which is `display: flex` widget-wide — inherited,
   that turned every episode into a *row* of title, date and synopsis, each
   starting wherever the previous one ended. Rebuilt as a grid, the number,
   the title and the synopsis line up down the list whatever their length.

   The tick's column is reserved whether or not it is drawn, so an episode
   becoming watched doesn't nudge the line it sits on. */
.widget .episode-entry {
    display: grid;
    grid-template-columns: 2.3rem minmax(0, 1fr) 1.1rem;
    column-gap: 0.55rem;
    align-items: start;
    padding: 0.45rem 0;
    border-top: 1px solid color-mix(in srgb, var(--text-dim) 18%, transparent);
}

.episode-list > .episode-entry:first-child {
    border-top: none;
    padding-top: 0;
}

.episode-number {
    color: var(--text-dim);
    font-size: 0.74rem;
    line-height: 1.45;
    text-align: right;
    font-variant-numeric: tabular-nums;
}

/* `minmax(0, 1fr)` above plus this is what lets a long title wrap inside
   its column instead of widening the grid. */
.episode-body {
    display: block;
    min-width: 0;
}

.episode-head {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 0.6rem;
    flex-wrap: wrap;
}

.episode-name {
    font-weight: 600;
    font-size: 0.86rem;
    line-height: 1.35;
}

/* Kept on one line and tabular so the dates form a column of their own on
   the right rather than a ragged edge. */
.episode-air {
    color: var(--text-dim);
    font-size: 0.72rem;
    white-space: nowrap;
    font-variant-numeric: tabular-nums;
}

.episode-overview {
    margin: 0.2rem 0 0;
    font-size: 0.82rem;
    line-height: 1.4;
}

.episode-check {
    display: flex;
    justify-content: center;
    padding-top: 0.15rem;
    color: var(--accent);
}

/* Dimmed rather than hidden: an episode already watched is still worth
   being able to read, it just isn't what you came for. Matches the
   treatment a watched poster gets. */
.episode-entry.watched {
    opacity: 0.55;
}

.streaming-kind-row {
    display: flex;
    gap: 0.3rem;
    flex-wrap: wrap;
    margin-bottom: 0.4rem;
}

.streaming-kind-row button {
    font-size: 0.72rem;
    padding: 0.1rem 0.45rem;
}




.streaming-detail-link {
    display: inline-block;
    margin: 0.3rem 0;
    padding: 0.25rem 0.6rem;
    border-radius: 6px;
    text-decoration: none;
}

/* All platforms carrying the title; the ones the user doesn't subscribe to
   stay listed but recede. */
.streaming-detail-offers {
    display: flex;
    flex-wrap: wrap;
    gap: 0.3rem;
    margin: 0.3rem 0;
}

.streaming-detail-link.unsubscribed {
    opacity: 0.55;
}


/* The services picker sits at the bottom of the fullscreen panel, separated
   from the shelves it configures. */
/* Toggled by the platform row's gear button, and drawn as a panel above the
   shelves it configures rather than as a footer below them. */
.streaming-services {
    margin-bottom: 0.6rem;
    padding: 0.5rem 0.6rem;
    border: 1px solid color-mix(in srgb, var(--text-dim) 25%, transparent);
    border-radius: 6px;
    background: color-mix(in srgb, var(--text-dim) 6%, transparent);
}

/* ---- Programme TV (per user request 2026-08-20) ---- */

/* A scrolling column in the compact card, the full list in the panel. The
   height matches `.catalog-row`'s so switching tabs doesn't jog the page. */
.tv-list {
    display: flex;
    flex-direction: column;
    gap: 0.1rem;
}

.tv-list-compact {
    height: 200px;
    overflow-y: auto;
}

/* Two columns: the channel, then everything about the programme. The channel
   column is fixed so the titles line up down the list — the same reasoning as
   the episode grid. `.widget button` paints every button blue, which a row
   this size must not be, hence the specificity. */
.widget .tv-row,
.tv-row {
    display: grid;
    grid-template-columns: 5.5rem minmax(0, 1fr);
    column-gap: 0.6rem;
    align-items: baseline;
    width: 100%;
    padding: 0.35rem 0.2rem;
    background: none;
    border: none;
    border-top: 1px solid color-mix(in srgb, var(--text-dim) 15%, transparent);
    text-align: left;
    color: inherit;
    font: inherit;
    cursor: pointer;
}

.widget .tv-row:hover,
.tv-row:hover {
    background: color-mix(in srgb, var(--text-dim) 8%, transparent);
    box-shadow: none;
}

.tv-list > .tv-row:first-child {
    border-top: none;
}

.tv-row-channel {
    color: var(--text-dim);
    font-size: 0.72rem;
    font-weight: 600;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.tv-row-body {
    display: flex;
    flex-direction: column;
    gap: 0.05rem;
    min-width: 0;
}

.tv-row-title {
    font-size: 0.86rem;
    font-weight: 600;
    line-height: 1.3;
}

.tv-row-subtitle {
    font-size: 0.78rem;
    color: var(--text-dim);
}

.tv-row-meta {
    font-size: 0.72rem;
    color: var(--text-dim);
    font-variant-numeric: tabular-nums;
}

/* Only drawn for what is actually on air, so its presence is the signal. */
.tv-row-progress {
    display: block;
    height: 3px;
    margin-top: 0.25rem;
    border-radius: 999px;
    background: color-mix(in srgb, var(--text-dim) 25%, transparent);
    overflow: hidden;
}

.tv-row-progress-fill {
    display: block;
    height: 100%;
    background: var(--accent);
}

.tv-toolbar {
    display: flex;
    flex-direction: column;
    gap: 0.3rem;
}

.tv-day-row {
    display: flex;
    align-items: center;
    gap: 0.3rem;
    flex-wrap: wrap;
    margin-bottom: 0.4rem;
}

.tv-day-row button {
    font-size: 0.72rem;
    padding: 0.1rem 0.45rem;
}

/* Thirty channels as buttons would wrap into a wall; as a select it is one
   control. */
.tv-channel-select {
    font-size: 0.78rem;
    padding: 0.15rem 0.3rem;
    max-width: 14rem;
}

.tv-day {
    display: flex;
    flex-direction: column;
    gap: 0.8rem;
}

.tv-day-channel-name {
    margin: 0 0 0.2rem;
    font-size: 0.8rem;
    color: var(--accent);
}

.tv-channels {
    margin-bottom: 0.6rem;
    padding: 0.5rem 0.6rem;
    border: 1px solid color-mix(in srgb, var(--text-dim) 25%, transparent);
    border-radius: 6px;
    background: color-mix(in srgb, var(--text-dim) 6%, transparent);
}

.tv-channel-row {
    display: flex;
    gap: 0.3rem;
    flex-wrap: wrap;
    margin: 0.3rem 0;
}

.tv-channel-row button {
    font-size: 0.72rem;
    padding: 0.1rem 0.45rem;
}

.tv-detail-subtitle {
    margin: 0.1rem 0 0.3rem;
    color: var(--text-dim);
}

.tv-detail-image {
    display: block;
    max-width: 100%;
    max-height: 220px;
    border-radius: 6px;
    margin: 0.4rem 0;
}

/* ---- Fiche d'une ligne de transport (per user request 2026-08-20) ---- */

.line-detail-head {
    display: flex;
    align-items: center;
    gap: 0.7rem;
    margin-bottom: 0.5rem;
}

/* The badge on the sheet is a label, not the interactive board one: no
   tooltip, no hover, no pointer. */
.ratp-badge.line-detail-badge {
    cursor: default;
    flex: none;
}

.ratp-badge.line-detail-badge .ratp-tooltip {
    display: none;
}

/* ---- Le trajet, dessiné comme un plan (per user request 2026-08-21) ----
   One spine, forks hanging off it. The spine is a bar behind every stop
   rather than a border on the list, so a branch can carry its own without
   any of them having to line up on a shared edge. */

/* ---- Le tracé d'une ligne (per user request 2026-08-21) ----
   Rails in a bundle of their own, drawn as one SVG, and every station name
   in a single column beside it: a fork is then a 22px bend rather than a
   bar reaching across a column of labels. Row heights are fixed at 28px on
   both sides, which is what keeps a dot level with its own name without
   measuring anything. */

.line-map {
    display: flex;
    align-items: flex-start;
    gap: 0.55rem;
    margin-top: 0.2rem;
}

.line-map-track {
    flex: none;
    line-height: 0;
}

.line-map-rails .rail {
    fill: none;
    stroke: var(--line-color, var(--accent));
    stroke-width: 3;
    stroke-linecap: round;
}

.line-map-rails .stop {
    fill: var(--panel);
    stroke: var(--line-color, var(--accent));
    stroke-width: 3;
}

/* A stop trains are not calling at, and a stretch they are not running
   over (per user request 2026-08-21). Red for `NO_SERVICE`, amber for
   everything else the API reports — it says nothing finer. */
.line-map {
    --impact-blocking: #e5484d;
    --impact-disrupted: #e08a2f;
}

.line-map-rails .stop.is-blocking {
    stroke: var(--impact-blocking);
}

.line-map-rails .stop.is-disrupted {
    stroke: var(--impact-disrupted);
}

.line-map-rails .cross {
    stroke: var(--impact-blocking);
    stroke-width: 2;
    stroke-linecap: round;
    fill: none;
}

.line-map-rails .cut {
    fill: none;
    stroke-width: 3;
    stroke-dasharray: 5 4;
}

.line-map-rails .cut.is-blocking {
    stroke: var(--impact-blocking);
}

.line-map-rails .cut.is-disrupted {
    stroke: var(--impact-disrupted);
}

/* The name says what happened, next to the stop it happened at. */
.line-map-cut {
    flex: 0 1 auto;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    padding: 0.02rem 0.32rem;
    border-radius: 999px;
    font-size: 0.62rem;
    font-weight: 600;
    white-space: nowrap;
    color: var(--panel);
    background: var(--impact-disrupted);
}

.line-map-label.is-blocking .line-map-cut {
    background: var(--impact-blocking);
}

.line-map-label.is-blocking .line-map-name {
    text-decoration: line-through;
    text-decoration-thickness: 1px;
    color: var(--text-dim);
}

.line-map-rails .arrow {
    fill: var(--line-color, var(--accent));
    stroke: var(--panel);
    stroke-width: 1.5;
}

/* Names on both sides of the rails, so a fork's stop and the trunk's sit
   level with each other (per user request 2026-08-21). The left column is
   sized by its own content — most lines have no fork at all, and it costs
   nothing then. */
.line-map-labels {
    flex: 1 1 auto;
    min-width: 0;
}

.line-map-labels.is-outward {
    flex: 0 1 auto;
    max-width: 45%;
}

/* Row heights are set inline, in step with the drawing beside it. */
.line-map-label {
    display: flex;
    align-items: center;
    gap: 0.35rem;
    font-size: 0.83rem;
}

/* A stop opens its own schedules (per user request 2026-08-21), so its
   name is a target. */
.line-map-label.is-clickable {
    position: relative;
    cursor: pointer;
}

.line-map-label.is-clickable:hover .line-map-name {
    text-decoration: underline;
    text-underline-offset: 2px;
}

/* Over the drawing rather than in it: the rows either side must keep their
   heights for the rails to stay level with the names. */
.line-map-panel {
    position: absolute;
    top: 100%;
    left: 0;
    z-index: 5;
    width: 17rem;
    max-width: 70vw;
    padding: 0.55rem 0.65rem;
    border: 1px solid var(--panel-border);
    border-radius: var(--radius);
    background: var(--panel);
    box-shadow: 0 10px 24px rgba(0, 0, 0, 0.45);
    cursor: default;
    white-space: normal;
}

/* Anchored to the side it belongs to, so it never runs off the sheet. */
.line-map-panel.is-outward {
    left: auto;
    right: 0;
}

.line-map-panel-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.5rem;
    margin-bottom: 0.35rem;
    font-size: 0.85rem;
}

.line-map-panel-title {
    margin: 0.5rem 0 0.2rem;
    font-size: 0.7rem;
    font-weight: 700;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    color: var(--text-dim);
}

.line-map-departures,
.line-map-service {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 0.15rem;
}

.line-map-departures li,
.line-map-service li {
    display: flex;
    align-items: baseline;
    gap: 0.4rem;
    font-size: 0.8rem;
    line-height: 1.3;
}

.line-map-departures li.is-cancelled .line-map-when,
.line-map-departures li.is-cancelled .line-map-toward {
    text-decoration: line-through;
    color: var(--text-dim);
}

.line-map-when {
    flex: none;
    min-width: 3.6rem;
    font-variant-numeric: tabular-nums;
    font-weight: 600;
}

.line-map-service .line-map-when {
    min-width: 0;
    margin-left: auto;
    font-weight: 500;
    color: var(--text-dim);
}

.line-map-toward {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    color: var(--text-dim);
}

/* Reading outward: name first from the rails, badges beyond it. */
.line-map-labels.is-outward .line-map-label {
    flex-direction: row-reverse;
    text-align: right;
}

.line-map-label.is-terminus {
    font-weight: 600;
}

/* The name comes first for the room: a chip and four correspondance badges
   would otherwise squeeze "République" down to "R.". */
.line-map-name {
    flex: 0 1 auto;
    min-width: 4rem;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.line-map-conns {
    display: inline-flex;
    flex: none;
    gap: 0.18rem;
}

.line-map-conn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: 1.05rem;
    height: 1.05rem;
    padding: 0 0.22rem;
    border-radius: 0.28rem;
    font-size: 0.66rem;
    font-weight: 700;
    line-height: 1;
}

.line-map-conn.is-round {
    border-radius: 50%;
    padding: 0;
}

/* ---- Perturbations de la ligne ---- */

.line-traffic-ok {
    margin: 0.2rem 0 0;
    font-size: 0.84rem;
    color: var(--text-dim);
}

.line-disruptions {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 0.45rem;
}

/* Each one is a card keyed to its own severity colour — the previous plain
   bordered rows were unreadable once a line had a dozen of them. */
/* Selector doubled up on purpose: `.widget li` is a flex row and outranks
   a lone class, which laid the title, the dates and the message out as
   three columns. */
.line-disruptions .line-disruption {
    display: block;
    padding: 0.5rem 0.65rem;
    border: 1px solid var(--panel-border);
    border-left: 3px solid var(--disruption-accent, var(--text-dim));
    border-radius: var(--radius);
    background: rgba(255, 255, 255, 0.03);
}

.line-disruption-title {
    margin: 0;
    font-size: 0.85rem;
    font-weight: 600;
}

.line-disruption-period {
    margin: 0.15rem 0 0;
    font-size: 0.74rem;
    color: var(--text-dim);
}

.line-disruption-detail {
    margin: 0.35rem 0 0;
    font-size: 0.8rem;
    line-height: 1.45;
    color: var(--text-dim);
    white-space: pre-line;
}

/* --- Réglages du widget Média (zone + onglets), et sources d'un spectacle ---
   La zone n'est pas propre aux spectacles : c'est la configuration
   géographique de l'utilisateur, destinée à servir aussi à la météo. */
.media-prefs-hint {
    margin: 0 0 0.5rem;
    font-size: 0.8rem;
    color: var(--text-dim);
}

.media-prefs-current {
    margin: 0 0 0.6rem;
    font-size: 0.9rem;
}

.media-prefs-row {
    display: flex;
    gap: 0.5rem;
    align-items: center;
}

.media-prefs-row input {
    flex: 1;
    min-width: 0;
}

.media-prefs-suggestions {
    list-style: none;
    margin: 0.5rem 0 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 0.2rem;
    max-height: 220px;
    overflow-y: auto;
}

.media-prefs-suggestions button {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    width: 100%;
    text-align: left;
    gap: 0.1rem;
}

.media-prefs-suggestion-detail {
    font-size: 0.75rem;
    color: var(--text-dim);
}

/* Mes cinémas : une ligne par salle, son bouton de retrait poussé au bout.
   Séparé des suggestions de recherche juste au-dessus, qui n'ont, elles,
   qu'un seul bouton occupant toute la ligne. */
.cinema-prefs-list {
    list-style: none;
    margin: 0 0 0.6rem;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 0.3rem;
}

.cinema-prefs-list li {
    display: flex;
    align-items: center;
    gap: 0.5rem;
}

.cinema-prefs-entry {
    display: flex;
    flex-direction: column;
    gap: 0.1rem;
    flex: 1;
    min-width: 0;
}

/* Un cinéma saisi à la main, sans identifiant AlloCiné : il figure bien dans
   la liste, mais aucune séance n'en sera récupérée — autant le dire. */
.cinema-prefs-warning {
    font-size: 0.75rem;
    color: var(--danger);
}

.media-prefs-radius {
    display: flex;
    flex-direction: column;
    gap: 0.3rem;
    margin-top: 0.9rem;
    font-size: 0.85rem;
}

/* En colonnes plutôt qu'en ligne : six libellés à cocher débordent d'une
   modale étroite, et une grille garde les cases alignées. */
.media-prefs-sections {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
    gap: 0.35rem;
}

.media-prefs-section {
    display: flex;
    align-items: center;
    gap: 0.4rem;
    font-size: 0.85rem;
}

/* Une ligne par billetterie : leurs textes de prix ne se réconcilient pas
   (« dès 14,50€ » contre une fourchette min-max), donc ils cohabitent au lieu
   d'être fusionnés. */
.spectacle-sources {
    list-style: none;
    margin: 0 0 0.4rem;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 0.3rem;
}

.spectacle-source {
    display: flex;
    align-items: baseline;
    gap: 0.5rem;
    flex-wrap: wrap;
}

.spectacle-source-detail {
    font-size: 0.78rem;
    color: var(--text-dim);
}

/* La période à l'affiche, sous le titre « Séances ». Pour une exposition ou
   une série elle remplace la liste des dates, qui n'existe pas — d'où le poids
   un peu plus marqué que `.spectacle-source-detail` : c'est la seule
   information de date que ces fiches portent. */
.spectacle-run-period {
    margin: 0 0 0.4rem;
    font-size: 0.85rem;
    color: var(--text);
}

.spectacle-session-status {
    font-size: 0.7rem;
    color: var(--text-dim);
    white-space: nowrap;
}

.spectacle-session-status-alert {
    color: var(--danger);
}

/* Barrée plutôt que masquée : une date annulée reste une information, et la
   faire disparaître donnerait l'impression qu'elle n'a jamais existé. */
.spectacle-session-cancelled {
    text-decoration: line-through;
    opacity: 0.6;
}

/* La formule de billet, en intertitre au-dessus de ses créneaux plutôt que
   répétée sur chacun : une exposition en vend un toutes les demi-heures. */
.spectacle-offer-group {
    margin-top: 0.3rem;
}

.spectacle-session-offer {
    display: block;
    font-size: 0.7rem;
    font-weight: 600;
    letter-spacing: 0.02em;
    color: var(--text-dim);
    margin-bottom: 0.15rem;
}

/* Le reste des créneaux se compte, il ne se déroule pas. */
.spectacle-session-more {
    font-size: 0.7rem;
    color: var(--text-dim);
    align-self: center;
}

/* Les billetteries alternatives d'une séance déjà listée. Volontairement sans
   `.pill` : une pastille identique à celle de la séance se lisait comme une
   seconde séance nommée « Ticketmaster ». */
.spectacle-session-also {
    display: inline-flex;
    align-items: baseline;
    gap: 0.3rem;
    margin-left: 0.35rem;
    font-size: 0.68rem;
    color: var(--text-dim);
}

.spectacle-session-alt {
    color: var(--accent);
    text-decoration: underline;
    text-underline-offset: 2px;
}

/* Les mentions de source, en pied de la grille principale : elles doivent
   être lisibles sans jamais concurrencer le contenu, d'où la taille réduite
   et la couleur atténuée. `grid-column: 1 / -1` les met sur toute la largeur
   quel que soit le nombre de colonnes de la grille. */
.attribution {
    grid-column: 1 / -1;
    margin-top: 0.5rem;
    padding-top: 0.75rem;
    border-top: 1px solid var(--panel-border);
    color: var(--text-dim);
    font-size: 0.75rem;
    line-height: 1.5;
}

.attribution p {
    margin: 0;
}

.attribution a {
    color: inherit;
    text-decoration: none;
    border-bottom: 1px dotted currentColor;
}

.attribution a:hover {
    color: var(--text);
}

/* La formule imposée par TMDB, encore un cran en retrait : c'est une mention
   légale, pas une information que l'utilisateur cherche. */
.attribution-notice {
    margin-top: 0.25rem;
    opacity: 0.75;
}

/* Always in the DOM, shown only by the media query below. Declared *before*
   that query on purpose: both rules are a single class, so at equal
   specificity the later one wins, and putting this after would hide the
   button at every width. */
.rss-mobile-back {
    display: none;
}

/* ---- RSS on a narrow screen ----------------------------------------------

   tt-rss's own mobile shape: one pane at a time — feeds, then the article
   list, then the article — with a back button, instead of three columns
   squeezed into 380px. Everything here is inside the media query, so the
   desktop layout above is untouched: `.mobile-pane-*` and `.rss-mobile-back`
   are inert classes at any wider width.

   The `!important`s are load-bearing and only these. The three panes carry
   *inline* width/height styles, written by the drag-to-resize handles and
   restored from localStorage on load, and an inline style beats a stylesheet
   rule at equal specificity by definition. Dropping the inline styles on
   narrow screens instead would mean the component measuring the viewport and
   keeping that measurement in sync with this breakpoint — two sources of
   truth for one number. */
/* The sort control's wrapper. Inert on a desktop — the `select` renders as
   itself and the icon is not there at all; the whole arrangement only comes
   into play in the narrow-screen rules below. */
/* The narrow-screen bar (`components::rss::mobile_back_button`) and the
   metadata separator. `.rss-mobile-bar` is only laid out inside the
   narrow-screen block; here it is nothing, because `.rss-mobile-back` is
   itself hidden on a wide screen and the bar would otherwise reserve a line
   for it. */
.rss-mobile-bar {
    display: none;
}

.rss-article-meta {
    display: inline-flex;
    align-items: baseline;
    gap: 0.35rem;
}

/* Carried by the date rather than written into it, so hiding the feed name
   on a narrow screen takes the separator with it. */
.rss-article-date::before {
    content: "·";
    margin-right: 0.35rem;
}

/* Nothing before it to separate from. */
.rss-article-feed:empty + .rss-article-date::before {
    content: none;
}

.rss-sort-control {
    display: inline-flex;
    align-items: center;
    position: relative;
}

/* Sits above the list it describes, right-aligned in its own toolbar.
   Worded on a wide screen because there is room and "Vignettes"/"Liste" says
   in one glance what an icon pair only hints at; the label drops on a phone,
   where the row is narrow and the icon is the whole point. */
.rss-layout-toggle {
    display: inline-flex;
    align-items: center;
    gap: 0.3rem;
    flex: 0 0 auto;
    padding: 0.25rem 0.55rem;
    font-size: 0.85rem;
}

.rss-sort-icon {
    display: none;
}

@media (max-width: 900px) {
    .rss-page-body {
        flex-direction: column;
    }

    /* Each pane fills the screen when it is the one showing. */
    .rss-page-body > .rss-feed-sidebar,
    .rss-page-body > .rss-main-column {
        width: 100% !important;
        flex: 1 1 auto;
        min-height: 0;
    }

    .rss-article-list-panel {
        height: auto !important;
        flex: 1 1 auto;
        min-height: 0;
    }

    /* Dragging a divider needs a pointer to aim with and space to aim in;
       neither exists here, and the panes no longer sit side by side. */
    .rss-resize-handle-v,
    .rss-resize-handle-h {
        display: none;
    }

    .rss-mobile-back {
        display: inline-flex;
        align-items: center;
        gap: 0.25rem;
    }

    /* One pane at a time. The reading pane and the article list are siblings
       inside `.rss-main-column`, so switching between those two hides one of
       them rather than the column. */
    .rss-page-body.mobile-pane-feeds > .rss-main-column,
    .rss-page-body.mobile-pane-articles > .rss-feed-sidebar,
    .rss-page-body.mobile-pane-reading > .rss-feed-sidebar {
        display: none;
    }

    .rss-page-body.mobile-pane-articles .rss-reading-pane-container,
    .rss-page-body.mobile-pane-reading .rss-article-list-panel {
        display: none;
    }

    /* The row above the list is narrow here; the icon carries it, as it does
       for "Configurer" on the same screen. */
    .rss-layout-toggle-label {
        display: none;
    }

    /* Two columns on a phone, which is what a thumbnail grid is for: at one
       column the cards are just fat list rows, and at three the titles stop
       being readable. The desktop rule's `auto-fill` minimum would give one
       column at 390px, so it is overridden rather than inherited. */
    .rss-article-list.cards {
        grid-template-columns: repeat(2, minmax(0, 1fr));
        gap: 0.5rem;
    }

    /* The list-item reflow below is written in `order`, which a card must not
       inherit: it would put the metadata above the title and strand the
       buttons. Cards keep their own stacking on every screen — the layout is
       a deliberate choice here, not a consequence of the width. */
    .rss-article-list.cards .rss-article-row {
        flex-wrap: nowrap;
        min-height: 0;
        padding: 0;
        column-gap: 0;
        row-gap: 0;
    }

    .rss-article-list.cards .rss-article-thumb,
    .rss-article-list.cards .rss-article-title,
    .rss-article-list.cards .rss-article-meta,
    .rss-article-list.cards .rss-article-labels,
    .rss-article-list.cards .rss-article-row > button {
        order: 0;
    }

    .rss-article-list.cards .rss-article-title {
        flex: none;
    }

    /* The feed name comes back on a card: unlike a list row, a card in
       "Vidéos" gives no other clue which channel it came from. */
    .rss-article-list.cards .rss-article-feed {
        display: inline;
    }

    /* The row stops being a table line and becomes a list item, per user
       request 2026-08-29 ("on sent encore fortement qu'on est sur un
       tableau pour PC"). Explicit `order` rather than a grid: the thumbnail
       and the labels are both optional, and named grid areas for children
       that may not exist take more care than they save.

       Reading order becomes thumbnail, title, actions on the first line,
       then the metadata on its own underneath — which is the shape every
       native reader uses, and what lets the title have the width it needs. */
    .rss-article-row {
        align-items: center;
        flex-wrap: wrap;
        column-gap: 0.6rem;
        row-gap: 0.2rem;
        /* Comfortably past the 44px tap target, and what stops a list of
           short titles from reading as a dense grid. */
        min-height: 64px;
        padding: 0.7rem 0.85rem;
    }

    /* Selection is a long press here (see `LONG_PRESS_MS`), so the box is
       both redundant and the smallest tap target on the row. */
    .rss-article-row > input[type="checkbox"] {
        display: none;
    }

    .rss-article-thumb {
        order: 1;
    }

    .rss-article-title {
        order: 2;
        /* A short basis, not `auto`: a title measured at its max-content
           width breaks the line before the two action buttons can share
           it, which is how they ended up stranded on a row of their own. */
        flex: 1 1 6rem;
        min-width: 0;
        white-space: normal;
        display: -webkit-box;
        -webkit-line-clamp: 2;
        -webkit-box-orient: vertical;
        overflow: hidden;
    }

    /* Kept beside the title rather than pushed under it: they are the row's
       actions, and a thumb reaches the right edge more easily than the
       middle. */
    .rss-article-row > button {
        order: 3;
        flex: none;
    }

    /* Its own line, so the title above is never squeezed by a date. */
    .rss-article-meta {
        order: 4;
        flex: 1 0 100%;
        font-size: 0.78rem;
    }

    .rss-article-labels {
        order: 5;
        flex: 1 0 100%;
    }

    /* The feed name is on the bar above (see `.rss-mobile-title`); repeating
       it on every row of a list that is all one feed is noise. Its separator
       goes with it, which is why the date carries its own. */
    .rss-article-feed {
        display: none;
    }

    .rss-article-date::before {
        content: none;
        margin-right: 0;
    }

    /* The way back, and the name of what is open. */
    .rss-mobile-bar {
        display: flex;
        align-items: center;
        gap: 0.4rem;
        margin: 0 0.25rem 0.4rem;
        min-width: 0;
    }

    .rss-mobile-title {
        flex: 1 1 auto;
        min-width: 0;
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
        font-weight: 600;
        font-size: 0.95rem;
    }

    .rss-mobile-back {
        margin: 0;
        flex: none;
    }

    /* Wraps instead of overflowing once there are more than two or three
       selected-article actions on one line. */
    .rss-bulk-bar {
        flex-wrap: wrap;
    }

    .rss-page-header h1 {
        font-size: 1.1rem;
    }

    /* The controls used to drop to their own full-width line, because
       "Configurer" and a sort dropdown could not fit beside the title. Both
       are icons here now (see below), which buys back enough room to put the
       whole header on one line — per user request 2026-08-29. */
    .rss-page-header {
        flex-wrap: wrap;
    }

    .rss-header-actions {
        /* A short basis, not `auto`. Flex decides where to break a line from
           the *base* size, and `auto` means this group's max-content width —
           a search input at its intrinsic size plus two icons — which
           overflowed the row and pushed the whole group onto a line of its
           own however much it could have shrunk afterwards. */
        flex: 1 1 6rem;
        justify-content: flex-end;
        min-width: 0;
    }

    /* The label goes, the icon stays. The button keeps its `title`, so what
       it does is still reachable by long-press and by a screen reader. */
    .rss-header-label {
        display: none;
    }

    /* The sort control becomes a square icon button — but a *real* `select`
       underneath, laid over the icon at zero opacity, so tapping it still
       opens the platform's own picker rather than something rebuilt here.
       `appearance: none` strips the native chrome that would otherwise
       reserve room for a dropdown arrow nobody can see. */
    .rss-sort-icon {
        display: inline-flex;
        align-items: center;
        pointer-events: none;
        color: var(--text-dim);
    }

    .rss-header-sort {
        position: absolute;
        inset: 0;
        width: 100%;
        height: 100%;
        padding: 0;
        margin: 0;
        border: none;
        opacity: 0;
        appearance: none;
        -webkit-appearance: none;
    }

    /* Room for a thumb, matching the other icon buttons in this header. */
    .rss-sort-control {
        width: 2rem;
        height: 2rem;
        justify-content: center;
        flex: none;
    }

    /* The one elastic thing in the row. A search input carries a wide
       intrinsic size (roughly twenty characters), which is what pushed the
       group onto a line of its own; a small basis plus `min-width: 0` lets
       it give way to the title and the two icons instead. */
    .rss-header-search,
    .rss-header-search:focus,
    .rss-header-search:not(:placeholder-shown) {
        flex: 1 1 4rem;
        width: auto;
        min-width: 0;
    }

    /* The title yields before the controls do — it is the one thing here
       that can be read from context. */
    .rss-page-header h1 {
        min-width: 0;
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
    }
}

/* ---- Mobile menu and section pages (`components::mobile_menu`) ----
   Only ever rendered on a narrow viewport or at a `/section/…` URL, so
   nothing here can reach the desktop dashboard. Sizes are set for a thumb:
   rows clear the 44px tap target every mobile guideline asks for, and the
   whole menu is one column so there is no horizontal decision to make. */
.mobile-menu {
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
    padding: 0.75rem;
    min-height: 100vh;
}

.mobile-menu-title {
    margin: 0.25rem 0 0.5rem;
    font-size: 1.3rem;
}

.mobile-menu-row {
    display: flex;
    align-items: center;
    gap: 0.7rem;
    min-height: 52px;
    padding: 0.6rem 0.9rem;
    background: var(--panel);
    border: 1px solid var(--panel-border);
    border-radius: var(--radius);
    color: var(--text);
    text-decoration: none;
}

/* No hover state: this is a touch surface, and a sticky hover left behind
   after a tap reads as a selection that isn't there. */
.mobile-menu-row:active {
    border-color: var(--accent);
}

/* The notifications row is a `<button>` where every other is an `<a>` (it
   opens an overlay, not a route), and a button brings its own font, width
   and alignment. */
button.mobile-menu-row {
    width: 100%;
    font: inherit;
    text-align: left;
    cursor: pointer;
}

.mobile-menu-row-icon {
    display: flex;
    align-items: center;
    color: var(--text-dim);
}

/* Takes the slack so the badge sits hard against the right edge. */
.mobile-menu-row-label {
    flex: 1;
    min-width: 0;
}

.mobile-menu-row-badge {
    flex: none;
    padding: 0.1rem 0.5rem;
    border-radius: 999px;
    background: var(--accent);
    color: #ffffff;
    font-size: 0.8rem;
}

/* Empty when the count hasn't loaded yet — without this the pill collapses
   to a bare dot and then pops to full width, which reads as a glitch. */
.mobile-menu-row-badge:empty {
    display: none;
}

.section-page {
    display: flex;
    flex-direction: column;
    gap: 0.6rem;
    padding: 0.6rem;
    min-height: 100vh;
}

/* The widget inside is the grid's own component, which expects to be one
   cell of a grid; here it is the whole page and should use it. */
.section-page-body > .widget {
    height: 100%;
}

/* Edge to edge, unlike everywhere else. `SectionPage` opens this panel on
   mount and closing it navigates back to the menu, so here the panel is not
   an overlay *on* a page — it is the page, and the 1rem ring the backdrop
   keeps elsewhere would frame nothing but the widget it was opened from,
   which on this page is a mount point nobody is meant to see. (It showed:
   the page pads 0.6rem against the backdrop's 1rem, so the card's border
   peeked out around the panel through the translucent margin.) */
.section-page .fullscreen-backdrop {
    padding: 0;
}

.section-page .fullscreen-panel {
    border: none;
    border-radius: 0;
}

/* ---- Swipe gestures (`crate::swipe`) ----
   `touch-action: pan-y` is load-bearing, not decoration: without it the
   browser claims horizontal movement for its own scrolling and the
   `pointermove` handler never sees the gesture. Vertical scrolling is left
   to the browser, which is why the list still scrolls normally.

   The transition is on the element at rest and removed while a finger is
   down (`.swiping`), so the drag tracks the finger exactly and only the
   spring back is animated. */
.rss-article-row,
.rss-reading-pane {
    touch-action: pan-y;
    transition: transform 0.18s ease-out, background-color 0.18s ease-out;
}

.rss-article-row.swiping,
.rss-reading-pane.swiping {
    transition: none;
}

/* Past the commit distance: letting go now marks it read. A colour change
   rather than a revealed icon, because the row is what moves — anything
   drawn behind it would need a wrapper element, and that would restructure
   a row the desktop layout depends on. */
.rss-article-row.swipe-armed {
    background-color: rgba(61, 220, 132, 0.22);
}

/* ---- Remote article bodies ----
   Both of these are `inner_html`: the publisher's own markup, dropped in as
   it came. It carries whatever width its author assumed — an `<img
   width="1200">` is routine — and on a phone that pushed the whole column
   sideways, so the article scrolled horizontally and the text ran off the
   screen. The app's own lead images (`.rss-reading-pane-image`,
   `.read-later-reading-image`) were already capped; this is everything
   underneath them that the app does not render itself.

   Scoped to the two injected containers rather than set globally: this is a
   statement about untrusted markup, not about how the app lays itself out. */
.rss-reading-pane-content img,
.rss-reading-pane-content video,
.read-later-reading-content img,
.read-later-reading-content video {
    max-width: 100%;
    /* Overrides a `height` attribute that would otherwise stretch the image
       once its width has been capped. */
    height: auto;
}

/* No `height: auto` here — an embed's height attribute is what gives it a
   shape, and dropping it collapses the player to nothing. Capping the width
   is what keeps it on screen. */
.rss-reading-pane-content iframe,
.read-later-reading-content iframe {
    max-width: 100%;
}

/* Code blocks and tables cannot be reflowed, so they scroll within
   themselves rather than widening the page around them. */
.rss-reading-pane-content pre,
.rss-reading-pane-content table,
.read-later-reading-content pre,
.read-later-reading-content table {
    display: block;
    max-width: 100%;
    overflow-x: auto;
}

/* A bare URL in a paragraph is one long unbreakable word, and the other
   common way these bodies overflow. */
.rss-reading-pane-content,
.read-later-reading-content {
    overflow-wrap: anywhere;
}

/* ---- The audio app (`components::player::page`) ----
   The player's two sections as a page of their own at `/audio`, installable
   separately (see `crate::pwa`). Built on `.fullscreen-panel` and flattened
   the same way `.rss-page` is — same box overrides, same "pin to the
   viewport and scroll inside" arrangement — because it is literally the
   panel's contents with the panel taken away. */
.audio-page {
    width: 100%;
    /* The shell above owns the viewport; this takes what is left.
       `min-height: 0` is what lets it be *smaller* than its content,
       which is the precondition for its own body scrolling instead
       of the document. */
    flex: 1 1 auto;
    min-height: 0;
    max-width: none;
    margin: 0;
    padding: 0.6rem;
    background: none;
    border: none;
    border-radius: 0;
    box-sizing: border-box;
    display: flex;
    flex-direction: column;
    overflow: hidden;
}

.audio-page-header {
    display: flex;
    align-items: center;
    gap: 0.75rem;
    margin-bottom: 0.6rem;
    flex: none;
}

/* The tab pair takes the space, so the two halves of the app read as the
   page's title rather than as a control tucked beside one. */
.audio-section-tabs {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    flex: 1 1 auto;
    min-width: 0;
}

/* `.audio-page-header .media-section-tab` (two classes), not the bare class
   — the same specificity trap `.widget .media-section-tab` documents above:
   the shared rule is written for the widget and the fullscreen header, and
   an unscoped override here would lose to it. */
.audio-page-header .media-section-tab {
    font-size: 1.2rem;
}

/* Everything below the header scrolls as one column. Unlike the RSS page
   there are no independent panes to keep in step — a list of episodes is one
   thing, read top to bottom. */
.audio-page-body {
    flex: 1 1 auto;
    min-height: 0;
    overflow-y: auto;
}

/* Said once, in place of a screen of controls that cannot work — see
   `AudioPage`'s radio branch and the "Hors ligne" tab's own banner. */
.audio-offline-notice {
    padding: 0.75rem;
    background: color-mix(in srgb, var(--danger) 10%, var(--panel) 90%);
    border: 1px solid color-mix(in srgb, var(--danger) 35%, var(--panel-border) 65%);
    border-radius: var(--radius);
    color: var(--text);
}

/* The page owns the scrolling, so the lists inside it must not also cap
   themselves — two nested scrollers on a phone is the arrangement where a
   flick moves the wrong one. */
.audio-page .podcast-episode-list {
    max-height: none;
    overflow-y: visible;
}

/* ---- Downloading an episode (`components::podcasts::offline`) ---- */

.podcast-download-control {
    display: inline-flex;
    align-items: center;
    gap: 0.35rem;
    flex: 0 0 auto;
}

/* Already on the device. An outlined tick in the same weight as the download
   arrow beside it read as another action rather than as a *state* (reported
   2026-08-29): `IconDownloaded` is now a solid disc with the tick knocked
   out of it, which is the one shape in this row that is filled. */
.podcast-downloaded {
    color: var(--accent);
}

.podcast-downloaded:hover {
    color: color-mix(in srgb, var(--accent) 75%, var(--danger) 25%);
}

/* First press of a delete. Turning the disc red and swapping it for a bin is
   the confirmation — see `EpisodeDownloadButton`. */
.podcast-downloaded.confirming {
    color: var(--danger);
    background: color-mix(in srgb, var(--danger) 18%, transparent);
    border-radius: 999px;
}

/* Occupies the button's own footprint while a download runs, so the row
   neither grows nor shrinks under a thumb that is still on it. */
.podcast-download-progress {
    display: inline-block;
    width: 2rem;
    height: 0.35rem;
    border-radius: 999px;
    background: var(--panel-border);
    overflow: hidden;
}

.podcast-download-progress-fill {
    display: block;
    height: 100%;
    background: var(--accent);
    border-radius: inherit;
    transition: width 0.2s ease;
}

/* No `Content-Length`, so there is no fraction to show. A sweep says "this
   is running" without claiming to know how far along it is. */
.podcast-download-progress-fill.indeterminate {
    animation: podcast-download-sweep 1.1s ease-in-out infinite;
}

@keyframes podcast-download-sweep {
    0% {
        transform: translateX(-100%);
    }
    100% {
        transform: translateX(100%);
    }
}

.podcast-download-error {
    font-size: 0.75rem;
}

/* ---- A download in flight, in an episode row ---- */

.podcast-download-pending {
    display: inline-flex;
    align-items: center;
    gap: 0.35rem;
}

/* The figure the bar cannot give: a bar that moves slowly and a bar that has
   stopped look identical, which was the complaint this answers (user report
   2026-08-29). Tabular figures so it does not jitter as the percentage
   climbs. */
.podcast-download-figure {
    font-size: 0.7rem;
    color: var(--text-dim);
    white-space: nowrap;
    font-variant-numeric: tabular-nums;
}

.podcast-download-cancel {
    color: var(--text-dim);
}

.podcast-download-cancel:hover {
    color: var(--danger);
}

/* Waiting its turn: the bar is full-width but flat, which reads as "nothing
   is moving here yet" rather than as a completed download. */
.podcast-download-progress-fill.queued {
    background: var(--text-dim);
    opacity: 0.5;
}

.podcast-download-progress-fill.failed {
    background: var(--danger);
}

/* ---- The queue, listed in the "Hors ligne" tab ---- */

/* Per user request 2026-08-29: an episode being fetched is listed with the
   ones already fetched, above them, because this is the screen somebody
   opens to find out what is happening. */
.podcast-download-queue {
    list-style: none;
    margin: 0 0 1rem;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
}

.podcast-download-queue-row {
    display: flex;
    align-items: center;
    gap: 0.6rem;
    padding: 0.5rem 0.6rem;
    background: var(--panel);
    border: 1px solid var(--panel-border);
    border-radius: var(--radius);
}

.podcast-download-queue-row.failed {
    border-color: color-mix(in srgb, var(--danger) 40%, var(--panel-border) 60%);
}

.podcast-download-queue-text {
    flex: 1 1 auto;
    min-width: 0;
    display: flex;
    flex-direction: column;
    gap: 0.1rem;
}

.podcast-download-queue-title,
.podcast-download-queue-show,
.podcast-download-queue-state {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.podcast-download-queue-title {
    font-size: 0.85rem;
    font-weight: 600;
}

.podcast-download-queue-show {
    font-size: 0.72rem;
    color: var(--text-dim);
}

.podcast-download-queue-state {
    font-size: 0.72rem;
    color: var(--text-dim);
    font-variant-numeric: tabular-nums;
}

.podcast-download-queue-row.failed .podcast-download-queue-state {
    color: var(--danger);
}

/* Wider than the one in a row: there is room here, and this is the copy
   somebody is actually watching. */
.podcast-download-progress.wide {
    width: 5rem;
    height: 0.4rem;
    flex: 0 0 auto;
}

/* An episode with no audio on the device, listed while there is no network.
   Dimmed rather than hidden: it is still worth knowing the episode exists
   and could be downloaded once there is a connection again. */
.podcast-episode-row.unavailable {
    opacity: 0.55;
}

.podcast-offline-size {
    font-size: 0.75rem;
    color: var(--text-dim);
    flex: 0 0 auto;
    white-space: nowrap;
}

.podcast-offline-clear {
    margin-top: 0.75rem;
}

@media (max-width: 900px) {
    /* A phone has hardware volume keys, and this control is a fiddly target
       that costs a now-playing card most of its width. The signal it carries
       is still there — the OS's own volume overlay. */
    .audio-page .radio-volume,
    .audio-page .radio-volume-overlay {
        display: none;
    }

    /* Six tabs do not fit across a phone, and wrapping them onto three rows
       pushes the list itself off the fold. One scrolling row instead, which
       is what every native player does with the same problem. */
    .podcast-view-tabs {
        flex-wrap: nowrap;
        overflow-x: auto;
        scrollbar-width: none;
        /* Bleeds to the page edges, so a half-visible tab at the end reads
           as "there is more this way" rather than as a layout mistake. */
        margin-inline: -0.6rem;
        padding-inline: 0.6rem;
    }

    .podcast-view-tabs::-webkit-scrollbar {
        display: none;
    }

    .podcast-view-tabs button {
        flex: 0 0 auto;
        white-space: nowrap;
    }

    /* Two tabs carry an icon; those are the two whose label can go, since
       the glyph is the thing being scanned for. */
    .podcast-view-tabs button:has(svg) .podcast-view-tab-label {
        display: none;
    }

    /* The episode row stops being one line of columns. The play button and
       the title stay together on the first line; the metadata drops beneath
       it; the download and playlist controls sit at the end where a thumb
       reaches them. Same treatment, and the same reason, as
       `.rss-article-row` above. */
    .podcast-episode-row {
        flex-wrap: wrap;
        row-gap: 0.35rem;
        padding: 0.6rem;
    }

    /* A title is what the row is *for*, so it gets the two lines it usually
       needs instead of being cut off after four words. */
    .podcast-episode-row-title {
        white-space: normal;
        display: -webkit-box;
        -webkit-line-clamp: 2;
        -webkit-box-orient: vertical;
    }

    /* Tiles sized for a thumb rather than for a mouse: two per row on a
       phone, which is what makes the cover big enough to recognise a show
       by.

       `display: grid` is the half that was missing until 2026-08-30, and
       without it the rest of this did nothing: the base rule lays the grid
       out with `display: flex`, and `grid-template-columns` on a flex
       container is simply ignored. What a phone actually got was the
       desktop layout — fixed 130px tiles, wrapped and packed left — which
       fits two per row with most of a third column's worth of dead space
       left over on the right, exactly as reported. */
    .podcast-tile-grid {
        display: grid;
        grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
    }

    /* And the fixed width has to go with it. A grid item only stretches to
       fill its column while its width is `auto`; left at 130px each tile
       would sit at the left edge of a column half again as wide, turning
       one gap on the right into a gap beside every tile. */
    .podcast-tile-grid .podcast-tile {
        width: auto;
    }

    /* The docked player tightens up: the scrubber already has a line of its
       own, so this is only about giving the titles room. */
    .radio-player.docked {
        padding: 0.5rem;
    }

    .audio-page .podcast-seek {
        flex: 1 1 6rem;
        min-width: 0;
    }

    /* Shrinks to whatever the controls leave, rather than reserving a width
       that pushes them onto a line of their own. The title is clamped to two
       lines above, so there is a floor on how little this can usefully be. */
    .podcast-episode-row-info {
        flex: 1 1 0;
        min-width: 0;
    }

    .audio-page-header {
        gap: 0.4rem;
    }

    .audio-page-header .media-section-tab {
        font-size: 1.05rem;
    }
}

/* A download button that cannot work, because the worker controlling this
   page predates the feature — see `podcast_downloads::Downloads`. Dimmed
   rather than hidden: the row should still say that downloading is a thing
   this app does, and the title says how to get it back. */
.podcast-download-stale {
    opacity: 0.4;
    cursor: not-allowed;
}

/* Which mechanism is carrying the queue — see `podcast_downloads::DownloadVia`.
   Under the list, not on each row: it describes the device, not the episode. */
.podcast-download-mode {
    margin-top: -0.5rem;
}

/* The gear that opens the podcasts section's settings. Sits in the tab row
   but is not a tab: pushed to the far end and drawn dimmer, so scanning the
   row reads six destinations and one tool rather than seven of anything. */
.podcast-config-button {
    margin-left: auto;
    flex: 0 0 auto;
    opacity: 0.6;
}

.podcast-config-button:hover {
    opacity: 1;
}

/* Play, download and free, for a whole playlist. A row of labelled buttons
   rather than icons: each one acts on every episode below it, and the two
   download ones carry a count that has to be readable before pressing. */
.podcast-playlist-actions {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.5rem;
    margin-bottom: 0.75rem;
}

.podcast-playlist-actions button {
    display: inline-flex;
    align-items: center;
    gap: 0.35rem;
}

.podcast-playlist-actions button:disabled {
    opacity: 0.45;
    cursor: default;
}

/* The playlist the player is working through. Same accent the playing row
   uses, so "this is what is running" looks the same everywhere. */
.pill.playing {
    color: var(--accent);
    border-color: var(--accent);
}

/* An episode this device downloaded by itself and will free once it is
   finished (`crate::podcast_auto_download`) — distinguished from one
   downloaded by hand, which stays until deleted. */
.podcast-offline-auto {
    flex: 0 0 auto;
}

/* --- Automatic downloading, in the config modal --- */

.podcast-autodl-toggle {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    font-weight: 500;
}

.podcast-autodl-limit,
.podcast-autodl-conditions {
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
    margin-top: 0.75rem;
}

.podcast-autodl-conditions {
    flex-direction: row;
    flex-wrap: wrap;
    gap: 1rem;
}

.podcast-autodl-conditions label {
    display: inline-flex;
    align-items: center;
    gap: 0.4rem;
}

/* Dimmed, not hidden, while automatic downloading is off: the settings are
   what somebody reads to decide whether to switch it on at all. */
.podcast-autodl-limit.disabled,
.podcast-autodl-conditions.disabled {
    opacity: 0.5;
}

.podcast-autodl-limit-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.75rem;
}

.podcast-autodl-limit-row label {
    display: inline-flex;
    align-items: center;
    gap: 0.4rem;
}

.podcast-autodl-limit-row input[type="number"] {
    width: 5.5rem;
    flex: 0 0 auto;
}

/* The one-line "what this device is holding" under the default playlist. */
.podcast-autodl-summary {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 0.5rem;
    margin-bottom: 0.75rem;
}

/* --- The compact player, in a narrow column and over a fullscreen panel --- */

/* Everything on the transport line has a floor: 28px of cover art, two or
   three icon buttons, a 90px volume slider and the expand button. In the
   dashboard's sidebar that no longer fits once a thumbnail is showing, and
   the overflow ran straight out of the panel (reported 2026-08-30).

   Three rules, in the order the browser should try them: the title column
   gives up its width first (it already has `min-width: 0`), then the volume
   slider shrinks, and only then does the line wrap. A button never shrinks —
   a half-width play button is worse than a second row. */
.radio-player-main {
    flex-wrap: wrap;
}

.radio-player-main > button,
.radio-player-main > .radio-volume > svg {
    flex: 0 0 auto;
}

.radio-player:not(.docked) .radio-volume {
    flex: 0 1 auto;
    min-width: 0;
}

.radio-player:not(.docked) .radio-volume input[type="range"] {
    flex: 0 1 90px;
    width: auto;
    min-width: 40px;
}

/* The strip while this widget's fullscreen panel is open — see
   `bar::CompactPlayerBar`'s own comment for why this is a class on the same
   element rather than a second player inside the panel.

   Aligned with `.fullscreen-panel`: same 1rem inset from the backdrop, same
   1800px cap, so it reads as the panel's own footer rather than as something
   floating in front of it. `z-index` sits between the backdrop (200) and the
   modals that can open above it (300) — a playlist dialog must still cover
   this. */
.radio-player.over-fullscreen {
    position: fixed;
    left: 50%;
    transform: translateX(-50%);
    bottom: calc(1rem + env(safe-area-inset-bottom, 0px));
    width: min(calc(100% - 2rem), 1800px);
    z-index: 250;
    box-shadow: 0 10px 34px rgba(0, 0, 0, 0.45);
}

/* Room for it at the end of the panel's own scroll, so the last row of a
   list is not permanently parked underneath the player. The strip is a
   preceding sibling of the backdrop (`PlayerWidget` renders it first), which
   is what makes this selectable at all. */
.radio-player.over-fullscreen ~ .fullscreen-backdrop .fullscreen-body {
    padding-bottom: 5.5rem;
}

/* A video episode, marked in the list — per user request 2026-08-30. Dimmer
   than the title it sits beside: it qualifies the row, it is not the row. */
.podcast-episode-row-video {
    color: var(--text-dim);
    margin-right: 0.3rem;
}

/* ---- Lectures: the desktop bits the phone rules below build on ----------- */

/* The narrow-screen bar (`components::read_later`'s own, mirroring
   `.rss-mobile-bar`). Nothing at all on a wide screen, where the sidebar row
   that is lit up already says which collection is open — laid out only inside
   the media query, or it would reserve an empty line here. */
.read-later-mobile-bar {
    display: none;
}

/* "Télécharger (40)" / "12/40 hors ligne", above the grid. */
.read-later-download {
    display: inline-flex;
    align-items: center;
    gap: 0.15rem;
    flex: none;
    /* Against the right edge whether or not a collection name precedes it —
       the inbox and the offline shelf have no `<h2>` in this row. */
    margin-left: auto;
}

.read-later-download:disabled {
    opacity: 0.7;
}

/* The per-card offline toggle: a download arrow when the article is not on
   the device, a filled check when it is (per user request 2026-09-01 —
   keeping one article, rather than a whole collection at a time).

   Only the stored state is accent-coloured, and the difference carries the
   meaning: a check in the accent colour is a *state* the card is in, while
   the arrow is an action like the "Marquer lu" and "Modifier" beside it and
   reads as plain text the way they do.

   Selected through its container and both its classes, not on
   `.read-later-card-offline` alone — the same specificity trap already
   documented for `.read-later-sidebar button.ghost.read-later-nav-item`:
   `.fullscreen-panel button` would otherwise win and paint it grey. */
.read-later-card-actions button.ghost.read-later-card-offline {
    display: inline-flex;
    align-items: center;
    padding: 0.2rem 0.35rem;
}

.read-later-card-actions button.ghost.read-later-card-offline.stored {
    color: var(--accent);
}

/* The same toggle in the reader's toolbar, where there is room for a word
   beside the icon — "Garder" / "Hors ligne". */
.read-later-reading-toolbar button.ghost.read-later-reading-offline {
    display: inline-flex;
    align-items: center;
    gap: 0.2rem;
}

.read-later-reading-toolbar button.ghost.read-later-reading-offline.stored {
    color: var(--accent);
}

/* ---- Lectures on a narrow screen ----------------------------------------

   Two problems, reported 2026-08-31: "le menu latéral occupe l'essentiel de
   l'écran de listing qui est donc difficilement utilisable", and the reading
   view "devrait être full-screen et se concentrer sur le fait d'offrir une
   expérience de lecture agréable sans trop de distractions".

   The first is the shape the RSS reader already answers and this answers it
   the same way: one pane at a time — collections, then the articles in one —
   with a bar to get back. `.mobile-pane-*` is inert at any wider width, which
   is why the desktop layout above needs no `min-width` counterpart.

   The second is the `.reading` block at the end. Everything the page draws
   around an article is chrome, and on a 390px screen the chrome was most of
   it; opening an article now takes the page header, the sidebar and the card
   frame away and leaves the text. */
@media (max-width: 900px) {
    .read-later-page {
        padding: 0.5rem;
    }

    /* The two panes stack rather than sitting side by side, and whichever is
       showing takes the screen. */
    .read-later-page-body {
        flex-direction: column;
    }

    .read-later-page-body > .read-later-sidebar,
    .read-later-page-body > .read-later-main {
        flex: 1 1 auto;
        width: 100%;
        min-height: 0;
    }

    /* One pane at a time. The reading view is not a third pane here — it
       replaces the body's contents outright, on a phone as on a desktop —
       so this only ever has two to choose between. */
    .read-later-page.mobile-pane-collections .read-later-main,
    .read-later-page.mobile-pane-list .read-later-sidebar,
    .read-later-page.mobile-pane-reading .read-later-sidebar {
        display: none;
    }

    .read-later-mobile-bar {
        display: flex;
        align-items: center;
        gap: 0.4rem;
        margin: 0 0.15rem 0.4rem;
        min-width: 0;
        flex: none;
    }

    .read-later-mobile-back {
        margin: 0;
        flex: none;
        display: inline-flex;
        align-items: center;
        gap: 0.25rem;
    }

    .read-later-mobile-title {
        flex: 1 1 auto;
        min-width: 0;
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
        font-weight: 600;
        font-size: 0.95rem;
    }

    /* One column. The desktop `auto-fill, minmax(260px, …)` already gives one
       at this width, but it does so by accident of arithmetic; saying it
       means the gap and the card shape below can be chosen for a phone. */
    .read-later-grid {
        grid-template-columns: 1fr;
        gap: 0.5rem;
    }

    /* A row, not a card: the image beside the text rather than above it, so
       a screenful is five or six articles instead of two. Same reasoning as
       `.rss-article-row`'s own reflow, and the same 64px floor for the tap
       target. */
    .read-later-card-body {
        flex-direction: row;
        align-items: flex-start;
        gap: 0.6rem;
        min-height: 64px;
    }

    .read-later-card-image {
        order: 2;
        flex: none;
        width: 88px;
        height: 88px;
        object-fit: cover;
    }

    .read-later-card-text {
        order: 1;
        flex: 1 1 auto;
        min-width: 0;
        /* No right padding: the thumbnail is now what fills that edge, and
           the gap between them is the flex `gap` above. */
        padding: 0.65rem 0 0.65rem 0.75rem;
    }

    /* Two lines, then an ellipsis — a five-line title pushes everything
       under it off the fold. */
    .read-later-card-text h3 {
        display: -webkit-box;
        -webkit-line-clamp: 2;
        -webkit-box-orient: vertical;
        overflow: hidden;
    }

    /* The collection's name is on the bar directly above (see
       `.read-later-mobile-title`); printing it again as an `<h2>` on the
       next line spends a whole row saying it twice. Same reasoning as
       `.rss-article-feed`, hidden at this width for the same reason. */
    .read-later-collection-header h2 {
        display: none;
    }

    .read-later-collection-header {
        padding: 0.2rem 0.15rem;
        margin-bottom: 0.35rem;
        gap: 0.4rem;
    }

    /* Half a row rather than a whole one. Three articles filled the screen
       when each card ended in a full-height button bar; these are secondary
       actions on a list you are scanning. */
    .read-later-card-actions {
        padding: 0.3rem 0.5rem;
        font-size: 0.85rem;
    }

    .read-later-card-actions button {
        padding: 0.25rem 0.5rem;
    }

    /* The excerpt is the first thing to go: on a phone the title and the
       site are what pick an article out of a list, and three lines of body
       text per row is what turned the list into a wall. */
    .read-later-card-excerpt {
        display: none;
    }

    /* The sidebar is the whole screen when it is showing, so it has no
       reason to be a panel inside one. */
    .read-later-sidebar {
        border: none;
        background: none;
        padding: 0.25rem;
    }

    /* Room for a thumb. The desktop rows are a mouse's height. */
    .read-later-sidebar button.ghost.read-later-nav-item {
        min-height: 44px;
    }

    /* ---- The reader ---------------------------------------------------- */

    /* The page header goes: "Lectures", "+ Ajouter" and a second back button
       are three pieces of chrome for a screen whose whole job is one column
       of text. The mobile bar stays — it is the way back, and it is one
       line. */
    .read-later-page.mobile-pane-reading .read-later-page-header {
        display: none;
    }

    .read-later-page.mobile-pane-reading {
        padding: 0;
    }

    /* `min-height` keeps the row's height even though the toolbar beside it
       is out of flow — without it the article would start under the buttons
       floating over it. */
    .read-later-page.mobile-pane-reading .read-later-mobile-bar {
        margin: 0.4rem 0.5rem 0.1rem;
        min-height: 2.1rem;
    }

    /* The toolbar shares the back bar's line instead of taking a second row
       under it (per user request 2026-09-01, "le bouton « modifier » devrait
       être sur la même ligne que le bouton de retour au listing"). On a
       screen whose whole job is one column of text, two rows of chrome above
       it was one too many.

       Lifted out of flow rather than pulled up with a negative margin, which
       was tried and failed: `.read-later-reading` is the scroll container,
       and in-flow content pushed above its origin is clipped — the
       "Modifier" button vanished outright. Absolute positioning escapes that
       clip because its containing block is `.read-later-page` (the nearest
       positioned ancestor, set below), which is *outside* the scroller — an
       overflow only clips descendants whose containing block is inside it.
       Being out of flow is also what keeps it pinned while the article
       scrolls under the bar.

       `left` is the back button's width plus its gap, so a long "Vue
       recette"/"Vue normale" pair wraps rather than landing on top of it. */
    .read-later-page.mobile-pane-reading {
        position: relative;
    }

    .read-later-page.mobile-pane-reading .read-later-reading-toolbar {
        position: absolute;
        top: 0.4rem;
        left: 9rem;
        right: 0.5rem;
        z-index: 2;
        width: auto;
        max-width: none;
        min-height: 2.1rem;
        margin: 0;
        padding: 0;
        gap: 0.35rem;
    }

    .read-later-page.mobile-pane-reading .read-later-reading-toolbar button {
        padding: 0.25rem 0.6rem;
        font-size: 0.85rem;
    }

    /* And the article starts straight after that one shared row. */
    .read-later-page.mobile-pane-reading .read-later-reading-article {
        padding-top: 0.2rem;
    }

    /* The title links to the original page, and as a plain `<a>` it drew as
       a block of underlined accent-blue — the single loudest thing on a
       screen whose whole job is to be quiet. It keeps the link (that is how
       you reach the source) and gives up the decoration. Deliberately only
       here: the desktop reader was explicitly left as it is. */
    .read-later-reading-article h1 a {
        color: inherit;
        text-decoration: none;
    }

    /* Edge to edge. The article had `padding: 1rem` on the scroller and
       `padding: 2rem` inside the card frame, which on a 390px screen left
       about 250px for the text itself. */
    .read-later-reading {
        padding: 0;
    }

    .read-later-reading-article {
        max-width: 100%;
        border: none;
        border-radius: 0;
        background: none;
        padding: 0 1rem calc(2rem + env(safe-area-inset-bottom, 0px));
    }

    .read-later-reading-toolbar {
        max-width: 100%;
        padding: 0 1rem;
        margin-bottom: 0.6rem;
    }

    /* Bigger and looser than the desktop's 1.05rem/1.7, which is the one
       place a phone should have *more* than a monitor: the column is 358px
       wide, so a comfortable 60-75 characters per line means larger type,
       not smaller. */
    .read-later-reading-content {
        margin-top: 1.1rem;
        font-size: 1.12rem;
        line-height: 1.75;
    }

    .read-later-reading-article h1 {
        font-size: 1.45rem;
    }

    /* Full-bleed, so a lead photo is not a postage stamp inset in a column
       that has no margins left to give it. */
    .read-later-reading-image {
        margin-inline: -1rem;
        width: calc(100% + 2rem);
        max-width: none;
        max-height: 240px;
        object-fit: cover;
        border-radius: 0;
    }

    /* The two recipe columns already wrap to one at this width (they are
       `flex-wrap` with 240px/360px bases); what does not follow is the
       2rem gap between them, which becomes 2rem of blank screen between
       the ingredients and the steps. */
    .read-later-recipe-columns {
        gap: 0.75rem;
    }
}

/* ==========================================================================
   Médiathèque (per user request 2026-09-01)
   ==========================================================================

   The third audio section. It borrows the podcasts' tab row
   (`.podcast-view-tabs`) and their whole download-queue block, because those
   are the same widgets doing the same job one tab away — what is below is
   only what a *music* list needs and an episode list does not: a row dense
   enough to fit a few hundred of them, a cover thumbnail, and the upload
   target that has no equivalent anywhere else in this app. */

.library-section {
    display: flex;
    flex-direction: column;
    gap: 0.75rem;
}

/* ---- The upload target ---- */

/* A dashed border rather than a solid one: this is a drop zone, and the
   convention is worth more than any label at saying so. */
.library-upload {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.5rem 0.75rem;
    padding: 0.75rem;
    border: 1px dashed var(--panel-border);
    border-radius: var(--radius);
    transition: border-color 0.15s ease, background 0.15s ease;
}

/* While something is being dragged over it. The whole strip lights up, not
   just its border — on a phone-sized panel a 1px colour change is invisible,
   and this is the only feedback that the drop will land somewhere. */
.library-upload.dragging {
    border-color: var(--accent);
    background: color-mix(in srgb, var(--accent) 8%, transparent);
}

.library-upload-button {
    flex: 0 0 auto;
}

.library-upload-hint {
    font-size: 0.75rem;
    color: var(--text-dim);
    flex: 1 1 12rem;
    min-width: 0;
}

/* Both take the whole width so a batch's outcome never shares a line with
   the button that started it. */
.library-upload-status,
.library-upload-failures {
    flex: 1 0 100%;
    margin: 0;
    font-size: 0.78rem;
    color: var(--text-dim);
    font-variant-numeric: tabular-nums;
}

.library-upload-failures {
    list-style: none;
    padding: 0;
    color: var(--danger);
    display: flex;
    flex-direction: column;
    gap: 0.15rem;
}

/* ---- Search, and the queue's two toggles ---- */

.library-toolbar {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    flex-wrap: wrap;
}

.library-search {
    flex: 1 1 12rem;
    min-width: 0;
}

.library-queue-controls {
    display: flex;
    gap: 0.35rem;
    flex: 0 0 auto;
}

/* `.ghost.active` is already the tab row's convention for "this one is on",
   and shuffle/repeat are exactly that kind of state. */
.library-queue-controls .ghost.active,
.library-playlist-picker .ghost.active {
    color: var(--accent);
    border-color: var(--accent);
}

/* ---- The list ---- */

.library-list-header,
.library-detail-header {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    font-size: 0.78rem;
    color: var(--text-dim);
}

/* Pushes the trailing buttons to the right in both headers, whatever sits
   before them. */
.library-list-header > :first-child,
.library-detail-header > .library-playlist-name {
    margin-right: auto;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.library-track-list {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 0.25rem;
}

/* Deliberately tighter than `.podcast-episode-row`: an episode list is tens
   of rows with a paragraph of description each, a médiathèque is hundreds of
   rows that are two lines and nothing else. */
.library-track-row {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    padding: 0.3rem 0.5rem;
    border: 1px solid transparent;
    border-radius: var(--radius);
}

.library-track-row:hover {
    background: var(--panel);
}

/* The one that is loaded, so a long list still says where you are in it. */
.library-track-row.current {
    background: var(--panel);
    border-color: color-mix(in srgb, var(--accent) 45%, var(--panel-border) 55%);
}

.library-track-play {
    flex: 0 0 auto;
}

.library-track-cover {
    flex: 0 0 auto;
    width: 2.25rem;
    height: 2.25rem;
    border-radius: 4px;
    object-fit: cover;
    background: var(--panel);
}

.library-track-text {
    flex: 1 1 auto;
    min-width: 0;
    display: flex;
    flex-direction: column;
    gap: 0.05rem;
}

.library-track-title,
.library-track-meta {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.library-track-title {
    font-size: 0.85rem;
    font-weight: 600;
}

.library-track-meta {
    font-size: 0.72rem;
    color: var(--text-dim);
}

/* Tabular figures so a column of durations lines up rather than jittering
   between `1:05` and `11:05`. */
.library-track-duration {
    flex: 0 0 auto;
    font-size: 0.75rem;
    color: var(--text-dim);
    font-variant-numeric: tabular-nums;
}

.library-track-stored {
    flex: 0 0 auto;
    display: inline-flex;
    color: var(--accent);
}

/* The percentage that replaces the download button while a track is being
   fetched — a button, because pressing it cancels. */
.library-track-progress {
    flex: 0 0 auto;
    font-size: 0.72rem;
    font-variant-numeric: tabular-nums;
    padding: 0.2rem 0.4rem;
}

.library-empty {
    margin: 0.5rem 0;
    font-size: 0.82rem;
    color: var(--text-dim);
}

/* ---- Playlists ---- */

.library-new-playlist,
.library-rename-form {
    display: flex;
    align-items: center;
    gap: 0.4rem;
    flex-wrap: wrap;
}

.library-new-playlist input,
.library-rename-form input {
    flex: 1 1 10rem;
    min-width: 0;
}

.library-playlist-list {
    list-style: none;
    margin: 0.5rem 0 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 0.35rem;
}

.library-playlist-card {
    display: flex;
    align-items: center;
    gap: 0.4rem;
    padding: 0.4rem 0.5rem;
    background: var(--panel);
    border: 1px solid var(--panel-border);
    border-radius: var(--radius);
}

/* The one currently being played out of. */
.library-playlist-card.playing {
    border-color: color-mix(in srgb, var(--accent) 45%, var(--panel-border) 55%);
}

/* The grip is the drag affordance; the row itself is what is draggable (the
   handle alone is a 12px target on a phone), so this only has to look like
   one. */
.library-playlist-grip {
    flex: 0 0 auto;
    display: inline-flex;
    color: var(--text-dim);
    cursor: grab;
}

/* The name and its summary as one button, so the whole left side of the row
   opens the playlist rather than a word in the middle of it. */
.library-playlist-open {
    flex: 1 1 auto;
    min-width: 0;
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: 0.05rem;
    text-align: left;
}

.library-playlist-name {
    font-size: 0.88rem;
    font-weight: 600;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    max-width: 100%;
}

.library-playlist-summary {
    font-size: 0.72rem;
    color: var(--text-dim);
}

.library-playlist-detail {
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
}

/* The "ajouter à une playlist" sheet: one full-width row per playlist,
   because a tick has to be tappable and a grid of them is not. */
.library-playlist-picker {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 0.35rem;
}

.library-playlist-picker .ghost {
    width: 100%;
    justify-content: flex-start;
    gap: 0.4rem;
}

.library-edit-form {
    display: flex;
    flex-direction: column;
    gap: 0.6rem;
}

.library-edit-form label {
    display: flex;
    flex-direction: column;
    gap: 0.25rem;
    font-size: 0.78rem;
    color: var(--text-dim);
}

/* ---- The strip's two skip buttons ---- */

/* Only ever rendered while a médiathèque queue is running (see
   `player::bar`), so this is sizing rather than visibility. */
.player-skip-button {
    flex: 0 0 auto;
    padding: 0.25rem;
}

@media (max-width: 900px) {
    /* On a phone the row has to lose something, and it is the album: the
       artist is what tells two versions of a track apart, and the size is on
       the download button's tooltip anyway. */
    .library-track-cover {
        width: 2rem;
        height: 2rem;
    }

    /* The hint under the upload button is a full line of its own here rather
       than sharing one with a button that already takes most of the width. */
    .library-upload-hint {
        flex: 1 0 100%;
    }
}

/* ---- Médiathèque : vues groupées et infos du morceau ---- */

/* The Titres / Artistes / Albums switcher. Below the search box rather than
   beside it: on a phone the search box already takes the width, and a row of
   three that wraps under it reads as a second control rather than a crowded
   first one. */
.library-grouping-tabs {
    display: flex;
    gap: 0.35rem;
    margin: 0.25rem 0 0.5rem;
}

.library-grouping-tabs .ghost.active {
    color: var(--accent);
    border-color: var(--accent);
}

/* A grid of sleeves, sized so a phone gets two columns and a desktop as many
   as fit. `auto-fill` rather than `auto-fit`: with one album, `auto-fit`
   stretches its tile across the whole row, which looks like a bug. */
.library-album-grid {
    list-style: none;
    margin: 0;
    padding: 0;
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr));
    gap: 0.75rem;
}

.library-album-tile .ghost {
    width: 100%;
    display: flex;
    flex-direction: column;
    align-items: stretch;
    gap: 0.2rem;
    padding: 0.4rem;
    text-align: left;
}

/* Square by construction, filled or empty — a grid whose rows change height
   because one album has no cover is worse than a grid of blanks. */
.library-album-cover {
    width: 100%;
    aspect-ratio: 1;
    border-radius: 6px;
    object-fit: cover;
    background: var(--panel);
    display: block;
}

.library-album-cover.empty {
    border: 1px solid var(--panel-border);
}

.library-album-name {
    font-size: 0.82rem;
    font-weight: 600;
}

.library-album-artist,
.library-album-summary {
    font-size: 0.7rem;
    color: var(--text-dim);
}

.library-album-name,
.library-album-artist,
.library-album-summary {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.library-artist-list {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 0.25rem;
}

.library-artist-row .ghost {
    width: 100%;
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: 0.05rem;
    text-align: left;
}

.library-artist-name {
    font-size: 0.85rem;
    font-weight: 600;
}

.library-artist-summary {
    font-size: 0.72rem;
    color: var(--text-dim);
}

/* The now-playing panel reuses `.track-panel` wholesale — it is the radio's
   and it is the same thing. The one difference is that it sits above a tab
   row rather than at the top of a section, so it needs the gap the flex
   column would otherwise leave it without. */
.library-now-playing {
    margin-bottom: 0.25rem;
}

/* Pulled out of the lyrics control row when that row became conditional:
   "Tout afficher" applies to any timed lyrics, and an exact clock draws no
   nudge buttons for it to sit beside. */
.track-lyrics-expand {
    margin-top: 0.4rem;
}
