/* ── Typography ───────────────────────────────────────────────────────────────
   Self-hosted, never a CDN. A Google Fonts <link> would hand every visitor's IP
   and Referer to a third party on every page load — precisely what the Mullvad
   kill switch, the L4 passthrough at the VPS, Referrer-Policy: no-referrer and
   the de-branded outbound User-Agent all exist to prevent. The woff2 files live
   in static/fonts/ and are recorded in misc/vendored_assets.json, so
   misc/check_vendored_assets.py hashes them like the rest of the
   vendored front-end (C14).

   Latin subsets only — the full family set is ~10x the size for glyphs this site
   never renders. 68 KB total for all four faces.

   Three roles:
     Orbitron       display  — headings and the brand lockup. Wide and geometric:
                               great for four words, unreadable for forty, so it is
                               deliberately NOT the body face.
     Chakra Petch   body/UI  — angular and techy but built for running text.
     JetBrains Mono mono     — code, filenames, the text editor.

   These override Pico's own custom properties rather than restyling elements, so
   every page picks them up without touching the ~40 inline templates. app.css
   loads AFTER pico.min.css (see the <link> order in home.py), so :root here wins.
   Each stack keeps var(--pico-font-family-emoji) last, as Pico's own do, or emoji
   silently lose their colour font.
   ───────────────────────────────────────────────────────────────────────────── */

@font-face {
    font-family: 'Orbitron';
    src: url('/static/fonts/orbitron-var.woff2') format('woff2');
    font-weight: 400 900;          /* variable axis — one file, all weights */
    font-style: normal;
    font-display: swap;
}
@font-face {
    font-family: 'Chakra Petch';
    src: url('/static/fonts/chakrapetch-400.woff2') format('woff2');
    font-weight: 400;
    font-style: normal;
    font-display: swap;
}
@font-face {
    font-family: 'Chakra Petch';
    src: url('/static/fonts/chakrapetch-700.woff2') format('woff2');
    font-weight: 700;
    font-style: normal;
    font-display: swap;
}
@font-face {
    font-family: 'JetBrains Mono';
    src: url('/static/fonts/jetbrainsmono-var.woff2') format('woff2');
    font-weight: 400 700;          /* variable axis */
    font-style: normal;
    font-display: swap;
}

:root {
    --pico-font-family-sans-serif:
        'Chakra Petch', system-ui, "Segoe UI", Roboto, Helvetica, Arial,
        sans-serif, var(--pico-font-family-emoji);
    --pico-font-family-monospace:
        'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas,
        "Liberation Mono", monospace, var(--pico-font-family-emoji);
    --pico-font-family: var(--pico-font-family-sans-serif);

    /* Display face. Not a Pico variable — applied only where it earns its keep. */
    --site-font-display:
        'Orbitron', 'Chakra Petch', system-ui, sans-serif,
        var(--pico-font-family-emoji);

    /* How far in from the top and bottom of the screen a fixed overlay must stay.
       env() alone is not enough: the page sets no viewport-fit=cover, so the insets
       read 0 even in the app, which draws under the status bar — the fixed floor is
       what actually clears it. #cal-dialog worked this sum out first, in a top margin
       of its own; this is that value named, so custom_select.js can measure it and stop
       a dropdown flipped above its trigger at the same line instead of letting it grow
       up behind the bar. (.ui-dialog deliberately sits half a rem lower still.) */
    --overlay-safe-top: max(2.5rem, calc(env(safe-area-inset-top, 0px) + 1rem));
    /* The bottom one is the bare inset — the gesture bar's own height and nothing
       more. Overlays already leave their own 8px gap above it, and folding a second
       one in here would move every dropdown that was sitting correctly. */
    --overlay-safe-bottom: env(safe-area-inset-bottom, 0px);
}

/* Headings only down to h3. h4-h6 are used for dense inline labels in the file
   and playlist views, where Orbitron's width would force wrapping. */
h1, h2, h3 {
    font-family: var(--site-font-display);
    letter-spacing: 0.015em;
}

/* Accent-coloured section titles. /color.css paints `article h2` with the accent; this
   class does the same for the h3/h4 section headings that carry it. Which LEVEL a page
   uses is deliberate: the display face and its glow are h1-h3 only, and are reserved
   for the password pages — login, folder lock/unlock, the prompt vault — so ordinary
   section titles (Upload, Models, Memories) are h4s: accent-coloured, plain face. */
.section-title {
    color: var(--pico-primary);
}

/* The brand lockup (app.py login heading, home.py nav) — the one place the
   display face is worth pushing further. */
h2 > .icon + *, hgroup > h1 {
    letter-spacing: 0.04em;
}

/* Chakra Petch's digits are slightly narrow; tabular figures keep file sizes,
   durations and timestamps from jittering as they tick. */
time, .file-size, .duration, td, th {
    font-variant-numeric: tabular-nums;
}

/* Home page nav menu — stacked full-width buttons */
.nav-menu {
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
}

.nav-menu a[role="button"],
.nav-menu > button {
    display: block;
    width: 100%;
    text-align: center;
    margin: 0;
}

.nav-menu > details {
    margin: 0;
}

.nav-menu > details > summary[role="button"] {
    display: block;
    width: 100%;
    text-align: center;
    margin: 0;
    list-style: none;
}

.nav-menu > details > summary[role="button"]::-webkit-details-marker {
    display: none;
}

/* Pico floats a chevron (::after) on the right of the summary, which pushes
   the centered label left relative to the sibling <a role="button"> entries.
   Mirror the chevron's reserved width on the left so the label re-centers. */
.nav-menu > details > summary[role="button"]::before {
    content: "";
    display: block;
    float: left;
    width: 1rem;
    height: calc(1rem * var(--pico-line-height, 1.5));
    margin-inline-end: calc(var(--pico-spacing, 1rem) * 0.5);
}

.nav-menu > details > .actions {
    padding-top: 0.5rem;
}

.nav-menu > form {
    margin: 0;
}

.nav-menu > form > button {
    width: 100%;
    margin: 0;
}


/* Action button columns — folders and file list */
.actions,
.file-actions {
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
    min-width: 0;
    max-width: 100%;
    overflow: hidden;
}

.actions > form,
.file-actions > form {
    margin: 0;
    min-width: 0;
}

.actions > form > button,
.file-actions > form > button {
    width: 100%;
    margin: 0;
}

/* Standalone buttons (type=button) inside action columns */
.actions > button,
.file-actions > button {
    display: block;
    width: 100%;
    margin: 0;
}

/* New folder/playlist/channel/command/group buttons — sit outside .actions, need explicit block width */
#create-folder-btn,
#create-playlist-btn,
#create-station-btn,
#create-channel-btn,
#add-cmd-btn,
#add-group-btn,
#uploadBtn {
    display: block;
    width: 100%;
    margin-bottom: 0.5rem;
}

/* Shell command input and relay selects — match standard spacing */
#test-cmd-input,
#ch-select,
#add-item-select,
#pl-new-select,
#row-filter {
    display: block;
    width: 100%;
    margin-bottom: 0.5rem;
}

/* Selects inside action columns */
.actions select,
.file-actions select {
    margin-bottom: 0;
}

/* Custom select wrapper — inherits the margin-bottom a native <select> would have.
   Reset to zero inside action columns where selects are part of move forms. */
.cs-wrapper {
    margin-bottom: 0.5rem;
}

.actions .cs-wrapper,
.file-actions .cs-wrapper,
.move-form .cs-wrapper {
    margin-bottom: 0;
}


/* Relay delete bar — fixed bottom, same pattern as #bulk-bar */
#relay-delete-bar {
    display: none;
    position: fixed;
    bottom: 0;
    left: 0;
    right: 0;
    background: var(--pico-card-background-color);
    border-top: 1px solid var(--pico-muted-border-color);
    padding: 0.75rem 1rem;
    z-index: 100;
}

#news-rename-btn {
    display: block;
    width: 100%;
}

#relay-delete-bar-btn {
    display: block;
    width: 100%;
    /* Destructive ring, not a destructive slab. #c0392b is already the site's
       delete colour as a border and as text — see .cal-delete-btn — so this is the
       same treatment at the 2px weight the primary actions use. */
    background-color: transparent;
    border: 2px solid #c0392b;
    color: #c0392b;
}

/* Collapsible rows (folders, files, playlists, stations, relay commands) */
details {
    margin-bottom: 0.5rem;
}

/* Channel Command — saved command rows (identical to folder rows) */
.relay-cmd-row {
    margin-bottom: 0.5rem;
}

/* Rename inline form — hidden until JS activates it */
.rename-form {
    display: none;
}

/* Lock/unlock forms — hidden until submitted via JS */
.lock-form,
.remove-lock-form,
.relock-form {
    display: none;
}

/* Playlist rename form — hidden until JS activates it */
.pl-rename-form {
    display: none;
}

/* Playlist edit — file picker container, hidden until folder selected + files loaded */
.pl-file-container {
    display: none;
}

/* Custom select dropdown */
.cs-wrapper {
    width: 100%;
    min-width: 0;
}

.cs-trigger {
    display: flex;
    align-items: center;
    justify-content: space-between;
    width: 100%;
    text-align: left;
    background: var(--pico-form-element-background-color);
    border: var(--pico-border-width) solid var(--pico-form-element-border-color);
    border-radius: var(--pico-border-radius);
    color: var(--pico-form-element-color);
    padding: var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal);
    font-size: 1rem;
    cursor: pointer;
    margin: 0;
    box-shadow: none;
}

.cs-trigger:hover {
    border-color: var(--pico-form-element-active-border-color);
    background: var(--pico-form-element-background-color);
    color: var(--pico-form-element-color);
}

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

.cs-arrow {
    flex-shrink: 0;
    margin-left: 0.5rem;
    opacity: 0.6;
    font-size: 0.75em;
}

.cs-dropdown {
    position: fixed;
    z-index: 9999;
    background: var(--pico-card-background-color);
    border: var(--pico-border-width) solid var(--pico-form-element-border-color);
    border-radius: var(--pico-border-radius);
    box-shadow: var(--pico-card-box-shadow);
    list-style: none;
    margin: 0;
    padding: 0.25rem 0;
    display: none;
}

.cs-option {
    /* Inset from the panel's own edges so the rings below read as an outline around
       the row instead of a line running into the dropdown border. margin + padding
       still add up to the old 1rem, so the text sits where it always did. */
    margin: 0 0.25rem;
    padding: 0.5rem 0.75rem;
    border-radius: var(--pico-border-radius);
    cursor: pointer;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    color: var(--pico-color);
    /* Resting ring, transparent: hover and selection swap its colour and width, and
       an inset shadow never affects layout, so nothing shifts as rows light up. */
    box-shadow: inset 0 0 0 1px transparent;
}

/* Hover and selection outline the row and tint its text; they deliberately do NOT
   fill it. --pico-primary is the accent's PALE shade (red preset: #f87171) — made
   to be read as text on a dark ground, which is exactly this job — while the solid
   shades stay on the ring:
       hover    --pico-primary-hover-background  (red: #ef4444)
       selected --pico-primary-background        (red: #dc2626)
   Hover is the brighter ring and selection the thicker one, so the two are still
   told apart at a glance when the cursor is somewhere else, and a hovered selected
   row (bright + thick + bold) is distinct from both. */
/* .cs-hl is the highlight without the row layout — custom_select.js hands it to
   lists that are dropdowns in behaviour but keep their own geometry. It appears in
   every rule below that .cs-option does, and nowhere that .cs-option doesn't, which
   is what keeps the two identical on screen. */
.cs-hl {
    border-radius: var(--pico-border-radius);
    box-shadow: inset 0 0 0 1px transparent;
}

.cs-option:hover,
.cs-option.cs-active,
.cs-hl:hover,
.cs-hl.cs-active {
    box-shadow: inset 0 0 0 1px var(--pico-primary-hover-background);
    color: var(--pico-primary);
}

.cs-option.cs-selected {
    box-shadow: inset 0 0 0 2px var(--pico-primary-background);
    color: var(--pico-primary);
    font-weight: bold;
}

.cs-option.cs-selected:hover,
.cs-option.cs-selected.cs-active {
    box-shadow: inset 0 0 0 2px var(--pico-primary-hover-background);
    color: var(--pico-primary);
}

/* Keep search outside the options scroller, including upward-opening panels. */
.cs-select-dropdown {
    flex-direction: column;
    box-sizing: border-box;
    overflow: hidden;
}
.cs-options {
    flex: 1 1 auto;
    min-height: 0;
    overflow-y: auto;
    overscroll-behavior: contain;
    overflow-anchor: none;
    list-style: none;
    margin: 0;
    padding: 0;
}
.cs-search {
    flex: 0 0 auto;
    padding: 0.5rem;
    background: var(--pico-card-background-color);
    border-bottom: 1px solid var(--pico-muted-border-color);
}

.cs-search-input {
    width: 100%;
    margin: 0;
    padding: 0.35rem 0.5rem;
    font-size: 0.9rem;
    height: auto;
    background: var(--pico-form-element-background-color);
    border: 1px solid var(--pico-form-element-border-color);
    border-radius: var(--pico-border-radius);
    color: var(--pico-form-element-color);
    box-shadow: none;
}

.cs-search-input:focus {
    border-color: var(--pico-primary);
    box-shadow: 0 0 0.6rem -0.15rem var(--pico-primary-focus);
    outline: none;
}

.cs-empty {
    padding: 0.5rem 1rem;
    color: var(--pico-muted-color);
    font-style: italic;
    cursor: default;
}

/* Action menus (custom_select.js openMenu) — the same dropdown, opened against an
   arbitrary element instead of a <select> trigger. Items may carry an icon. */
.cs-menu .cs-option {
    display: flex;
    align-items: center;
    gap: 0.5rem;
}

.cs-option-disabled {
    opacity: 0.45;
    cursor: default;
}

.cs-option-disabled:hover {
    box-shadow: inset 0 0 0 1px transparent;
    color: var(--pico-color);
    /* The matching `animation: none` cannot live here — see the glitch section. */
}

/* Move form — single combined button */
.move-form {
    width: 100%;
    min-width: 0;
}

.move-form select {
    width: 100%;
    max-width: 100%;
    min-width: 0;
    margin: 0;
}

.cs-trigger.move-trigger {
    background: var(--pico-background-color);
    border-color: var(--pico-border-color);
    color: var(--pico-color);
    justify-content: center;
}

.cs-trigger.move-trigger .cs-label {
    flex: none;
}

.cs-trigger.move-trigger .cs-arrow {
    display: none;
}

/* Bulk action bar — hidden until items are selected */
#bulk-bar {
    display: none;
    position: fixed;
    bottom: 0;
    left: 0;
    right: 0;
    background: var(--pico-card-background-color);
    border-top: 1px solid var(--pico-muted-border-color);
    padding: 0.75rem 1rem;
    z-index: 100;
}

/* Memory rows on the prompts page. The summary carries the memory's full text so that
   two facts sharing an opening clause stay distinguishable and find-in-page can reach
   them; clamp the height here rather than truncating the text server-side. */
.memory-row > summary {
    display: block;
    text-align: left;
    white-space: normal;
    overflow-wrap: anywhere;
    line-height: 1.4;
    max-height: calc(1.4em * 3);
    overflow: hidden;
}

.memory-row[open] > summary {
    max-height: none;
}

/* Rows the pager/filter is not showing. display:none rather than detaching the node —
   single_open_details.js and bulk_select.js bind their listeners to the rows at load,
   so a removed row would come back inert. */
.memory-row.mem-hidden {
    display: none;
}

/* Pico styles [type=search] as a 5rem pill. The memory filter sits right above the
   Options dropdown, whose .cs-trigger uses the standard 0.25rem radius — override the
   variable (not border-radius itself, which would fight Pico's own rule) so the two
   controls read as one set of rectangular fields. */
#memory-filter {
    --pico-border-radius: 0.25rem;
}

/* Folders/files search — the bar sits directly ABOVE #create-folder-btn on the folders
   and folder-contents pages and matches it: the input joins that button's width/spacing
   rule above, so the two read as one stack in either order. One box, no scope picker:
   it always searches folder and file names together. */

/* Pico styles [type=search] as a 5rem pill; #create-folder-btn is 0.25rem. Override
   the variable, not border-radius itself, so Pico's own rule isn't fought — the same
   fix #memory-filter takes. */
#row-filter {
    --pico-border-radius: 0.25rem;
}

#row-filter-count {
    display: block;
    margin: -0.25rem 0 0.5rem;
    color: var(--pico-muted-color);
}

/* With no query on screen the count is empty, but a display:block element still
   contributes its margins — which left more space below the search bar than above it.
   The bar sits in the stack exactly like #create-folder-btn: 0.5rem either side. */
#row-filter-count:empty {
    display: none;
}

/* Result rows ARE listing rows — folder_row / file_row, the same macros the listing
   pages use — so they need no styling of their own. Only the group heading and the
   path line below each hit are new: a match three folders down is meaningless without
   the path, and the listing rows have nowhere to put one. */
.row-results-group {
    margin: 0 0 0.25rem;
    color: var(--pico-muted-color);
    font-size: 0.85rem;
    text-transform: uppercase;
    letter-spacing: 0.04em;
}

.row-result-path {
    display: block;
    /* Pulled up under the row it belongs to, out of the gap before the next one. */
    margin: -0.35rem 0 0.5rem 0.4rem;
    color: var(--pico-muted-color);
}

/* list_filter.js hides the normal listing while a search is on screen, and hides
   non-matching rows — see the note at the top of that file. */
details[data-name].row-hidden {
    display: none;
}

/* Upload drop zone */
/* Whole-window drop target. The uploads page and the AI composer both take a file
   dropped anywhere on the page — the uploads one always did, at the document level,
   it just never looked like it, and the composer does now too. This frame is the only
   thing that says so; there is no drop box on either page to aim at.

   On <body> rather than an injected element so there is no markup to keep in sync on
   two pages, and pointer-events:none so the frame can never swallow the drop it is
   advertising. z-index sits below .cs-dropdown's 9999 — a dropdown open behind a drag
   is odd, but it should still be the thing on top. */
body.drag-active::before {
    content: "";
    position: fixed;
    inset: 0.4rem;
    z-index: 9998;
    pointer-events: none;
    border: 2px dashed var(--pico-primary);
    border-radius: var(--pico-border-radius);
    box-shadow: inset 0 0 3rem -1rem var(--pico-primary-focus);
}

/* Text editor */
.editor-textarea {
    font-family: var(--pico-font-family-monospace);
    min-height: 60vh;
    resize: vertical;
}

/* Viewer nav — clamp long filenames so mobile never side-scrolls */
.viewer-nav {
    overflow: hidden;
}
.viewer-nav > ul:first-child {
    flex: 1 1 0%;
    min-width: 0;
    overflow: hidden;
}
.viewer-nav > ul:first-child > li {
    display: block;
    min-width: 0;
    overflow: hidden;
}
.viewer-nav > ul:first-child > li > strong {
    display: block;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
.viewer-nav > ul:last-child {
    flex-shrink: 0;
}

/* The full-bleed media viewer below uses width:100vw. 100vw counts the vertical
   scrollbar's width, but the visible page area does not, so when a vertical
   scrollbar is present (depends on window height/orientation) the breakout is a
   scrollbar-width wider than the page and forces a left-right scroll that clips
   the edge of the centred video. Clip that horizontal overflow at the root; the
   breakout box stays centred on the visible area, so the video itself remains
   fully visible. Vertical scrolling is unaffected. With overlay scrollbars
   (mobile) there is no overflow to clip, so behaviour is unchanged there. */
html {
    overflow-x: clip;
}

/* Media viewer */
.media-viewer {
    display: flex;
    justify-content: center;
    margin-top: 1.5rem;
    width: 100vw;
    margin-left: calc(50% - 50vw);
}

.media-viewer img {
    width: 100%;
    height: 90vh;
    object-fit: contain;
}

@media (max-width: 768px) {
    .media-viewer {
        margin-top: 0.25rem;
    }
    .media-viewer img {
        height: 85vh;
    }
    main.container:has(.media-viewer img) {
        padding-top: 0.25rem;
    }
}

.media-viewer video {
    max-width: 100%;
    width: 100%;
    max-height: calc(100dvh - 4.5rem);
    object-fit: contain;
    border-radius: 0;
}

/* Plyr outer container: cap width so 16:9 content fills viewport height without side letterboxing */
.media-viewer .plyr--video {
    width: 100%;
    max-width: calc((100dvh - 4.5rem) * 16 / 9);
}

/* Plyr video wrapper: safety height cap for non-16:9 content */
.media-viewer .plyr__video-wrapper {
    max-height: calc(100dvh - 4.5rem);
}

/* Playlist player has more surrounding UI — allow less height for the video */
.media-viewer--pl .plyr--video {
    max-width: calc((100dvh - 14rem) * 16 / 9);
}
.media-viewer--pl video,
.media-viewer--pl .plyr__video-wrapper {
    max-height: calc(100dvh - 14rem);
}

/* Unified playlists mix audio and video in one <video> element. For an audio track,
   playlist_player.js swaps Plyr's container class to .plyr--audio (so it renders as the
   dedicated audio player the app themes above), and here we drop the leftover video
   surface and the video-only control buttons. The opacity guard defends against Plyr's
   video inactivity auto-hide, which keys off .plyr--video (removed for audio tracks). */
.media-viewer--pl.pl-audio-only .plyr__video-wrapper {
    display: none;
}
.media-viewer--pl.pl-audio-only [data-plyr="fullscreen"],
.media-viewer--pl.pl-audio-only [data-plyr="captions"] {
    display: none;
}
.media-viewer--pl.pl-audio-only .plyr__controls {
    opacity: 1 !important;
    transform: none !important;
}

.media-viewer video:focus,
.media-viewer audio:focus {
    outline: none;
}

.media-viewer audio:focus-within * {
    outline: none;
}

.media-viewer audio {
    width: 100%;
    max-width: 600px;
}

.media-viewer .plyr--audio {
    width: 100%;
    max-width: 600px;
    margin: 0 auto;
}

/* Standalone audio viewer (audio outside .media-viewer) */
main.container > audio[data-progress-url] {
    display: block;
    width: 100%;
    max-width: 600px;
    margin: 1.5rem auto 0;
}

main.container > audio[data-progress-url]:focus {
    outline: none;
}

/* Plyr wrapper replaces the <audio> element in the DOM — match the same sizing */
main.container > .plyr--audio {
    width: 100%;
    max-width: 600px;
    margin: 1.5rem auto 0;
}


/* Playlist edit — track list */
.playlist-track-list {
    list-style: none;
    padding: 0;
    margin: 0 0 1rem;
}

.playlist-track-item {
    display: flex;
    align-items: center;
    gap: 0.6rem;
    padding: 0.5rem 0.4rem;
    border-bottom: 1px solid var(--pico-muted-border-color);
    cursor: default;
}

.playlist-track-item.dragging {
    opacity: 0.4;
}

.drag-handle {
    cursor: grab;
    color: var(--pico-muted-color);
    font-size: 1.2rem;
    user-select: none;
    flex-shrink: 0;
}

.track-info {
    flex: 1;
    display: flex;
    flex-direction: column;
    min-width: 0;
}

.track-filename {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.track-folder {
    color: var(--pico-muted-color);
}

.remove-track-form {
    flex-shrink: 0;
    margin: 0;
}

.small-btn {
    padding: 0.2rem 0.5rem;
    font-size: 0.85rem;
    margin: 0;
}

/* Playlist player — queue */
.player-card {
    padding: 1rem;
    overflow: visible;
}

.np-filename {
    font-size: 1.1rem;
    font-weight: bold;
    margin: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.np-folder {
    color: var(--pico-muted-color);
}

.player-controls {
    display: flex;
    flex-wrap: nowrap;
    justify-content: space-between;
    gap: 0.5rem;
    margin-top: 1rem;
}

.player-controls button,
.player-controls a[role="button"] {
    flex: 1;
    text-align: center;
    margin: 0;
}

.icon {
    width: 1em;
    height: 1em;
    vertical-align: -0.125em;
    display: inline-block;
    flex-shrink: 0;
}

.active-control {
    border-color: var(--pico-primary) !important;
    color: var(--pico-primary) !important;
}

@media (max-width: 520px) {
    .btn-label {
        display: none;
    }
    .player-controls button,
    .player-controls a[role="button"] {
        padding-left: 0.25rem;
        padding-right: 0.25rem;
    }
}

.playlist-queue {
    list-style: none;
    padding: 0;
    margin: 0;
    max-height: 50vh;
    overflow-y: auto;
}

.queue-item {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    padding: 0.4rem 0.4rem;
    border-bottom: 1px solid var(--pico-muted-border-color);
    cursor: pointer;
    border-radius: var(--pico-border-radius);
}

.queue-item:hover {
    background: var(--pico-card-background-color);
}

/* The playing track: page-dark inside an accent ring, accent text — the same
   ring-not-fill treatment the custom select's selected row gets, rather than a solid
   accent slab. The ring is an inset shadow, not a border, so the row's geometry does
   not change as the selection moves; the item's own bottom border steps aside for it
   so the ring reads as one continuous outline. */
.queue-item.queue-active {
    background: var(--pico-background-color);
    border-color: transparent;
    box-shadow: inset 0 0 0 1px var(--pico-primary);
    color: var(--pico-primary);
}

.queue-item.queue-active:hover {
    background: var(--pico-background-color);
}

/* The number, folder and progress labels are muted on every other row; on the playing
   one they take the accent too, so the whole row reads as selected. */
.queue-item.queue-active .queue-num,
.queue-item.queue-active .queue-folder,
.queue-item.queue-active .queue-progress-info {
    color: var(--pico-primary);
}

.queue-num {
    color: var(--pico-muted-color);
    min-width: 2rem;
    text-align: right;
    flex-shrink: 0;
}

.queue-progress-info {
    flex-shrink: 0;
    color: var(--pico-muted-color);
    white-space: nowrap;
    font-size: 0.8rem;
}

/* The element is always in the row so the player has somewhere to write the label as
   it plays; until there is progress to show, it takes up no space. */
.queue-progress-info:empty {
    display: none;
}

.queue-track {
    flex: 1;
    display: flex;
    flex-direction: column;
    min-width: 0;
}

.queue-filename {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.queue-folder {
    color: var(--pico-muted-color);
}

/* Playlist edit — file checkbox list */
.file-checkbox-list {
    max-height: 260px;
    overflow-y: auto;
    border: 1px solid var(--pico-muted-border-color);
    border-radius: var(--pico-border-radius);
    padding: 0.4rem 0.6rem;
    margin: 0.4rem 0 0.75rem;
}

.file-checkbox-list label {
    display: flex;
    align-items: center;
    gap: 0.4rem;
    padding: 0.15rem 0;
    margin: 0;
    font-size: 0.9rem;
    cursor: pointer;
}

/* Radio — active station highlight */
details.station-active > summary {
    border-color: var(--pico-primary) !important;
    color: var(--pico-primary) !important;
}

/* Radio — station connecting (preload in progress) */
details.station-pending > summary {
    border-color: var(--pico-primary) !important;
    color: var(--pico-primary) !important;
    animation: station-pending-pulse 0.9s ease-in-out infinite alternate;
}

@keyframes station-pending-pulse {
    from { opacity: 0.45; }
    to   { opacity: 1.0; }
}

/* ── Camera page ─────────────────────────────────────────────────────────── */
body.camera-body {
    display: grid;
    grid-template-rows: auto 1fr auto;
    height: 100dvh;
    overflow: hidden;
}

#media-wrap {
    background: #000;
    min-height: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    overflow: hidden;
    touch-action: none;
    user-select: none;
}

#media-wrap video,
#media-wrap img {
    display: block;
    width: 100%;
    height: 100%;
    object-fit: contain;
    object-position: center;
}

#media-wrap #waiting-msg {
    color: var(--pico-muted-color);
}

#sender-controls .nav-menu button,
#sender-controls .nav-menu select,
#viewer-controls .nav-menu button {
    display: block;
    width: 100%;
    margin: 0;
}

#status-text {
    font-size: 0.8rem;
    color: var(--pico-muted-color);
    text-align: center;
}

/* Named accent palette. Two consumers: the theme picker on /color, and the event
   colours in the planning calendar (calendar_db.COLORS), where the same class lands
   on a .cal-event chip and on its swatch in the dialog. Border + text only, which is
   why one rule can serve a 2rem circle and a 0.72rem calendar chip. */
.swatch-red     { border-color: #f87171 !important; color: #f87171 !important; }
.swatch-rose    { border-color: #fb7185 !important; color: #fb7185 !important; }
.swatch-pink    { border-color: #f472b6 !important; color: #f472b6 !important; }
.swatch-fuchsia { border-color: #e879f9 !important; color: #e879f9 !important; }
.swatch-purple  { border-color: #c084fc !important; color: #c084fc !important; }
.swatch-violet  { border-color: #a78bfa !important; color: #a78bfa !important; }
.swatch-indigo  { border-color: #818cf8 !important; color: #818cf8 !important; }
.swatch-blue    { border-color: #01aaff !important; color: #01aaff !important; }
.swatch-sky     { border-color: #38bdf8 !important; color: #38bdf8 !important; }
.swatch-cyan    { border-color: #22d3ee !important; color: #22d3ee !important; }
.swatch-teal    { border-color: #2dd4bf !important; color: #2dd4bf !important; }
.swatch-emerald { border-color: #34d399 !important; color: #34d399 !important; }
.swatch-green   { border-color: #4ade80 !important; color: #4ade80 !important; }
.swatch-lime    { border-color: #a3e635 !important; color: #a3e635 !important; }
.swatch-yellow  { border-color: #facc15 !important; color: #facc15 !important; }
.swatch-amber   { border-color: #fbbf24 !important; color: #fbbf24 !important; }
.swatch-orange  { border-color: #fb923c !important; color: #fb923c !important; }
.swatch-slate   { border-color: #94a3b8 !important; color: #94a3b8 !important; }

.swatch-selected { background-color: rgba(255, 255, 255, 0.08) !important; }

.color-grid {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: 0.375rem;
}
.color-grid button {
    margin: 0;
    padding: 0.4rem 0.5rem;
    font-size: 0.85rem;
}

/* Search — image grid */
.search-image-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
    gap: 0.75rem;
}

.search-music-thumb-box {
    width: 56px;
    height: 56px;
    min-width: 56px;
    flex-shrink: 0;
    border-radius: 4px;
    overflow: hidden;
}

.search-music-thumb-box img {
    width: 56px;
    height: 56px;
    max-width: 56px;
    max-height: 56px;
    object-fit: cover;
    display: block;
}

.search-thumb-wrapper {
    display: flex;
    flex-direction: column;
    gap: 0.3rem;
}

.search-thumb-title {
    font-size: 0.8rem;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    color: var(--pico-color);
}

.search-thumb-url {
    font-size: 0.72rem;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    color: var(--pico-muted-color);
}

.search-image-cell {
    display: block;
    aspect-ratio: 4/3;
    overflow: hidden;
    border-radius: var(--pico-border-radius);
    background: var(--pico-card-background-color);
    cursor: pointer;
}

.search-image-cell img {
    width: 100%;
    height: 100%;
    object-fit: cover;
    display: block;
    transition: opacity 0.15s;
}

.search-image-cell:hover img {
    opacity: 0.85;
}

/* Search — inline YouTube video result (Plyr) */
.search-video-cell {
    position: relative;
}

.search-video-play {
    position: absolute;
    inset: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    color: #fff;
    pointer-events: none;
}

.search-video-play svg {
    width: 48px;
    height: 48px;
    opacity: 0.9;
    filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.6));
}

/* Search — video lightbox (expanded Plyr YouTube player) */
#video-lightbox {
    display: none;
    position: fixed;
    inset: 0;
    z-index: 1000;
    background: rgba(0,0,0,0.85);
    flex-direction: column;
    align-items: center;
    justify-content: center;
    padding: 1.5rem;
    cursor: zoom-out;
}

#video-lightbox.open {
    display: flex;
}

#video-lightbox-inner {
    /* Largest 16:9 box that fits the viewport in both dimensions: bounded by the
       full width and by the viewport height. It grows to the vertical edges when
       the screen is tall enough to allow it, and shrinks to fit width-first in
       mobile landscape so it never overflows. 3rem = the lightbox's 1.5rem top +
       bottom padding. */
    width: min(100%, (100dvh - 3rem) * 16 / 9);
    cursor: default;
}

#video-lightbox-inner iframe {
    width: 100%;
    aspect-ratio: 16 / 9;
    border: 0;
    border-radius: var(--pico-border-radius);
    background: #000;
    display: block;
    /* cursor is an inherited property; reset it to auto here so the iframe does
       not carry an explicit cursor from its ancestors. That lets YouTube's own
       player control the cursor, including hiding it in fullscreen. */
    cursor: auto;
}

/* Search — image lightbox */
#img-lightbox {
    display: none;
    position: fixed;
    inset: 0;
    z-index: 1000;
    background: rgba(0,0,0,0.85);
    flex-direction: column;
    align-items: center;
    justify-content: center;
    padding: 0.75rem;
    cursor: zoom-out;
}

#img-lightbox.open {
    display: flex;
}

#img-lightbox img {
    /* Fill all the space left after the caption and scale the photo up/down to
       fit (object-fit:contain upscales when the box is larger than the source),
       so small images no longer sit tiny in the middle — the photo grows until
       it meets the viewport edges on its limiting dimension. */
    flex: 1;
    min-height: 0;
    width: 100%;
    object-fit: contain;
    border-radius: var(--pico-border-radius);
    display: block;
}

#img-lightbox-url {
    margin-top: 0.75rem;
    font-size: 0.85rem;
    color: rgba(255,255,255,0.75);
    word-break: break-all;
    text-align: center;
    max-width: 100%;
}

#img-lightbox-url a {
    color: var(--pico-primary);
}

/* Playlist create form — top row: name input + type select */
.pl-create-top {
    display: flex;
    gap: 0.5rem;
    align-items: stretch;
    margin-bottom: 0.5rem;
}

.pl-create-top > input[type="text"] {
    flex: 1 1 0;
    min-width: 0;
    margin-bottom: 0;
}

/* Keep the Audio/Video type select compact; don't let it stretch */
.pl-create-top > .cs-wrapper {
    flex: 0 0 auto;
    width: auto;
    min-width: 8rem;
    margin-bottom: 0;
}

/* Actions row */
.pl-create-actions {
    display: flex;
    gap: 0.5rem;
    margin-top: 0.5rem;
}

.pl-create-actions button {
    margin: 0;
}

@media (max-width: 480px) {
    .pl-create-top {
        flex-direction: column;
    }
    .pl-create-top > .cs-wrapper {
        width: 100%;
        min-width: 0;
    }
}

/* Video player in playlist */
.pl-video {
    width: 100%;
    max-height: calc(100dvh - 14rem);
    object-fit: contain;
    border-radius: var(--pico-border-radius);
    background: #000;
    display: block;
}

/* ── AI chat page ────────────────────────────────────────────────────────── */
.ai-body {
    display: flex;
    flex-direction: column;
    height: 100dvh;
    overflow: hidden;
}

.ai-main {
    flex: 1;
    min-height: 0;
    display: flex;
    flex-direction: column;
    padding-bottom: 0;
    box-sizing: border-box;
}

#ai-no-model {
    flex-shrink: 0;
    text-align: center;
    color: var(--pico-muted-color);
}

#ai-messages {
    flex: 1;
    min-height: 0;
    overflow-y: auto;
    overscroll-behavior: contain;
    -webkit-overflow-scrolling: touch;
    display: flex;
    flex-direction: column;
    gap: 0.75rem;
    padding: 0.5rem 0 1rem;
}

#ai-messages::before {
    content: '';
    flex: 1;
}

.ai-msg {
    max-width: 85%;
    padding: 0.6rem 0.9rem;
    border-radius: 0.85rem;
    line-height: 1.55;
    word-break: break-word;
    white-space: pre-wrap;
}

.ai-msg-user {
    align-self: flex-end;
    background: var(--pico-primary-background);
    border: 1px solid var(--pico-primary);
    border-bottom-right-radius: 0.2rem;
}

.ai-msg-ai {
    align-self: flex-start;
    background: var(--pico-card-background-color);
    border: 1px solid var(--pico-muted-border-color);
    border-bottom-left-radius: 0.2rem;
}

/* Bulleted lists in a reply. The bubble is pre-wrap and Pico's list spacing is set for
   document body text, so the list carries its own tight margins here.

   The bullet is a real character the renderer writes into the item (see renderContent),
   not a CSS ::marker — a marker is generated content and never reaches the clipboard, so
   copying a list would paste as bare lines. list-style is therefore off (see below), and
   the hanging indent is built by hand: the ul pads the list in, each item pulls its first
   line back out by the same amount, and the fixed-width bullet fills exactly that space so
   the item's text starts on the indent whether it wraps or not. */
.ai-msg ul.ai-list {
    margin: 0.35rem 0;
    padding-left: 1.35rem;
}
.ai-msg ul.ai-list:first-child { margin-top: 0; }
.ai-msg ul.ai-list:last-child  { margin-bottom: 0; }
/* list-style has to be turned off on the ITEM: Pico sets `ul li{list-style:square}` on
   the li itself, and a rule on the element beats the ul's inherited value — off the ul
   alone, the square marker survives and draws a second bullet beside the real one. */
.ai-msg ul.ai-list li {
    list-style: none;
    margin-bottom: 0.2rem;
    text-indent: -1.35rem;
}
.ai-msg ul.ai-list li:last-child { margin-bottom: 0; }
.ai-msg ul.ai-list .ai-bullet {
    display: inline-block;
    width: 1.35rem;
    text-indent: 0;
}

/* Maths the model wrote as LaTeX. renderContent unwraps the $…$ delimiters and turns
   the exponents into real <sup>, so all that is needed here is to keep a display
   equation apart from the prose around it and to stop a superscript stretching the
   line it sits on. Deliberately not a different typeface: the equations that arrive
   here are short — E = mc², 9 × 10¹⁶ — and setting them in a serif or monospace face
   would make them look like quoted code rather than part of the sentence. */
.ai-msg .ai-math {
    margin: 0.5rem 0;
    padding-left: 0.2rem;
}
.ai-msg .ai-math:first-child { margin-top: 0; }
.ai-msg .ai-math:last-child  { margin-bottom: 0; }
/* A sup/sub is set at full size by default and pushes the line taller than its
   neighbours, which shows up as uneven leading in a paragraph of prose. */
.ai-msg sup,
.ai-msg sub {
    font-size: 0.75em;
    line-height: 0;
}

/* Inline web-research citations — a [1] the reader can click. Sized down and set as a
   superscript so a sentence carrying three of them still reads as a sentence. */
/* A citation group — [1] or [1, 3]. The wrapper carries the superscript so the brackets
   and separators ride with the numbers; each number inside is its own link. */
.ai-cite {
    font-size: 0.78em;
    vertical-align: super;
    line-height: 0;
    padding: 0 0.1em;
    white-space: nowrap;
}
.ai-cite a {
    text-decoration: none;
}
.ai-cite a:hover,
.ai-cite a:focus-visible {
    text-decoration: underline;
}

#ai-input-area {
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
    padding: 0.75rem 0;
    border-top: 1px solid var(--pico-muted-border-color);
    flex-shrink: 0;
    transition: outline-color 0.15s, background 0.15s;
    outline: 2px dashed transparent;
    outline-offset: 4px;
}

/* Dragging a photo over the composer. Outline rather than border so the highlight
   doesn't reflow the layout underneath the message list mid-drag. */
#ai-input-area.drag-over {
    outline-color: var(--pico-primary);
    background: var(--pico-card-background-color);
}

/* Textarea and the camera button share a row; Send stays full-width beneath them. */
.ai-input-row {
    display: flex;
    align-items: flex-end;
    gap: 0.5rem;
}

#ai-input {
    flex: 1;
    min-width: 0;
    resize: none;
    margin: 0;
    min-height: 2.5rem;
    max-height: 8rem;
    overflow-y: auto;
}

#ai-photo-btn {
    width: auto;
    flex-shrink: 0;
    margin: 0;
    padding: 0.55rem 0.75rem;
    line-height: 1;
    /* Span the composer's full height so the button's outline meets the textarea's top
       edge as well as its bottom. The row is align-items:flex-end, which otherwise leaves
       the button hugging the bottom while the textarea grows upward past it. Stretching
       tracks the textarea as it auto-resizes between its min- and max-height. */
    align-self: stretch;
    display: inline-flex;
    align-items: center;
    justify-content: center;
}

#ai-photo-btn .icon {
    width: 1.25em;
    height: 1.25em;
    /* Centred by the flex box above; the glyph keeps its size while the outline grows. */
    vertical-align: middle;
}

/* Pending attachments: thumbnails above the composer, each with a remove button. */
#ai-attachments {
    display: flex;
    flex-wrap: wrap;
    gap: 0.5rem;
}

#ai-attachments[hidden] {
    display: none;
}

.ai-thumb {
    position: relative;
    width: 3.5rem;
    height: 3.5rem;
    border-radius: var(--pico-border-radius);
    border: 1px solid var(--pico-muted-border-color);
    overflow: hidden;
}

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

/* The one fill left standing. This × sits on top of a user's photo, where a
   transparent ground would put a dark glyph over whatever happens to be behind it
   — white sky, pale skin, a screenshot — and lose it. The black is a legibility
   scrim, not the accent language the rest of this pass converted, so it stays.
   The class (set in ai_chat.js) keeps it out of the global button rule at the
   bottom of this file. */
.ai-thumb .ai-thumb-x {
    position: absolute;
    top: 0;
    right: 0;
    width: 1.25rem;
    height: 1.25rem;
    padding: 0;
    margin: 0;
    border: 0;
    border-radius: 0 0 0 var(--pico-border-radius);
    background: rgba(0, 0, 0, 0.65);
    color: #fff;
    font-size: 0.8rem;
    line-height: 1.25rem;
    cursor: pointer;
}

/* Photos echoed back inside a sent message bubble. */
.ai-msg-img {
    max-width: 12rem;
    max-height: 12rem;
    border-radius: var(--pico-border-radius);
    display: block;
    margin-bottom: 0.35rem;
}

#ai-send-btn,
#ai-stop-btn {
    width: 100%;
    margin: 0;
}

#ai-stop-btn {
    background: transparent;
    border: 2px solid #c0392b;
    color: #c0392b;
}

#ai-send-btn.ai-btn-loading,
#ai-stop-btn.ai-btn-loading {
    position: relative;
    overflow: hidden;
    cursor: wait;
    opacity: 0.7;
}

#ai-send-btn.ai-btn-loading::after,
#ai-stop-btn.ai-btn-loading::after {
    content: '';
    position: absolute;
    bottom: 0;
    left: 0;
    height: 3px;
    width: 40%;
    background: var(--pico-primary-hover-background);
    animation: ai-progress 1.4s ease-in-out infinite;
}

#ai-stop-btn.ai-btn-loading { cursor: pointer; }

@keyframes ai-progress {
    0%   { left: -40%; }
    100% { left: 100%; }
}

.ai-open-btn {
    display: inline-block;
    margin-top: 0.5rem;
    padding: 0.4rem 0.8rem;
    border-radius: 0.5rem;
    border: 1px solid var(--pico-primary);
    color: var(--pico-primary);
    font-size: 0.85em;
    text-decoration: none;
    word-break: break-all;
}

/* A round that failed (usually a dropped connection the reattach couldn't recover).
   The bubble is the retry control: clicking it picks up a reply the server may have
   finished in the meantime, or regenerates the turn. */
.ai-msg.ai-msg-error {
    border-color: #c0392b;
    cursor: pointer;
}

.ai-msg.ai-msg-error:hover,
.ai-msg.ai-msg-error:focus-visible {
    background: var(--pico-form-element-background-color);
}

/* The failure itself, shown under whatever prose the round had already streamed
   (showError keeps that rather than replacing it). Its own colour so the reason is
   legible as an error even when it follows a page of normal reply text. */
.ai-error-line {
    display: block;
    margin-top: 0.35rem;
    color: #c0392b;
}

/* Nothing above it means the bubble is nothing but the error — no leading gap. */
.ai-error-line:first-child {
    margin-top: 0;
}

.ai-retry-hint {
    display: block;
    margin-top: 0.35rem;
    font-size: 0.8em;
    color: var(--pico-muted-color);
}

/* Shown in place of nothing while a dropped stream is being reattached, so a frozen
   half-written reply doesn't read as a hang. */
.ai-reconnecting {
    display: block;
    margin-top: 0.35rem;
    font-size: 0.8em;
    color: var(--pico-muted-color);
    font-style: italic;
}

/* ── Plyr dark theme — matches site dark background ──────────────────────── */

/* Center controls for all Plyr instances (audio and video) */
.plyr__controls {
    justify-content: center !important;
    gap: 0.25rem;
}

/* Keep the progress bar stretched; it uses flex:1 and must not shrink */
.plyr__controls .plyr__progress__container {
    flex: 1 !important;
    min-width: 0 !important;
}

.plyr--audio {
    --plyr-color-main:                         var(--pico-primary);
    --plyr-audio-progress-buffered-background: rgba(255,255,255,0.15);
    --plyr-range-fill-background:              var(--pico-primary-hover-background);
    --plyr-range-thumb-background:             #fff;
    --plyr-tooltip-background:                 var(--pico-dropdown-background-color);
    --plyr-tooltip-color:                      #fff;
    --plyr-tooltip-shadow:                     0 1px 3px rgba(0,0,0,0.6);
    border-radius: var(--pico-border-radius);
}

/* !important overrides everything regardless of specificity or load order.

   The controls bar sits on the one flat base surface defined at the end of this
   file, not on Pico's old card colour — the player is part of the page, not a
   panel floating on it, and a lighter bar was the last visible seam. The tooltip
   and the volume popup above stay on the lifted popover surface on purpose:
   those float OVER the bar and need to separate from it. */
.plyr--audio .plyr__controls {
    background: var(--pico-background-color) !important;
    color: var(--pico-color) !important;
    align-items: center !important;
}
.plyr--audio .plyr__control {
    background: transparent !important;
    color: var(--pico-color) !important;
    /* Pico sets line-height:1.5 on all buttons, making them taller than the
       sliders and breaking vertical alignment in the flex controls bar. */
    line-height: 1 !important;
    padding: calc(var(--plyr-control-spacing, 10px) * 0.7) !important;
    border: 0 !important;
    margin: 0 !important;
    align-self: center !important;
}
.plyr--audio .plyr__time {
    color: var(--pico-primary) !important;
}
/* Target SVG fill directly — currentColor inheritance doesn't reliably
   propagate into <use> shadow DOM on all WebView versions. */
.plyr--audio .plyr__control svg {
    fill: var(--pico-primary) !important;
}
/* Hover draws a ring instead of filling the control. Inset box-shadow rather than
   a border: these buttons are laid out flush in a flex bar, and a border would move
   the icon by a pixel on every hover. It follows Plyr's own border-radius for free.
   !important throughout — plyr.css is linked after app.css. */
.plyr--audio .plyr__control:hover,
.plyr--audio .plyr__control:focus,
.plyr--audio .plyr__control:active {
    background: transparent !important;
    box-shadow: inset 0 0 0 1px var(--pico-primary) !important;
    color: var(--pico-primary) !important;
}
.plyr--audio .plyr__control:hover svg,
.plyr--audio .plyr__control:focus svg,
.plyr--audio .plyr__control:active svg {
    fill: var(--pico-primary) !important;
}
#player-section .plyr__controls {
    gap: 0.75rem;
}

/* ── Plyr video — dark theme matching audio controls ─────────────────────── */
.plyr--video {
    --plyr-color-main:                          var(--pico-primary);
    --plyr-video-control-color:                 #c2c7d0;
    --plyr-video-control-color-hover:           #fff;
    --plyr-video-control-background-hover:      rgba(255,255,255,0.15);
    --plyr-video-controls-background:           linear-gradient(rgba(0,0,0,0), rgba(0,0,0,0.75));
    --plyr-video-progress-buffered-background:  rgba(255,255,255,0.2);
    --plyr-range-fill-background:               var(--pico-primary-hover-background);
    --plyr-range-thumb-background:              #fff;
    --plyr-tooltip-background:                  var(--pico-dropdown-background-color);
    --plyr-tooltip-color:                       #fff;
    --plyr-tooltip-shadow:                      0 1px 3px rgba(0,0,0,0.6);
}

/* Fix Pico's line-height on video controls buttons (same issue as audio) */
.plyr--video .plyr__control {
    line-height: 1 !important;
    padding: calc(var(--plyr-control-spacing, 10px) * 0.7) !important;
    border: 0 !important;
    margin: 0 !important;
    align-self: center !important;
}

/* Explicitly set SVG fill so it follows --plyr-video-control-color */
.plyr--video .plyr__control svg {
    fill: #c2c7d0;
}

/* Play/pause, mute, captions, and fullscreen icons — use pico primary (green).
   Must use fill + !important: currentColor doesn't reliably propagate
   through SVG <use> shadow DOM. */
.plyr--video .plyr__control[data-plyr="play"] svg,
.plyr--video .plyr__control[data-plyr="pause"] svg,
.plyr--video .plyr__control[data-plyr="mute"] svg,
.plyr--video .plyr__control[data-plyr="captions"] svg,
.plyr--video .plyr__control[data-plyr="fullscreen"] svg,
.plyr--video .plyr__control[data-plyr="exit-fullscreen"] svg {
    fill: var(--pico-primary) !important;
}

/* Hover/focus/active — use pico primary as highlight */
.plyr--video .plyr__control:hover svg,
.plyr--video .plyr__control:focus svg,
.plyr--video .plyr__control:active svg {
    fill: #fff;
}

/* Time display */
.plyr--video .plyr__time {
    color: var(--pico-primary) !important;
}

/* Overlaid play button (big centre button when paused). Ring and accent icon like
   everything else, but it keeps a ground — a neutral scrim, not the accent slab it
   used to be. It floats over an arbitrary paused frame, and a fully transparent
   button is unreadable against a bright one. Same reasoning as the .ai-thumb-x
   above: a scrim is a legibility device, not part of the accent language. */
.plyr--video .plyr__control--overlaid {
    background: rgba(0, 0, 0, 0.55) !important;
    box-shadow: inset 0 0 0 2px var(--pico-primary) !important;
    color: var(--pico-primary) !important;
}
/* Explicitly fill the overlaid button's SVG icon — currentColor doesn't reliably
   reach SVG <use> shadow DOM. */
.plyr--video .plyr__control--overlaid svg {
    fill: var(--pico-primary) !important;
}

/* ── Plyr volume bar — vertical slider popup above the mute button on hover ──
   Desktop only (touch hides the whole control, see the @media below).

   The slider is Plyr's normal HORIZONTAL range simply rotated -90deg. Rotating
   the horizontal input (rather than using a native vertical range) keeps Plyr's
   --value-driven fill gradient — linear-gradient(to right, ...) on
   ::-webkit-slider-runnable-track — working: after -90deg the fill runs
   bottom→top (100% at the top), and avoids fragile native vertical-range
   support in WebView.

   Centering: .plyr__volume is position:relative and, in Plyr's markup, contains
   both the mute <button> and this <input>. translate(-50%, 50%) places the box
   centre on the parent's horizontal 50% line regardless of the box size, so the
   rotated bar is always centred over the mute button. The `bottom` offset is the
   one tuning knob for vertical placement — it lifts the bar clear of the control
   row while keeping a small overlap at its base so there is no hover dead-zone
   (the popup is a DOM child of .plyr__volume, so hovering it keeps
   .plyr__volume:hover active even though it floats outside the parent's box). */
.plyr__volume input[type=range] {
    --vol-length: 90px;                 /* vertical travel length once rotated */
    /* plyr.css loads AFTER app.css and sets position:relative / z-index:2 on this
       same selector, so position and z-index MUST be !important to win. */
    position: absolute !important;
    left: 50%;
    bottom: calc(100% + 47px);          /* tuning knob: lifts bar, ~8px base overlap */
    width: var(--vol-length) !important;
    min-width: var(--vol-length) !important;
    max-width: var(--vol-length) !important;
    margin: 0 !important;
    /* Top/bottom padding becomes the bar's horizontal THICKNESS after the -90deg
       rotation — i.e. the width of the hover hit-area. Keep it generous so the
       cursor can drift sideways while sliding up/down without dropping hover. */
    padding: 16px 10px !important;
    background: var(--pico-dropdown-background-color);  /* lifted: it floats over the bar */
    border-radius: 6px;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
    transform-origin: center;
    transform: translate(-50%, 50%) rotate(-90deg);
    opacity: 0;
    pointer-events: none;
    transition: opacity 0.2s ease;
    z-index: 3 !important;
}

.plyr__volume:hover input[type=range] {
    opacity: 1;
    pointer-events: auto;
}

/* The popup floats above the button with a gap between them; crossing that gap
   would leave both the button and the slider, dropping :hover and hiding the
   bar mid-move. This invisible bridge spans the gap so :hover stays alive. It is
   present only while already hovering (so it never becomes a stray trigger, e.g.
   over a video) and sits entirely above the button (bottom:100%) so it never
   blocks mute clicks. Height comfortably covers the ~47px lift. */
.plyr__volume:hover::before {
    content: '';
    position: absolute;
    left: 50%;
    bottom: 100%;
    transform: translateX(-50%);
    width: 60px;
    height: 70px;
    z-index: 2;
}

/* Touch/mobile: hide the entire volume control — ExoPlayer mute is unsupported
   and hardware volume buttons are the expected mobile control. */
@media (hover: none) {
    .plyr__volume,
    [data-plyr="mute"],
    [data-plyr="volume"] {
        display: none !important;
    }
}

/* ── Plyr fullscreen — fix top cutoff and ensure video fills the screen ───── */

/* CSS fallback fullscreen (Plyr's own implementation, no native API).
   Use inset:0 instead of width/height percentages — on some WebViews height:100%
   on a fixed element is relative to the document, not the viewport, leaving a gap. */
.plyr--fullscreen-fallback {
    position: fixed !important;
    inset: 0 !important;
    margin: 0 !important;
    z-index: 9999 !important;
    background: #000 !important;
}

/* Video wrapper fills the whole fixed container; controls overlay absolutely.
   padding-bottom: 0 clears Plyr's inline aspect-ratio trick (style="padding-bottom:56.25%")
   which otherwise caps the wrapper height to the video's native ratio. */
.plyr--fullscreen-fallback .plyr__video-wrapper {
    position: absolute !important;
    inset: 0 !important;
    height: 100% !important;
    padding-bottom: 0 !important;
}

/* Video fills its wrapper with letterboxing — always flush with two sides. */
.plyr--fullscreen-fallback .plyr__video-wrapper video {
    width: 100% !important;
    height: 100% !important;
    object-fit: contain !important;
    max-height: none !important;
}

/* Controls always visible in fullscreen — override Plyr's auto-hide (opacity:0) */
.plyr--fullscreen-fallback .plyr__controls,
.plyr--hide-controls.plyr--fullscreen-fallback .plyr__controls {
    opacity: 1 !important;
    visibility: visible !important;
    transform: translateY(0) !important;
}

/* Gradient overlay so controls are readable over the video */
.plyr--fullscreen-fallback .plyr__controls {
    background: linear-gradient(rgba(0,0,0,0), rgba(0,0,0,0.75)) !important;
    padding-bottom: env(safe-area-inset-bottom, 8px) !important;
}

/* Tap-to-play: remove 300ms touch delay from the video area */
.plyr--video .plyr__video-wrapper {
    touch-action: manipulation;
}

/* Hide cursor in fullscreen when Plyr hides its controls */
.plyr--fullscreen.plyr--hide-controls,
.plyr--fullscreen-fallback.plyr--hide-controls {
    cursor: none !important;
}
.plyr--fullscreen.plyr--hide-controls *,
.plyr--fullscreen-fallback.plyr--hide-controls * {
    cursor: none !important;
}

/* Plyr fullscreens the .plyr container, so .plyr:fullscreen is the correct selector.
   video:fullscreen never matches because the video element is not the fullscreen target.
   The wrapper keeps its aspect-ratio: 16/9 so controls always sit flush below the video.
   max-height: 100vh prevents it overflowing on screens wider than 16:9 (e.g. ultrawide
   or landscape phone); in that case aspect-ratio + max-height together shrink the width
   so the video still touches exactly two sides.
   max-height: none on the inner video clears the .media-viewer video constraint. */
.plyr:-webkit-full-screen .plyr__video-wrapper,
.plyr:fullscreen .plyr__video-wrapper {
    max-height: 100vh !important;
    padding-bottom: 0 !important;
}
.plyr:-webkit-full-screen video,
.plyr:fullscreen video {
    max-height: none !important;
}

/* YouTube — Manage page: channel names in the theme accent color. Set here
   (not inline) because the site CSP (style-src 'self') drops inline colors. */
.yt-channel-name {
    color: var(--pico-primary);
}

/* ── Planning calendar — month grid ──────────────────────────────────────── */
.cal-toolbar {
    align-items: center;
    margin-bottom: 0;
}

.cal-toolbar .cal-title {
    font-size: 1.15rem;
    white-space: nowrap;
}

/* Month/year pickers sit centered between the prev/next arrows. */
.cal-toolbar ul {
    align-items: center;
}

.cal-toolbar .cal-month-li,
.cal-toolbar .cal-year-li {
    padding: 0;
}

.cal-toolbar .cs-wrapper {
    width: auto;
    margin: 0;
}

.cal-toolbar .cal-month-li .cs-wrapper {
    min-width: 8rem;
}

.cal-toolbar .cal-year-li .cs-wrapper {
    min-width: 5.5rem;
}

.cal-today-link {
    text-align: center;
    margin: 0 0 0.75rem;
}

.cal-grid {
    display: grid;
    grid-template-columns: repeat(7, 1fr);
    gap: 0.3rem;
}

.cal-dow-row {
    margin-bottom: 0.3rem;
}

.cal-dow {
    text-align: center;
    font-size: 0.75rem;
    font-weight: bold;
    color: var(--pico-muted-color);
    padding: 0.2rem 0;
}

.cal-cell {
    min-height: 5.5rem;
    border: 1px solid var(--pico-muted-border-color);
    border-radius: var(--pico-border-radius);
    padding: 0.25rem;
    background: var(--pico-card-background-color);
    cursor: pointer;
    overflow: hidden;
    display: flex;
    flex-direction: column;
    gap: 0.2rem;
    transition: border-color 0.15s;
}

.cal-cell:hover {
    border-color: var(--pico-primary);
}

.cal-cell.cal-empty {
    background: transparent;
    border-color: transparent;
    cursor: default;
}

.cal-cell.cal-today {
    border-color: var(--pico-primary);
}

.cal-daynum {
    font-size: 0.8rem;
    font-weight: bold;
    color: var(--pico-muted-color);
    line-height: 1;
}

.cal-cell.cal-today .cal-daynum {
    color: var(--pico-primary);
}

/* Weekday name shown only in the small-screen list view (see media query). */
.cal-cell-dow {
    display: none;
}

.cal-events {
    display: flex;
    flex-direction: column;
    gap: 0.15rem;
    min-width: 0;
}

.cal-event {
    display: block;
    width: 100%;
    text-align: left;
    margin: 0;
    padding: 0.1rem 0.3rem;
    font-size: 0.72rem;
    line-height: 1.25;
    /* 1px, not the 2px the primary actions carry: a day cell can hold several of
       these stacked, and at 0.72rem a heavy ring turns the grid into a fence. */
    border: 1px solid var(--pico-primary);
    border-radius: 0.25rem;
    background: transparent;
    color: var(--pico-primary);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    cursor: pointer;
}

.cal-event-time {
    font-weight: bold;
    opacity: 0.85;
}

/* Add/edit dialog. Pin it near the top with enough clearance that the header
   isn't hidden behind the mobile status/notification bar (safe-area inset plus
   a fixed fallback), and cap the height so a tall form scrolls inside the
   dialog rather than overflowing off the top of the screen. */
#cal-dialog {
    align-items: flex-start;
}

#cal-dialog article {
    margin-top: var(--overlay-safe-top);
    margin-bottom: 2rem;
    max-height: calc(100dvh - 5rem);
    overflow-y: auto;
}

#cal-dialog .cal-optional {
    color: var(--pico-muted-color);
    font-weight: normal;
}

.cal-error {
    color: #f87171;
    min-height: 1.2em;
    margin: 0 0 0.5rem;
    font-size: 0.9rem;
}

.cal-skip-hint {
    color: var(--pico-muted-color);
    font-size: 0.85rem;
    margin: 0 0 0.5rem;
}

/* Skip popup: two equal buttons sharing the row, neatly aligned. */
.cal-skip-actions {
    display: flex;
    gap: 0.5rem;
}

.cal-skip-actions button {
    margin: 0;
    flex: 1;
}

/* Buttons stack vertically. DOM order is Cancel, Skip this day, Delete series,
   Save; column-reverse flips that to Save (top), Delete series, Skip this day,
   Cancel (bottom). Hidden buttons (display:none) drop out, so add-mode and
   non-recurring edits keep the same top-down order. */
.cal-dialog-actions {
    display: flex;
    flex-direction: column-reverse;
    gap: 0.5rem;
    align-items: stretch;
}

.cal-dialog-actions button {
    margin: 0;
    width: 100%;
}

.cal-delete-btn {
    border-color: #c0392b !important;
    color: #c0392b !important;
}

.cal-event-repeat {
    opacity: 0.85;
}

/* Event colour picker. The swatches are rings, not filled dots: an empty ring in
   the colour is what the chip itself will look like on the grid, so the picker is a
   preview rather than a legend. The selected one thickens to 2px and grows a filled
   centre — the same 1px/2px weighting the dropdown uses for hovered vs selected. */
.cal-color-fs {
    margin-bottom: 1rem;
}

.cal-color-row {
    display: flex;
    flex-wrap: wrap;
    gap: 0.4rem;
}

.cal-color-swatch {
    width: 1.75rem;
    height: 1.75rem;
    min-width: 0;
    padding: 0;
    margin: 0;
    border-radius: 50%;
    background: transparent;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    line-height: 1;
}

/* First swatch: no .swatch-* class, so it shows the live theme accent. */
.cal-color-theme {
    border-color: var(--pico-primary);
    color: var(--pico-primary);
}

.cal-color-swatch.cal-color-on {
    border-width: 2px;
}

.cal-color-swatch.cal-color-on::after {
    content: "";
    width: 0.6rem;
    height: 0.6rem;
    border-radius: 50%;
    background: currentColor;  /* .swatch-* sets `color`, so the dot is free */
}

/* Add/edit dialog — 12-hour time row */
.cal-allday-label {
    display: flex;
    align-items: center;
    gap: 0.4rem;
}

.cal-time-row {
    display: flex;
    align-items: center;
    gap: 0.4rem;
    margin-bottom: 1rem;
}

.cal-time-row select {
    margin: 0;
    width: auto;
    flex: 0 0 auto;
}

/* The selects are replaced by custom-select wrappers; keep them compact and
   inline rather than the default full-width .cs-wrapper. */
.cal-time-row .cs-wrapper {
    width: auto;
    min-width: 4.75rem;
    flex: 0 0 auto;
    margin: 0;
}

.cal-time-colon {
    font-weight: bold;
}

/* The optional end time. Its checkbox follows the start row (which carries the 1rem
   below it), so all this needs is the smaller gap between the question and the selects
   it opens — otherwise the row would sit flush against its own label. */
.cal-endtime-label {
    margin-bottom: 0.5rem;
}

/* Repeat-rule sub-sections — shown by JS only for the relevant repeat type. */
.cal-repeat-section {
    display: none;
}

.cal-weekday-grid {
    display: flex;
    flex-direction: column;
    gap: 0.25rem;
}

.cal-weekday-grid label {
    display: flex;
    align-items: center;
    gap: 0.35rem;
    margin: 0;
    font-size: 0.9rem;
}

.cal-skip-row {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    margin-bottom: 1rem;
}

.cal-skip-row input {
    width: 5rem;
    margin: 0;
}

.cal-skip-row select {
    width: auto;
    margin: 0;
}

.cal-skip-row .cs-wrapper {
    width: auto;
    min-width: 8rem;
    flex: 0 0 auto;
    margin: 0;
}

/* Holiday "Nth weekday" rows: ordinal + weekday, then cadence. */
.cal-nthdow-row {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    margin-bottom: 0.5rem;
    flex-wrap: wrap;
}

.cal-nthdow-row:last-child {
    margin-bottom: 1rem;
}

.cal-nthdow-row .cs-wrapper {
    width: auto;
    min-width: 6.5rem;
    flex: 0 0 auto;
    margin: 0;
}

/* Small screens: the 7-column grid gets too cramped, so fall back to a
   single-column vertical list (one row per day of the month). The weekday
   column header is dropped; each row shows its own weekday + date instead,
   and the empty leading/trailing padding cells are hidden. */
@media (max-width: 600px) {
    .cal-dow-row {
        display: none;
    }
    .cal-grid {
        grid-template-columns: 1fr;
        gap: 0.4rem;
    }
    .cal-cell.cal-empty {
        display: none;
    }
    .cal-cell {
        min-height: 0;
        flex-direction: row;
        align-items: flex-start;
        gap: 0.6rem;
        padding: 0.5rem 0.6rem;
    }
    .cal-cell-dow {
        display: inline;
    }
    .cal-daynum {
        flex: 0 0 auto;
        min-width: 6.5rem;
        font-size: 0.9rem;
        /* Match the first event's line-height and top padding so the day
           name/date sits level with it instead of riding high (line-height:1
           in the base rule otherwise leaves a descender gap below the text). */
        line-height: 1.25;
        padding-top: 0.2rem;
    }
    .cal-events {
        flex: 1;
    }
    .cal-event {
        font-size: 0.8rem;
        padding: 0.2rem 0.4rem;
    }
}

/* ── Planning weather ────────────────────────────────────────────────────── */
.wx-add {
    margin-bottom: 0.75rem;
}

.wx-add #wx-search {
    margin-bottom: 0;
}

.wx-results {
    list-style: none;
    margin: 0.25rem 0 0;
    padding: 0;
}

.wx-results:not(:empty) {
    border: 1px solid var(--pico-muted-border-color);
    border-radius: var(--pico-border-radius);
    overflow: hidden;
}

.wx-result, .wx-result-empty {
    padding: 0.5rem 0.75rem;
    cursor: pointer;
    border-bottom: 1px solid var(--pico-muted-border-color);
}

.wx-result:last-child, .wx-result-empty:last-child { border-bottom: 0; }
/* Hover comes from .cs-hl, applied by custom_select.js — see highlightRows(). */
.wx-result-empty { color: var(--pico-muted-color); cursor: default; }

.wx-loc-bar {
    display: flex;
    gap: 0.5rem;
    align-items: stretch;
    margin-bottom: 1rem;
}

/* Make the wrapper a flex container so its trigger stretches to the row height
   and matches the delete button exactly. (A percentage height wouldn't resolve
   reliably against a flex-stretched parent, leaving the button taller.) */
.wx-loc-bar .cs-wrapper {
    flex: 1;
    min-width: 0;
    display: flex;
    margin: 0;          /* base .cs-wrapper has margin-bottom:0.5rem, which under
                           align-items:stretch shrinks the trigger and leaves the
                           delete button extending lower */
}

.wx-loc-bar .cs-trigger {
    flex: 1;
}

.wx-loc-del-btn {
    margin: 0;
    width: auto;
    flex: 0 0 auto;
    color: var(--pico-muted-color);
    border-color: var(--pico-muted-border-color);
}

.wx-loc-del-btn:hover {
    color: #f87171;
    border-color: #f87171;
}

.wx-empty, .wx-msg {
    color: var(--pico-muted-color);
}

/* Current conditions card */
.wx-current {
    display: flex;
    align-items: center;
    gap: 1rem;
    padding: 1rem 1.25rem;
    background: var(--pico-card-background-color);
    border: 1px solid var(--pico-muted-border-color);
    border-radius: var(--pico-border-radius);
}

.wx-current-emoji { font-size: 3.5rem; line-height: 1; }
.wx-current-mid { min-width: 0; }
.wx-current-temp { font-size: 2.5rem; font-weight: bold; line-height: 1.1; }
.wx-current-cond { color: var(--pico-color); }
.wx-current-label { color: var(--pico-muted-color); font-size: 0.9rem; overflow: hidden; text-overflow: ellipsis; }

.wx-details {
    display: flex;
    gap: 0.5rem;
    margin: 0.75rem 0;
}

.wx-detail {
    flex: 1;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 0.15rem;
    padding: 0.6rem 0.4rem;
    background: var(--pico-card-background-color);
    border: 1px solid var(--pico-muted-border-color);
    border-radius: var(--pico-border-radius);
}

.wx-detail-label { color: var(--pico-muted-color); font-size: 0.75rem; }
.wx-detail-value { font-weight: bold; }

/* 7-day strip */
.wx-week {
    display: flex;
    gap: 0.4rem;
    overflow-x: auto;
}

.wx-day {
    flex: 1 0 4rem;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 0.15rem;
    padding: 0.6rem 0.3rem;
    background: var(--pico-card-background-color);
    border: 1px solid var(--pico-muted-border-color);
    border-radius: var(--pico-border-radius);
}

.wx-day { cursor: pointer; }
.wx-day:hover { border-color: var(--pico-primary); }
.wx-day-active { border-color: var(--pico-primary); }
.wx-day-name { font-size: 0.8rem; color: var(--pico-muted-color); }
.wx-day-emoji { font-size: 1.5rem; line-height: 1; }
.wx-day-hi { font-weight: bold; }
.wx-day-lo { color: var(--pico-muted-color); font-size: 0.85rem; }
.wx-day-precip { font-size: 0.7rem; color: var(--pico-primary); }

/* Hourly breakdown (shown when a day is clicked) */
.wx-hourly { margin-top: 0.75rem; }

.wx-hours {
    display: flex;
    gap: 0.4rem;
    overflow-x: auto;
}

.wx-hour {
    flex: 0 0 auto;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 0.15rem;
    min-width: 3.2rem;
    padding: 0.5rem 0.3rem;
    background: var(--pico-card-background-color);
    border: 1px solid var(--pico-muted-border-color);
    border-radius: var(--pico-border-radius);
}

.wx-hour-now {
    border-color: var(--pico-primary);
}

.wx-hour-now .wx-hour-time { color: var(--pico-primary); font-weight: bold; }

.wx-hour-time { font-size: 0.72rem; color: var(--pico-muted-color); white-space: nowrap; }
.wx-hour-emoji { font-size: 1.2rem; line-height: 1; }
.wx-hour-temp { font-weight: bold; font-size: 0.85rem; }
.wx-hour-precip { font-size: 0.65rem; color: var(--pico-primary); }

/* ---------------------------------------------------------------------------
   PDF viewer (C7)
   Moved here from the inline <style> blocks that used to sit in the two PDF
   viewer pages (filemgr/view.py and sharing.py). The site's CSP sets
   "style-src 'self'" with no 'unsafe-inline', which blocks inline <style>
   elements outright — so those rules were never actually applied and the
   viewer rendered without its scroll container or backdrop. Both pages already
   link this file, so defining the rules once here fixes them without relaxing
   the policy. plyr.css (which loads after app.css) is only pulled in for
   audio/video, so there is no override-order concern on a PDF page.
   --------------------------------------------------------------------------- */
#pdf-wrap {
    width: 100%;
    max-height: calc(100vh - 6rem);
    overflow-y: auto;
    /* No backdrop here on purpose. The upstream inline rule set background:#525659 —
       the usual grey gutter around a rendered page — but pdf_viewer.js keeps
       #pdf-canvas at display:none until the first page has rendered, so before that
       this wrapper is an EMPTY grey band sitting above the page ("Loading PDF...").
       Since these rules were previously blocked by CSP and never applied, the look
       everyone is used to has no backdrop at all, and a rendered page reads fine
       directly on the page background. The parts actually worth having are the
       scroll container and the canvas constraint below, so keep those and drop the
       gutter rather than paper over the empty state. */
    display: flex;
    justify-content: center;
    align-items: flex-start;
}

#pdf-canvas {
    display: block;
    max-width: 100%;
}

#pdf-page-info {
    text-align: center;
    padding: 0.5rem 0;
    font-size: 0.9rem;
    color: var(--pico-muted-color);
}

#pdf-status {
    padding: 2rem;
    color: var(--pico-muted-color);
    font-size: 0.9rem;
}

/* ── Cyberpunk accents ────────────────────────────────────────────────────────
   Deliberately restrained: this is a file and media server, and anything that
   fights legibility or sits on top of video is a bug, not a effect.

   Everything keys off --pico-primary* rather than a hardcoded hue, so it follows
   the colour picker (color.py, 18 presets) instead of going stale the moment you
   switch away from cyan. --pico-primary-focus is already an rgba (alpha .375),
   which is what the glows use — no color-mix() needed, so no browser-support
   caveat.

   Every page pins data-theme="dark" (see the <html> tag in home.py et al), so
   these are tuned for dark only and need no light-mode guard.

   To remove any one of these, delete its block — they are independent.
   ───────────────────────────────────────────────────────────────────────────── */

/* 1 — Selection. Accent plate, dark ink, no inherited glow. */
::selection {
    background: var(--pico-primary-hover-background);
    color: #06080c;
    text-shadow: none;
}

/* 2 — Focus ring. Doubles as an accessibility improvement: :focus-visible only,
   so it shows for keyboard navigation and not on every mouse click. */
:focus-visible {
    outline: 2px solid var(--pico-primary);
    outline-offset: 2px;
    box-shadow: 0 0 0.75rem var(--pico-primary-focus);
}

/* 2b — ...but never inside the media player.

   Two separate sources produced the ring in the screenshots:
     * effect 2 above (a universal :focus-visible) caught the player container;
     * Plyr's OWN css draws `outline: 2px dashed var(--plyr-color-main)` on
       .plyr__control:focus-visible and on the range tracks — that is the dashed
       one, and it inherits the site accent, which is why it came out red.

   It only appears after click-then-keyboard because that is exactly when the
   browser's :focus-visible heuristic fires: the element is already focused from
   the mouse, and the first key press promotes it.

   !important is required: plyr.css is linked AFTER app.css (playlists.py:441 vs
   :327), so an equal-specificity rule here loses.

   Removing it costs no accessibility — focus stays visible through the existing
   rules further down this file: .plyr--audio .plyr__control:focus swaps the
   button background to the accent, and .plyr--video .plyr__control:focus svg
   turns the icon white. Both are stronger indicators than a 2px dashed box. */
.plyr:focus,
.plyr:focus-visible,
.plyr__video-wrapper:focus,
.plyr__video-wrapper:focus-visible,
.plyr video:focus,
.plyr video:focus-visible,
video:focus,
video:focus-visible,
audio:focus,
audio:focus-visible,
.plyr__control:focus-visible,
.plyr *:focus-visible {
    outline: none !important;
    box-shadow: none !important;
}

/* The range tracks are pseudo-elements, so they need their own rules — the
   selector above cannot reach them. */
.plyr--full-ui input[type="range"]:focus-visible::-webkit-slider-runnable-track,
.plyr--full-ui input[type="range"]:focus-visible::-moz-range-track {
    outline: none !important;
}

/* 3 — Heading glow. Barely-there bloom on the Orbitron headings. Kept to h1-h3
   for the same reason the display face is: h4-h6 are dense inline labels. */
h1, h2, h3 {
    text-shadow: 0 0 1.35rem var(--pico-primary-focus),
                 0 0 0.25rem var(--pico-primary-focus);
}

/* 4 — Chromatic split, on hover, on everything that glows. Two offset shadows in
   the classic red/cyan mis-registration, decaying back into the resting bloom.

   `nav ul li strong` is the site-wide PAGE TITLE element, not just the brand
   lockup — it is the same selector color.py's _TITLE_RULE paints with the accent,
   and it appears in 18 modules (Folders, Playlists, Radio, AI, Access Logs, and
   the dynamic {{ folder }} / {{ filename }} titles). article h2 is _TITLE_RULE's
   other half, so section headings inside cards get it too.

   Two speeds on one keyframe. Titles are hovered occasionally and can afford the
   full 690ms read. Buttons are hovered constantly in normal use, so they run the
   same animation at 400ms — long enough to register, short enough that it never
   becomes a toll on every click.

   ONE keyframe set drives every glitch on the site — headings, buttons, dropdown
   rows, the AI streaming edge. Amplitude, decay and opacity live in @keyframes
   site-glitch and nowhere else, so the effect cannot drift out of step between
   places: a per-site variant is how you end up with a trigger that tears twice as
   hard as the rows it opens. Only the DURATION varies, and only by trigger type.

   The split opens at 0.19em (roughly two character-widths of mis-registration at
   heading sizes) with a small vertical component so the two channels tear apart
   rather than just sliding sideways, and the opacity dips to 0.70 on the offbeats
   so the text visibly drops frames before settling. It decays over eleven stops
   rather than nine, which keeps the fall-off readable at the larger amplitude
   instead of collapsing in two jumps.

   The offsets are in em and the glow radii in rem, deliberately: the tear belongs
   to the glyphs, so it scales with them and a 2rem heading and a 0.9rem list row
   mis-register by the same fraction of a character; the bloom belongs to the
   surface, and matches the rem-based resting glow in section 3 rather than
   ballooning on large text.

   steps() rather than a smooth ease: the point is digital mis-registration, and
   interpolating between the offsets just reads as a blur. Fewer steps per segment
   (3, not 5) at this amplitude — each jump is bigger, so coarser reads as harder
   snapping rather than as stutter.

   Non-intrusive by construction: needs a deliberate hover, runs once (no loop),
   and is disabled entirely under prefers-reduced-motion below. */
nav ul li strong:hover,
hgroup > h1:hover,
article h2:hover {
    animation: site-glitch 690ms steps(3, end) 1;
}

/* Interactive tier — buttons, and the dropdown rows in section "Custom select"
   above, which are buttons in all but tag name. .cs-active is the same highlight
   arriving by keyboard or filter match instead of by pointer. */
[role="button"]:hover,
button:hover,
input[type="submit"]:hover,
summary[role="button"]:hover,
.cs-option:hover,
.cs-option.cs-active,
.cs-hl:hover,
.cs-hl.cs-active {
    animation: site-glitch 400ms steps(3, end) 1;
}

/* A colour swatch holds no text, so the glitch has nothing to mis-register and
   contributes only its opacity dropout — the swatch just blinks. Same reason Plyr's
   icon controls are excluded below. */
.cal-color-swatch:hover {
    animation: none;
}

/* An item you cannot pick shouldn't answer the pointer. This reset belongs with the
   rest of .cs-option-disabled far above, but it has the same specificity as the tier
   rule and would lose on source order, so it lives here instead. */
.cs-option-disabled:hover {
    animation: none;
}

/* Plyr's controls are <button class="plyr__control">, so the rule above catches
   every play/mute/fullscreen button in the player. It has no business there: the
   glitch animates text-shadow and opacity, and a control whose visible content is
   an SVG icon gets none of the split and all of the frame-dropping — the icon just
   blinks. The player keeps the accent background swap it defines further up
   instead. Class-count beats the bare `button:hover` above, so no !important. */
.plyr button:hover,
.plyr [role="button"]:hover,
.plyr__control:hover {
    animation: none;
}

@keyframes site-glitch {
    0%   { text-shadow: 0 0 1.2rem var(--pico-primary-focus); opacity: 1; }
    6%   { text-shadow: -0.190em -0.020em 0 rgba(255, 0, 64, 1), 0.190em 0.020em 0 rgba(0, 229, 255, 1); opacity: 0.78; }
    14%  { text-shadow: 0.165em 0.020em 0 rgba(255, 0, 64, 1), -0.165em -0.020em 0 rgba(0, 229, 255, 1), 0 0 0.5rem var(--pico-primary-focus); opacity: 1; }
    22%  { text-shadow: -0.148em 0.014em 0 rgba(255, 0, 64, 0.97), 0.148em -0.014em 0 rgba(0, 229, 255, 0.97); opacity: 0.70; }
    31%  { text-shadow: 0.126em -0.014em 0 rgba(255, 0, 64, 0.95), -0.126em 0.014em 0 rgba(0, 229, 255, 0.95), 0 0 0.7rem var(--pico-primary-focus); opacity: 1; }
    40%  { text-shadow: -0.104em 0 0 rgba(255, 0, 64, 0.9), 0.104em 0 0 rgba(0, 229, 255, 0.9); opacity: 0.84; }
    50%  { text-shadow: 0.086em 0.010em 0 rgba(255, 0, 64, 0.85), -0.086em -0.010em 0 rgba(0, 229, 255, 0.85), 0 0 0.85rem var(--pico-primary-focus); opacity: 1; }
    60%  { text-shadow: -0.066em 0 0 rgba(255, 0, 64, 0.78), 0.066em 0 0 rgba(0, 229, 255, 0.78); opacity: 0.90; }
    70%  { text-shadow: 0.048em -0.008em 0 rgba(255, 0, 64, 0.68), -0.048em 0.008em 0 rgba(0, 229, 255, 0.68), 0 0 1.0rem var(--pico-primary-focus); opacity: 1; }
    80%  { text-shadow: -0.032em 0 0 rgba(255, 0, 64, 0.55), 0.032em 0 0 rgba(0, 229, 255, 0.55); opacity: 0.96; }
    90%  { text-shadow: 0.016em 0 0 rgba(255, 0, 64, 0.35), -0.016em 0 0 rgba(0, 229, 255, 0.35), 0 0 1.1rem var(--pico-primary-focus); opacity: 1; }
    100% { text-shadow: 0 0 1.2rem var(--pico-primary-focus); opacity: 1; }
}

/* 5 — Buttons also keep an accent edge-glow on hover. The box-shadow and the
   text-shadow animation above are separate properties, so they compose rather
   than fight. */
[role="button"]:hover,
button:hover,
input[type="submit"]:hover {
    box-shadow: 0 0 1.1rem -0.12rem var(--pico-primary-focus);
}



/* 6 — Streaming edge. ai_chat.js wraps the trailing ~20 characters of a reply in
   .ai-edge on every token; the bubble is re-rendered each token, so the span is
   recreated and this animation restarts, making the split ride the leading edge
   of the text as it arrives. The span unwraps itself on animationend, so once the
   stream stops the last one settles and disappears.

   Same keyframes, same amplitude as everywhere else — this used to run a private,
   gentler curve (0.065em, no opacity dropout) on the grounds that it rides body
   text the reader is following rather than something they pointed at. That made it
   the one glitch on the site that read as a different effect, so it now shares
   site-glitch; 520ms is the only thing left that is its own, sitting between the
   heading and button speeds because it retriggers per token rather than per hover.

   No layout properties are animated — only text-shadow and opacity — so a long
   reply does not reflow on every token. */
.ai-edge {
    animation: site-glitch 520ms steps(3, end) 1;
}

/* Anything animated is opt-out. The static effects (selection, focus ring, glow,
   scanlines) stay — they convey state and carry no motion. */
@media (prefers-reduced-motion: reduce) {
    nav ul li strong:hover,
    hgroup > h1:hover,
    article h2:hover,
    [role="button"]:hover,
    button:hover,
    input[type="submit"]:hover,
    summary[role="button"]:hover,
    .cs-option:hover,
    .cs-option.cs-active,
    .cs-hl:hover,
    .cs-hl.cs-active,
    .ai-edge {
        animation: none;
    }

    /* .ai-edge still unwraps itself on animationend — which never fires when the
       animation is none. Give it a zero-length one so the span is still cleaned
       up rather than left wrapped around the tail of every stored reply. */
    .ai-edge {
        animation: site-glitch 1ms steps(1, end) 1;
        text-shadow: none;
    }
}

/* ── Flat dark surface ────────────────────────────────────────────────────────
   Pico's dark theme ships FOUR different near-blacks — page rgb(19,22.5,30.5),
   card #181c25, sectioning rgb(26,30.5,40.25), form rgb(28,33,43.5) — all
   blue-tinted and all slightly different. Stack them and every boundary reads as
   a band of lighter or darker black. It is most obvious in the AI chat, where a
   run of reply bubbles (.ai-msg-ai uses the card colour) sits on the page colour.

   This collapses the page/card/sectioning surfaces onto ONE darker, near-neutral
   base so those boundaries disappear.

   Two things stay deliberately lifted, because flat is not the goal everywhere:
     * form elements — an input the same colour as the page stops reading as a
       control at all;
     * dropdowns and modals — a popover must float above what it covers, or it
       looks like the page just changed.

   --pico-card-border-color becomes load-bearing. Pico defaults it to the card's
   own fill (i.e. invisible, structure carried by the fill contrast). With the
   fills flattened, that would make cards vanish entirely — so it gets a real
   value here, and cards read as outlines instead of blocks.

   These live at the end of app.css on purpose: same specificity as Pico's own
   [data-theme=dark] block, later in the cascade, so they win. /color.css is
   injected after this but only sets --pico-primary-*, so it never collides.
   ───────────────────────────────────────────────────────────────────────────── */
[data-theme=dark] {
    /* The one base surface. */
    --pico-background-color:                  #0a0b0e;
    --pico-card-background-color:             #0a0b0e;
    --pico-card-sectioning-background-color:  #0a0b0e;

    /* Structure now comes from edges, not fills. */
    --pico-card-border-color:                 #1b1f29;
    --pico-muted-border-color:                #1b1f29;

    /* Deliberately above the base — see the note above. */
    --pico-form-element-background-color:        #101319;
    --pico-form-element-active-background-color: #141821;
    --pico-form-element-selected-background-color: #141821;
    --pico-code-background-color:             #101319;
    --pico-dropdown-background-color:         #101319;
    --pico-dropdown-hover-background-color:   #171b24;
    --pico-progress-background-color:         #171b24;

    /* Darker page means the modal scrim has to go darker too, or it reads grey. */
    --pico-modal-overlay-background-color:    rgba(4, 5, 7, 0.8);
}


/* ── Filled buttons → ring ────────────────────────────────────────────────────
   Pico fills every button that isn't .outline with --pico-primary-background and
   inverts the text. That is the same solid accent slab the custom select used to
   paint behind a hovered row, and it fails the same way: at the red preset a large
   filled block washes out to pink, and it puts 23 shouting buttons among the 154
   .outline ones they sit next to.

   So they get the dropdown's treatment — transparent ground, accent ring, accent
   text. The icons inside are fill="currentColor" (templates/macros/icons.html), so
   they follow the text colour with no rule of their own.

   The ring is 2px where an .outline button's is 1px, and that carries the whole
   hierarchy this pass would otherwise have flattened: these are the one primary
   action of their page — Save, Upload, Download, Create folder — and the extra
   pixel keeps them first among their new lookalikes, exactly as the dropdown's
   selected row is 2px against a hovered row's 1px. box-sizing is border-box, so
   the second pixel costs no layout.

   This lives at the very bottom because it has to beat Pico, and it names its
   exceptions rather than fighting them: .cs-trigger is a form input wearing a
   button tag; .cal-event is a calendar chip; .ai-thumb-x is a scrim over a photo;
   Plyr owns its controls. #ai-stop-btn and #relay-delete-bar-btn win on their ids
   and keep the destructive red. .secondary/.contrast are Pico variants nothing
   currently uses unfilled — left alone rather than silently recoloured. */
button:not(.outline, .secondary, .contrast, .cs-trigger, .cal-event, .ai-thumb-x, .plyr__control),
input[type="submit"]:not(.outline, .secondary, .contrast),
input[type="button"]:not(.outline, .secondary, .contrast),
[role="button"]:not(.outline, .secondary, .contrast, .plyr__control) {
    background: transparent;
    border: 2px solid var(--pico-primary);
    color: var(--pico-primary);
}

button:not(.outline, .secondary, .contrast, .cs-trigger, .cal-event, .ai-thumb-x, .plyr__control):is(:hover, :focus, :active),
input[type="submit"]:not(.outline, .secondary, .contrast):is(:hover, :focus, :active),
input[type="button"]:not(.outline, .secondary, .contrast):is(:hover, :focus, :active),
[role="button"]:not(.outline, .secondary, .contrast, .plyr__control):is(:hover, :focus, :active) {
    background: transparent;
    border-color: var(--pico-primary-hover);
    color: var(--pico-primary-hover);
}

/* ── In-app dialogs (ui_dialog.js) ─────────────────────────────────────────
   The replacement for window.alert/confirm/prompt. One <dialog> node is reused
   for every call, so these rules describe the whole family. Pico already styles
   <dialog><article>; what is added here is the narrower measure a message box
   wants (Pico's default article is sized for a form), the stacked action row the
   calendar dialog established, and the chooser list used by the right-click menus.

   Pinned near the top for the same reason #cal-dialog is: on a phone a centred
   dialog with the keyboard open ends up under the status bar. Sits below the
   image lightbox's z-index so a dialog opened from it still lands on top. */
.ui-dialog {
    align-items: flex-start;
    z-index: 1100;
}

.ui-dialog article {
    margin-top: max(3rem, calc(env(safe-area-inset-top, 0px) + 1.5rem));
    margin-bottom: 2rem;
    max-height: calc(100dvh - 6rem);
    overflow-y: auto;
    max-width: 32rem;
}

/* No title on most calls (the natives had none either), and an empty <header>
   still draws Pico's bottom rule — so the header is hidden outright instead. */
.ui-dialog header[hidden] {
    display: none;
}

/* No bottom margin: Pico's own `article > footer` already brings a top margin and
   padding, and stacking ours on top of those left a visibly bigger gap above the first
   button than between the buttons themselves. */
.ui-dialog .ui-dialog-body {
    margin-bottom: 0;
}

/* Message paragraphs. A blank line in the source string becomes an empty <p>,
   which is what reproduces the "\n\n" spacing the old strings were written for. */
.ui-dialog-line {
    margin: 0 0 0.4rem;
    white-space: pre-wrap;
    word-break: break-word;
}

.ui-dialog-line:empty {
    margin: 0;
    height: 0.5rem;
}

.ui-dialog-line:last-child {
    margin-bottom: 0;
}

.ui-dialog .ui-dialog-body > input[type="text"] {
    margin-top: 0.75rem;
    margin-bottom: 0;
}

/* Multi-field prompt: label above its input, fields stacked. The class also lands on a
   bare <input> when a field has no caption, so the spacing is the same either way. */
.ui-dialog-field {
    display: block;
    margin-top: 0.75rem;
}

.ui-dialog-field input,
input.ui-dialog-field {
    margin-bottom: 0;
}

/* A caption-less field is the whole content of its dialog; no leading gap under the
   title, exactly as the single-field uiPrompt renders. */
.ui-dialog-body > .ui-dialog-field:first-child {
    margin-top: 0;
}

/* Pico spaces the buttons in a dialog footer with a left margin:

     dialog > article > footer button:not(:first-of-type) {
         margin-left: calc(var(--pico-spacing) * .5)
     }

   That assumes the footer is a horizontal row. Every dialog footer in this app is a
   stacked column (or a gap-spaced flex row) instead, so the margin lands as the second
   button sitting ~8px proud of the first — the buttons are the same width but visibly
   out of line with each other and with the field above them.

   Pico's selector is four elements plus a pseudo-class, which out-specifies a plain
   `.footer-class button`; that is why each selector below names its dialog as well,
   putting two classes (or an id) up against Pico's one pseudo-class. Applies to the
   calendar's two dialogs for the same reason — same rule, same symptom. */
.ui-dialog .ui-dialog-actions button,
#cal-dialog .cal-dialog-actions button,
#cal-skip-dialog .cal-skip-actions button {
    margin-left: 0;
}

/* Same column-reverse trick as .cal-dialog-actions: DOM order is Cancel then OK,
   which flips to OK on top — the confirming action nearest the thumb on a phone. */
.ui-dialog-actions {
    display: flex;
    flex-direction: column-reverse;
    gap: 0.5rem;
    align-items: stretch;
}

.ui-dialog-actions button {
    margin: 0;
    width: 100%;
}

/* Destructive confirmations (delete, re-lock, erase) carry the same red the
   calendar's Delete series button uses, so the dangerous choice never looks
   like the safe one. */
.ui-dialog-actions button.ui-dialog-danger {
    border-color: #c0392b !important;
    color: #c0392b !important;
}

/* Right-click action menus: one full-width button per choice. */
.ui-dialog-choices {
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
    margin-top: 0.5rem;
}

.ui-dialog-choices button {
    margin: 0;
    width: 100%;
}
/* Keep the gap from a description to the next headline compact. */
#news-list > article {
  padding-block: 0.5rem;
  margin-bottom: 0.5rem;
}

#news-list > article > :last-child {
  margin-bottom: 0;
}

/* Small site-styled warnings; validation feedback floats without moving fields. */
.site-field-warning {
    display: block;
    color: var(--pico-del-color, #c84f48);
    font-size: 0.875rem;
    line-height: 1.35;
    overflow-wrap: anywhere;
}
.site-validation-popover {
    position: fixed;
    z-index: 1200;
    width: max-content;
    max-width: min(22rem, calc(100vw - 16px));
    margin: 0;
    padding: 0.5rem 0.75rem;
    border: 1px solid var(--pico-del-color, #c84f48);
    border-radius: var(--pico-border-radius, 0.25rem);
    background: var(--pico-card-background-color, #181c25);
    box-shadow: 0 0.25rem 1rem #0003;
    pointer-events: none;
}

/* Markdown blocks stay within a chat bubble on narrow screens. */
.ai-msg { min-width: 0; }
.ai-msg :is(h1, h2, h3, h4, h5, h6) {
    font-size: 1.08em;
    line-height: 1.35;
    margin: 0.8rem 0 0.4rem;
}
.ai-msg :is(h1, h2, h3, h4, h5, h6):first-child { margin-top: 0; }
.ai-table-scroll { max-width: 100%; overflow-x: auto; margin: 0.6rem 0; }
.ai-table-scroll table { margin: 0; font-size: 0.9em; white-space: normal; }
.ai-table-scroll :is(th, td) { padding: 0.45rem 0.65rem; min-width: 7rem; }
.ai-msg .ai-code { max-width: 100%; overflow-x: auto; white-space: pre; padding: 0.7rem; }
