/* MeshBay Web Client — Phase 9.6 */

/* ── Theme variables ──────────────────────────────────────────────────────── */

:root, .theme-light {
  --bg-base: #f8fafc;
  --bg-surface: #ffffff;
  --bg-raised: #f1f5f9;
  --text: #0f172a;
  --text-secondary: #475569;
  --text-dim: #94a3b8;
  --border: #e2e8f0;
  --border-focus: #0ea5e9;
  --accent: #0ea5e9;
  --accent-hover: #0284c7;
  --accent-text: #ffffff;
  --error: #ef4444;
  --error-bg: #fef2f2;
  --success: #22c55e;
  --nav-bg: #0f172a;
  --nav-text: #e2e8f0;
  /* One step down from sky-400 towards sky-500: the wordmark sat brighter than
     the picture beside it, which made the two read as different materials. */
  --nav-brand: #23b1f0;
  --sidebar-bg: #f1f5f9;
  --sidebar-hover: #e2e8f0;
  /* Used with a literal fallback in a handful of rules before they were
     defined here — same values, so nothing moves; now themeable, and the
     names in those rules are real. */
  --warn: #d97706;
  --accent-bg: rgba(59, 130, 246, 0.1);
  --shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
  --shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.1);
}

.theme-dark {
  --bg-base: #0f172a;
  --bg-surface: #1e293b;
  --bg-raised: #334155;
  --text: #f1f5f9;
  --text-secondary: #94a3b8;
  --text-dim: #64748b;
  --border: #334155;
  --warn: #f59e0b;
  --accent-bg: rgba(59, 130, 246, 0.18);
  --error-bg: #450a0a;
  --nav-bg: #020617;
  --sidebar-bg: #1e293b;
  --sidebar-hover: #334155;
  --shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
  --shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.3);
}

/* ── Metrics ──────────────────────────────────────────────────────────────── */

/* Not a palette: these are the layout's own numbers, so they sit outside the
   theme blocks and neither theme overrides them.

   `--nav-h` is the height of `.nav`, which is `position: sticky` at the top of
   every page — the offset everything else pins beneath. The other two are
   *measured* at runtime by sticky.js and published onto the page's root
   element, which shadows the value declared here. They are declared anyway, and
   as `0px` rather than as nothing: an undefined custom property invalidates the
   whole `calc()` that names it (test_css_variables.py has the story), so
   without these a toolbar would silently lose its `top` — and lose it exactly
   in the window between the first paint and the first measurement, which is
   also the window a screenshot is most likely to catch. At `0px` the band pins
   directly under the navigation bar instead, which is where it belongs when
   there is nothing above it. */
:root {
  --nav-h: 52px;
  --chrome-h: 0px;
  --toolbar-h: 0px;
  /* Published by the music bar while it is on screen (sticky.js). */
  --music-bar-h: 0px;
  /* Published by the indexing dock, which sits on top of the music bar. */
  --index-dock-h: 0px;
}

/* ── Reset ────────────────────────────────────────────────────────────────── */

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

/* A tapped button keeps real DOM focus on mobile Chrome/Safari the way a
   click never does on desktop — there's no mouse to move away afterward, so
   the browser's own default focus ring (never reset anywhere in this file)
   is what stayed visibly "pressed" until something else was tapped (found
   live, 2026-08-26 — the nav hamburger has no :hover rule of its own at all,
   so the ring was the only thing that could have been showing). `:focus-
   visible` still shows it for real keyboard navigation — this only clears
   the leftover from a pointer/touch interaction, never accessibility. */
:focus:not(:focus-visible) { outline: none; }

/* A third, separate mechanism from :hover/:focus above, found live
   2026-08-26 (screenshots): Android Chrome's own tap-highlight flash —
   never disabled anywhere in this file — filled a tapped round button
   (music-player-btn's "next", which carries no state class at all) with a
   solid colour that stayed until another element was tapped. Left at its
   default, this shade is drawn from the OS accent colour on many Android
   builds, which is why it read as "the app's own blue", not an obviously
   foreign highlight. Unrelated to real hover/focus — safe to kill outright. */
*, *::before, *::after { -webkit-tap-highlight-color: transparent; }

body {
  font-family: system-ui, -apple-system, sans-serif;
  background: var(--bg-base);
  color: var(--text);
  line-height: 1.6;
  min-height: 100vh;
}

a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }

/* ── Cross-group search page ──────────────────────────────────────────────── */

.search-bar {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 8px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--bg-raised);
  /* A pinned band keeps this gap: `--band-margin` is both the margin in
     the flow and the width of the opaque ring the band paints around
     itself once pinned, so the two can never disagree (style.css,
     "Sticky chrome"). */
  --band-margin: 12px;
  margin-bottom: var(--band-margin);
}
.search-bar .icon { width: 16px; height: 16px; flex-shrink: 0; color: var(--text-dim); }
.search-bar input {
  border: none; outline: none; background: transparent;
  flex: 1; font-size: 0.95em; color: var(--text);
  min-width: 0;
}
.search-bar input::placeholder { color: var(--text-dim); }
.search-bar:focus-within { border-color: var(--border-focus); }
.search-bar-clear {
  background: none; border: none; padding: 2px; cursor: pointer;
  color: var(--text-dim); display: flex; align-items: center;
}
.search-bar-clear:hover { color: var(--text); }
.search-bar-clear .icon { width: 14px; height: 14px; }

.search-progress {
  display: flex; align-items: center; gap: 8px;
  font-size: 0.85em; color: var(--text-dim); margin-bottom: 12px;
}
.search-progress-bar {
  flex: 1; height: 4px; background: var(--border);
  border-radius: 2px; overflow: hidden;
}
.search-progress-fill {
  height: 100%; background: var(--accent);
  transition: width 0.3s;
}
.search-unreachable {
  font-size: 0.8em; color: var(--text-dim); margin-bottom: 8px;
}

.view-toggle {
  display: flex; gap: 0; flex-shrink: 0; margin-left: auto;
}
.view-toggle button {
  padding: 6px 12px; border: 1px solid var(--border);
  background: transparent; color: var(--text-dim);
  cursor: pointer; border-radius: 0; display: flex;
  align-items: center; gap: 4px; font-size: 0.85em;
}
.view-toggle button:first-child { border-radius: 6px 0 0 6px; }
.view-toggle button:last-child { border-radius: 0 6px 6px 0; }
.view-toggle button + button { margin-left: -1px; }
.view-toggle button.active {
  background: var(--accent-bg, rgba(59, 130, 246, 0.1));
  color: var(--accent); border-color: var(--accent);
  z-index: 1;
}
.view-toggle .icon { width: 16px; height: 16px; }

.search-media-list {
  display: flex; flex-direction: column; gap: 2px;
}
.search-media-row {
  display: flex; align-items: center; gap: 10px;
  padding: 8px 10px; border-radius: 6px; cursor: pointer;
}
.search-media-row:hover { background: var(--bg-raised); }
.search-media-icon {
  flex-shrink: 0; width: 32px; height: 32px;
  display: flex; align-items: center; justify-content: center;
  background: var(--bg-raised); border-radius: 4px; color: var(--text-dim);
}
.search-media-icon .icon { width: 18px; height: 18px; }
.search-media-info { flex: 1; min-width: 0; }
.search-media-title {
  font-weight: 500; white-space: nowrap;
  overflow: hidden; text-overflow: ellipsis;
}
.search-media-meta {
  font-size: 0.8em; color: var(--text-dim);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.search-media-section {
  font-size: 0.75em; color: var(--text-dim);
  text-transform: uppercase; letter-spacing: 0.05em;
  padding: 12px 10px 4px;
}
.search-media-row .badge { flex-shrink: 0; font-size: 0.78em; }
.search-media-show { opacity: 0.85; }

/* ── Icons ────────────────────────────────────────────────────────────────── */

/* Sized in em and stroked in currentColor, so an icon inherits the size and
   colour of whatever it sits next to and needs no rule of its own. Only the
   places that want a fixed size say so below. */
.icon {
  width: 1em;
  height: 1em;
  vertical-align: -0.14em;
  flex-shrink: 0;
}
.icon.flip { transform: rotate(180deg); }

/* ── Navigation ───────────────────────────────────────────────────────────── */

.nav {
  background: var(--nav-bg);
  color: var(--nav-text);
  padding: 0 16px;
  height: 52px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  position: sticky;
  top: 0;
  z-index: 100;
}

.nav-left, .nav-right { display: flex; align-items: center; gap: 12px; }

.nav-brand {
  color: var(--nav-brand);
  font-weight: 700;
  /* 22px measured in the application. 1.15em was lettering sized for plain
     text and read as a caption beside the picture; 1.45em overshot. */
  font-size: 1.375em;
  text-decoration: none;
}
.nav-brand:hover { text-decoration: none; }

/* The M is a picture standing in for a letter, so it is sized in `em` and
   follows the wordmark rather than being pinned to a pixel height.

   Sized by measuring the running application, twice. At 1.35em of the old
   font size it rendered 25px against a 29px line box — *smaller* than the
   letters beside it, so the wordmark read as unchanged. At 1.9em it was 35px
   against 29px, which overshot the other way. The pair is now set together:
   the picture leads, the lettering follows a little behind it. */
.nav-brand {
  display: inline-flex;
  align-items: center;
}
.nav-brand-m {
  /* **In pixels, not `em` — and that is the point.** Written in `em` this was
     a fraction of the lettering's size, so every adjustment to the text moved
     the picture too and the pair could never be settled: making the words
     bigger made the M bigger by the same stroke, and the ratio never changed.
     Two independent things need two independent numbers.

     30px against 22px of lettering. */
  height: 30px;
  width: auto;
  /* Positive, deliberately. The glyph this replaces carried its own side
     bearing and an image has none, so without this the picture sits flush
     against the "e". */
  margin-right: 4px;
}

.nav-hamburger {
  display: none;
  background: none;
  border: none;
  color: var(--nav-text);
  font-size: 1.4em;
  cursor: pointer;
  padding: 4px 8px;
  line-height: 1;
}

.nav-user { color: var(--text-dim); font-size: 0.9em; }

/* ── User Menu ──────────────────────────────────────────────────────────── */

.user-menu-wrap { position: relative; }

.user-menu-trigger {
  display: flex;
  align-items: center;
  gap: 8px;
  background: rgba(255, 255, 255, 0.08);
  border: 1px solid rgba(255, 255, 255, 0.12);
  color: var(--nav-text);
  padding: 4px 10px 4px 4px;
  border-radius: 20px;
  cursor: pointer;
  font-size: 0.85em;
  transition: background 0.12s;
}
.user-menu-trigger:hover { background: rgba(255, 255, 255, 0.15); }

.user-menu-avatar {
  width: 28px; height: 28px;
  border-radius: 50%;
  background: var(--accent);
  color: #fff;
  display: flex; align-items: center; justify-content: center;
  font-weight: 700; font-size: 0.8em;
  flex-shrink: 0;
}
.user-menu-avatar.lg { width: 36px; height: 36px; font-size: 0.95em; }

.user-menu-name { max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.user-menu-caret { font-size: 0.7em; color: rgba(255,255,255,0.5); }

.user-menu-dropdown {
  position: absolute;
  right: 0; top: calc(100% + 6px);
  background: var(--bg-surface);
  border: 1px solid var(--border);
  border-radius: 10px;
  box-shadow: var(--shadow-lg);
  min-width: 220px;
  z-index: 120;
  /* The language submenu expands to ten entries inside this dropdown, which
     overruns a short viewport. Clip horizontally as before, scroll vertically
     rather than hiding the last languages where nothing can reach them. */
  max-height: calc(100vh - 72px);
  overflow-x: hidden;
  overflow-y: auto;
  padding: 4px 0;
}

.user-menu-header {
  display: flex; align-items: center; gap: 10px;
  padding: 12px 14px 8px;
}
.user-menu-uname { font-weight: 600; font-size: 0.9em; color: var(--text); }
.user-menu-role { font-size: 0.75em; color: var(--text-dim); text-transform: capitalize; }

.user-menu-divider { height: 1px; background: var(--border); margin: 4px 0; }

.user-menu-section-label {
  padding: 6px 14px 2px;
  font-size: 0.7em; text-transform: uppercase; letter-spacing: 0.06em;
  color: var(--text-dim); font-weight: 600;
}

.user-menu-item {
  display: flex; align-items: center; gap: 8px;
  width: 100%; padding: 8px 14px;
  background: none; border: none; border-radius: 0;
  color: var(--text); font-size: 0.85em;
  cursor: pointer; text-align: left;
}
.user-menu-item:hover { background: var(--bg-raised); }
.user-menu-logout { color: var(--error); }

.umi-icon { width: 17px; height: 17px; flex-shrink: 0; color: var(--text-secondary); }
.umi-arrow { margin-left: auto; width: 13px; height: 13px; color: var(--text-dim); }
.umi-check { margin-left: auto; width: 14px; height: 14px; color: var(--accent); }
.user-menu-caret { width: 13px; height: 13px; color: var(--nav-text); opacity: 0.8; }
.user-menu-sub { padding-left: 32px; }

.nav-btn {
  background: rgba(255, 255, 255, 0.1);
  color: var(--nav-text);
  border: none;
  padding: 6px 14px;
  border-radius: 6px;
  font-size: 0.85em;
  cursor: pointer;
  text-decoration: none;
}
.nav-btn:hover { background: rgba(255, 255, 255, 0.18); text-decoration: none; }

/* ── Layout ───────────────────────────────────────────────────────────────── */

.layout { display: flex; min-height: calc(100vh - 52px); }

/* ── Sidebar ──────────────────────────────────────────────────────────────── */

/* Pinned under the navigation bar at the window's height, so the legal link at
   its foot is at the bottom of the window whatever the page's length — on a
   long file list it would otherwise be at the bottom of the page. It stops
   above the music bar, which is pinned to the bottom of the window too. */
.sidebar {
  position: sticky;
  top: var(--nav-h);
  align-self: flex-start;
  display: flex;
  flex-direction: column;
  width: 240px;
  height: calc(100vh - var(--nav-h) - var(--music-bar-h) - var(--index-dock-h));
  /* The visible viewport where the browser has the unit: on a phone or tablet
     `100vh` is the height with the address bar hidden, which leaves the foot of
     the list under the bar while it shows. The line above is the fallback. */
  height: calc(100dvh - var(--nav-h) - var(--music-bar-h) - var(--index-dock-h));
  /* Reaching either end of the list must not start scrolling the page behind. */
  overscroll-behavior: contain;
  background: var(--sidebar-bg);
  border-right: 1px solid var(--border);
  padding: 16px 0 0;
  flex-shrink: 0;
  overflow-y: auto;
}

.sidebar-legal {
  margin-top: auto;
  padding: 10px 20px 12px;
  color: var(--text-dim);
  font-size: 0.75em;
}
.sidebar-legal:hover { color: var(--accent); }

.sidebar-section { padding: 0 12px; margin-bottom: 24px; }

.sidebar-heading {
  font-size: 0.7em;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-dim);
  padding: 4px 8px;
  margin-bottom: 4px;
  font-weight: 600;
}

.sidebar-item {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 7px 12px;
  border-radius: 6px;
  color: var(--text);
  text-decoration: none;
  font-size: 0.9em;
  transition: background 0.12s;
}
/* A group entry: the name+dot on one line, the @owner on a subordinate line
   under it — so the status dot sits in front of the name, not floating
   between the name and the owner. */
.sidebar-item.sidebar-group {
  flex-direction: column;
  align-items: stretch;
  gap: 1px;
}
.si-head { display: flex; align-items: center; gap: 8px; min-width: 0; }
.sidebar-owner {
  margin-left: 16px;                 /* clear the 8px dot + 8px gap */
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: 0.8em;
  color: var(--text-dim);
}
.sidebar-item.active .sidebar-owner { color: inherit; opacity: 0.75; }
.sidebar-item:hover { background: var(--sidebar-hover); text-decoration: none; }
.sidebar-item.active { background: var(--accent); color: var(--accent-text); }

.sidebar-empty {
  padding: 8px 12px;
  color: var(--text-dim);
  font-size: 0.85em;
  font-style: italic;
}

/* ── Main content ─────────────────────────────────────────────────────────── */

.main {
  flex: 1;
  padding: 24px 32px;
  max-width: 960px;
  min-width: 0;
}

.main h2 { font-size: 1.25em; margin-bottom: 16px; }

.page-message {
  color: var(--text-secondary);
  margin-bottom: 24px;
}

/* ── Page center (login / register) ───────────────────────────────────────── */

.page-center {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: calc(100vh - 52px);
  padding: 24px;
}

/* A centring page already reserves the whole viewport below the header, so the
   main column's own padding is added on top of it and the page overflows by
   exactly that much — 48px, and a scrollbar on sign-in at every window size.
   Measured, not deduced: `.page-center` ended at 795 and `main` at 819.
   The page brings its own 24px, so dropping main's here changes nothing
   visible and removes the duplicated arithmetic rather than adding more.
   `max-width` goes too: signed out there is no sidebar, so the 960px column sat
   at the left of the window and a "centred" form was centred in that — at
   x=290 in a 1440px window. */
.main:has(> .page-center) { padding: 0; max-width: none; }

/* ── Sticky chrome ────────────────────────────────────────────────────────── */

/* Files, Videos, Music and Photos are read by scrolling, and until now the
   controls that steer that reading went with the first screenful: which
   application, where in the tree, which filter, which column sorts. They pin
   under the navigation bar instead, in three bands, on the two pages that show
   those applications — a group (`group-page.js`) and Search (`search-page.js`),
   both of which mark their root `.sticky-chrome`:

     .nav                     52px, already sticky, --nav-h
     .group-tabs / .search-bar    which application / what is being searched
     the application's toolbar    where in the tree, the filter, the actions
     .file-table th               the column heads

   The group's name and description are deliberately *not* in the stack. They
   say nothing a reader needs while walking a directory, and the height they
   would cost is height the list does not get.

   Each band pins below the ones above it, so its `top` is their heights added
   up — heights that change with the window (`.video-toolbar` wraps on a phone,
   by design), with the state (naming a folder grows the toolbar; `hideFilter`
   shrinks it) and with the theme's font. sticky.js measures them and publishes
   `--chrome-h` and `--toolbar-h`; everything here reads those rather than
   repeating a number that would be right in one file and wrong in another.

   `>` throughout, and not by accident: a band publishes its height onto its
   own parent, so these rules and sticky.js are asserting the same structural
   fact — these elements are children of the page root. Nest one deeper and
   both stop applying, rather than the stylesheet pinning something the script
   is no longer measuring.

   `.file-toolbar` is also the toolbar of the public directory
   (`explore-page.js`), which is not one of these pages and is left alone. */

.sticky-chrome > .group-tabs,
.sticky-chrome > .search-bar {
  position: sticky;
  top: var(--nav-h);
  z-index: 30;
}

.sticky-chrome > .file-toolbar,
.sticky-chrome > .video-toolbar,
.sticky-chrome > .photo-toolbar,
.sticky-chrome > .photo-album-bar {
  position: sticky;
  top: calc(var(--nav-h) + var(--chrome-h));
  z-index: 20;
}

.sticky-chrome > .file-table th {
  position: sticky;
  top: calc(var(--nav-h) + var(--chrome-h) + var(--toolbar-h));
  z-index: 10;
}

/* A pinned band has the list running underneath it and must be opaque, which
   most of these were not: only `.search-bar` and `.file-toolbar` came with a
   surface of their own. So the bare bands take the page colour outright. */
.sticky-chrome > .group-tabs,
.sticky-chrome > .video-toolbar,
.sticky-chrome > .photo-toolbar,
.sticky-chrome > .photo-album-bar,
.sticky-chrome > .file-table th {
  background: var(--bg-base);
}

/* **The gap a band keeps when it pins.** In the flow every band is followed by
   a margin — 16px under the tab bar, 12px under the search field and the file
   toolbar, 14px under the media ones. Pinning absorbs it: the band below stops
   at the bottom edge of the band above and the two come into contact. Reported
   on both counts, and they are the same fault — two rounded panels flush
   against each other in Files read as *encastrés*, and the row of controls in
   Videos/Music/Photos reads as glued to the tab bar.

   So each band paints its own margin as a ring of page colour around itself,
   and the band below pins past it: `--chrome-h`/`--toolbar-h` are published by
   sticky.js as **height plus that margin**, which is why the ring and the
   offset can never disagree — they are the same number, declared once beside
   the component's own `margin-bottom` and nowhere else. The pinned layout is
   then pixel-identical to the flow layout, so nothing shifts at the moment a
   band pins.

   The ring must be exactly the margin, not more: it is painted at the band's
   own z-index, above every band below it, so a ring wider than the gap would
   paint page colour over the top of the next band down. It is also what covers
   the four transparent corners a border radius leaves, where a row's rule
   sliding past would read as a flicker nobody can reproduce. */
.sticky-chrome > .group-tabs,
.sticky-chrome > .search-bar,
.sticky-chrome > .file-toolbar,
.sticky-chrome > .video-toolbar,
.sticky-chrome > .photo-toolbar,
.sticky-chrome > .photo-album-bar {
  box-shadow: 0 0 0 var(--band-margin) var(--bg-base);
}

/* **And the same gap above the first one.** The ring is painted on all four
   sides, which is what the corners need — so a band has to have
   `--band-margin` of clearance above it too, or it paints page colour over
   whatever is there. Every band but the first has that for free: what sits
   above it is the band it pins under, at a higher z-index, and a ring cannot
   paint over that. The first has the page's own content above it, and nothing
   was making the two agree — the join-code form under a group's title leaves
   12px, the tab bar's ring is 16px, and the form was handed back with the
   bottom 4px of its field and its button painted over. The same 4px went off
   the bottom of the "could not reach this node" banner, which is the other
   thing that stands between the title and the tabs.

   Only the first band, and that is the whole of the rule rather than an
   economy: the gap *between* two bands is the upper one's `--band-margin` and
   nothing else — it is the number `--chrome-h` carries and the offset the
   lower one pins at. A margin-top on a lower band would collapse to the
   larger of the pair and win wherever it was bigger, which is most widths,
   leaving the flow a couple of pixels wider than the pinned layout. Measured,
   in every media view at every phone width, the moment this rule was written
   for all six.

   `* +`, so this is the gap between two elements and not a margin the band
   always carries: as a first child — the Search page's field is one — a
   margin-top would collapse through the page root and take the whole page
   down with it. Between siblings the two margins collapse to the larger of
   the pair, so every place that already leaves enough is untouched, and only
   what was being painted over moves. */
.sticky-chrome > * + .group-tabs,
.sticky-chrome > * + .search-bar {
  margin-top: var(--band-margin);
}

/* ── Cards ────────────────────────────────────────────────────────────────── */

.card {
  background: var(--bg-surface);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 24px;
  box-shadow: var(--shadow);
}

.login-card { width: 100%; max-width: 380px; }
.login-card h2 { text-align: center; margin-bottom: 20px; }
.login-card form { display: flex; flex-direction: column; gap: 12px; }

.login-footer {
  text-align: center;
  margin-top: 16px;
  font-size: 0.85em;
  color: var(--text-dim);
}

/* ── Welcome (sign-in page, browser only) ─────────────────────────────────── */

/* What MeshBay is on the left, the form on the right and level with the
   title; stacked, form first, once the text would be narrower than the form.
   The form comes first in the markup (first on a phone, first to the
   keyboard) and `row-reverse` is what moves it right. Headings are
   `.welcome-pitch .welcome-h` rather than a bare class so they outrank
   `.main h2`. */
.welcome {
  display: flex;
  flex-direction: row-reverse;
  align-items: flex-start;
  justify-content: center;
  gap: 96px;
  width: 100%;
  max-width: 1180px;
}
.welcome > .login-card { flex: 0 0 380px; }

.welcome-pitch {
  flex: 1 1 0;
  min-width: 0;
  max-width: 660px;
  color: var(--text-secondary);
  font-size: 0.95rem;
  line-height: 1.5;
}
.welcome-pitch p { margin-bottom: 10px; }

.welcome-apps, .welcome-uses, .welcome-never { list-style: none; }

.welcome-title {
  margin-bottom: 10px;
  color: var(--text);
  font-size: 1.9em;
  font-weight: 700;
  line-height: 1.2;
  letter-spacing: -0.02em;
  text-wrap: balance;
}
.welcome-lead { font-size: 1.05em; }

.welcome-apps { display: flex; flex-wrap: wrap; gap: 8px; margin: 4px 0 14px; }
.welcome-apps li {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 5px 12px;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: var(--bg-surface);
  color: var(--text);
  font-size: 0.88em;
}
.welcome-apps .icon { color: var(--accent); }

.welcome-e2e {
  display: flex;
  align-items: flex-start;
  gap: 10px;
  padding: 8px 14px;
  border-left: 3px solid var(--accent);
  border-radius: 0 6px 6px 0;
  background: var(--accent-bg);
  color: var(--text);
}
.welcome-e2e .icon { margin-top: 0.3em; color: var(--accent); }

.welcome-pitch .welcome-h {
  margin: 18px 0 8px;
  color: var(--text-dim);
  font-size: 0.78em;
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
}

.welcome-uses { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 18px; }
.welcome-uses li {
  display: flex;
  align-items: flex-start;
  gap: 10px;
  color: var(--text);
  font-size: 0.92em;
  line-height: 1.45;
}
.welcome-use-icon {
  display: inline-flex;
  flex: 0 0 auto;
  align-items: center;
  justify-content: center;
  width: 32px;
  height: 32px;
  border-radius: 8px;
  background: var(--accent-bg);
  color: var(--accent);
  font-size: 17px;
}

.welcome-hub {
  margin-top: 18px;
  padding: 12px 18px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--bg-surface);
  font-size: 0.92em;
}
.welcome-pitch .welcome-hub .welcome-h { margin-top: 0; }
.welcome-pitch .welcome-hub-never { margin-bottom: 6px; color: var(--text); font-weight: 600; }
.welcome-never { display: flex; flex-direction: column; gap: 4px; color: var(--text); }
.welcome-never li { display: flex; align-items: flex-start; gap: 8px; }
.welcome-never .icon { margin-top: 0.3em; color: var(--success); }
.welcome-pitch .welcome-hub-free {
  margin: 10px 0 0;
  padding-top: 8px;
  border-top: 1px solid var(--border);
  color: var(--text);
  font-weight: 500;
}

.welcome-links {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 12px 20px;
  margin-top: 14px;
}
.welcome-cta {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 10px 18px;
  border-radius: 6px;
  background: var(--accent);
  color: var(--accent-text);
  font-size: 0.95em;
  font-weight: 600;
}
.welcome-cta:hover { background: var(--accent-hover); text-decoration: none; }
.welcome-legal { color: var(--text-secondary); font-size: 0.9em; }
.welcome-legal:hover { color: var(--accent); }

@media (max-width: 1000px) {
  .welcome { flex-direction: column; align-items: center; gap: 36px; }
  .welcome > .login-card { flex: none; }
  .welcome-pitch { flex: none; width: 100%; max-width: 520px; }
}
@media (max-width: 520px) {
  .welcome-title { font-size: 1.6em; }
  .welcome-uses { grid-template-columns: 1fr; }
}

/* ── Form elements ────────────────────────────────────────────────────────── */

input[type="text"],
input[type="password"],
input[type="email"] {
  padding: 10px 12px;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--bg-base);
  color: var(--text);
  font-size: 0.95em;
  width: 100%;
  transition: border-color 0.15s;
}
input:focus { outline: none; border-color: var(--border-focus); }

/* A reveal button inside the field rather than beside it. The input reserves
   the room on its right, so a passphrase long enough to scroll ends under the
   button instead of behind it. The `background: none` pair is not redundant:
   the bare `button` rules just below paint every button with the accent colour
   on rest and on hover, and this one has to stay part of the field. */
.pw-field { position: relative; display: flex; }
.pw-field input { padding-right: 40px; }
.pw-toggle {
  position: absolute;
  top: 0;
  right: 4px;
  bottom: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 32px;
  padding: 0;
  background: none;
  border: none;
  border-radius: 6px;
  color: var(--text-dim);
  font-size: 1.15em;
  cursor: pointer;
}
.pw-toggle:hover { background: none; color: var(--text); }

button {
  padding: 10px 20px;
  background: var(--accent);
  color: var(--accent-text);
  border: none;
  border-radius: 6px;
  font-size: 0.95em;
  cursor: pointer;
  transition: background 0.15s;
}
button:hover { background: var(--accent-hover); }
button:disabled { opacity: 0.5; cursor: not-allowed; }

/* ── Alerts ───────────────────────────────────────────────────────────────── */

.error-msg {
  background: var(--error-bg);
  color: var(--error);
  border: 1px solid var(--error);
  border-radius: 6px;
  padding: 8px 12px;
  font-size: 0.85em;
}

/* The middle state: the hub is still sending what somebody is waiting on,
   but not what a newcomer needs. `--warn` already exists in both themes. */
.warn-msg {
  background: color-mix(in srgb, var(--warn) 12%, transparent);
  color: var(--warn);
  border: 1px solid var(--warn);
  border-radius: 6px;
  padding: 8px 12px;
  font-size: 0.85em;
}

.success-msg {
  background: #16a34a20;
  color: var(--success);
  border: 1px solid var(--success);
  border-radius: 6px;
  padding: 8px 12px;
  font-size: 0.85em;
}

.settings-hint {
  font-size: 0.85em;
  color: var(--text-dim);
  margin-bottom: 8px;
}

/* ── Group cards (9.7 prep) ───────────────────────────────────────────────── */

.group-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
  gap: 16px;
}

.group-card {
  background: var(--bg-surface);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 16px;
  cursor: pointer;
  transition: border-color 0.12s, box-shadow 0.12s;
  text-decoration: none;
  color: var(--text);
  display: block;
}
.group-card:hover {
  border-color: var(--accent);
  box-shadow: var(--shadow-lg);
  text-decoration: none;
}
.group-card h3 { font-size: 1em; margin-bottom: 4px; }

.badge {
  display: inline-block;
  background: var(--bg-raised);
  color: var(--text-secondary);
  padding: 2px 8px;
  border-radius: 12px;
  font-size: 0.75em;
}
.badge-ok { background: #16a34a20; color: var(--success); }
.badge-err { background: var(--error-bg); color: var(--error); }

/* ── Group page header ───────────────────────────────────────────────────── */

.group-header {
  display: flex;
  align-items: flex-start;
  gap: 12px;
  margin-bottom: 16px;
  flex-wrap: wrap;
}
.group-header h2 { margin-bottom: 0; }

/* ── Group tabs ──────────────────────────────────────────────────────────── */

.group-tabs {
  display: flex;
  gap: 0;
  /* A pinned band keeps this gap: `--band-margin` is both the margin in
     the flow and the width of the opaque ring the band paints around
     itself once pinned, so the two can never disagree (style.css,
     "Sticky chrome"). */
  --band-margin: 16px;
  margin-bottom: var(--band-margin);
  border-bottom: 2px solid var(--border);
}

.group-tab {
  background: none;
  border: none;
  border-bottom: 2px solid transparent;
  margin-bottom: -2px;
  padding: 10px 18px;
  color: var(--text-secondary);
  cursor: pointer;
  border-radius: 0;
  transition: color 0.12s, border-color 0.12s;
  display: flex;
  align-items: center;
  justify-content: center;
}
.group-tab:hover { color: var(--text); background: none; }
.group-tab.active {
  color: var(--accent);
  border-bottom-color: var(--accent);
}
.tab-icon { width: 22px; height: 22px; }

.group-mute-btn {
  background: none;
  border: none;
  cursor: pointer;
  color: var(--text-dim);
  padding: 4px;
  border-radius: 4px;
  display: flex;
  align-items: center;
  margin-left: auto;
}
.group-mute-btn:hover { color: var(--text); }
.group-mute-btn .icon { width: 20px; height: 20px; }

/* ── Chat panel ──────────────────────────────────────────────────────────── */

.chat-panel {
  position: relative;
  display: flex;
  flex-direction: column;
  /* Overwritten from JS with the space actually left below the group header,
     which varies with the description and the buttons. These two are the value
     used for the first paint and the fallback if that never runs. `dvh` rather
     than `vh` so a phone's collapsing URL bar does not add a scrollbar. */
  height: calc(100dvh - 260px);
  min-height: 240px;
  background: var(--bg-surface);
  border: 1px solid var(--border);
  border-radius: 8px;
  overflow: hidden;
}

.chat-messages {
  flex: 1;
  overflow-y: auto;
  padding: 16px;
  display: flex;
  flex-direction: column;
  gap: 4px;
}

.chat-empty {
  text-align: center;
  color: var(--text-dim);
  font-style: italic;
  padding: 40px 16px;
}

.chat-msg {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  max-width: 75%;
}

.chat-msg-own {
  align-self: flex-end;
  align-items: flex-end;
}

.chat-sender {
  font-size: 0.72em;
  color: var(--text-dim);
  margin-bottom: 2px;
  padding-left: 10px;
  font-weight: 600;
}

.chat-bubble {
  background: var(--bg-raised);
  padding: 8px 12px;
  border-radius: 12px 12px 12px 4px;
  font-size: 0.9em;
  line-height: 1.45;
  word-break: break-word;
  display: inline-flex;
  align-items: baseline;
  gap: 8px;
  flex-wrap: wrap;
}

.chat-bubble-own {
  background: var(--accent);
  color: var(--accent-text);
  border-radius: 12px 12px 4px 12px;
}

.chat-text { white-space: pre-wrap; }

/* Link unfurl card. Sits on its own line inside the bubble and carries its
   own surface colours so it stays readable inside an own-message bubble too. */
.chat-link-preview {
  flex: 0 0 100%;
  display: flex;
  gap: 10px;
  margin-top: 4px;
  padding: 8px;
  max-width: 380px;
  border: 1px solid var(--border);
  border-left: 3px solid var(--accent);
  border-radius: 8px;
  background: var(--bg-base);
  color: var(--text);
  text-decoration: none;
  overflow: hidden;
}
.chat-link-preview:hover { background: var(--bg-surface); }
.clp-img {
  width: 72px;
  height: 72px;
  flex-shrink: 0;
  object-fit: cover;
  border-radius: 6px;
  background: var(--bg-surface);
}
.clp-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.clp-site {
  font-size: 0.7em;
  color: var(--text-dim);
  text-transform: uppercase;
  letter-spacing: 0.03em;
}
.clp-title {
  font-weight: 600;
  font-size: 0.9em;
  line-height: 1.3;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
.clp-desc {
  font-size: 0.82em;
  color: var(--text-secondary);
  line-height: 1.35;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.chat-time {
  font-size: 0.65em;
  color: var(--text-dim);
  white-space: nowrap;
  flex-shrink: 0;
}
.chat-bubble-own .chat-time { color: rgba(255, 255, 255, 0.6); }

.chat-input-row {
  display: flex;
  gap: 8px;
  padding: 12px;
  border-top: 1px solid var(--border);
  background: var(--bg-base);
}

.chat-input {
  flex: 1;
  resize: none;
  padding: 8px 12px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--bg-surface);
  color: var(--text);
  font-size: 0.9em;
  font-family: inherit;
  line-height: 1.4;
  min-height: 38px;
  max-height: 100px;
}
.chat-input:focus { outline: none; border-color: var(--border-focus); }

.chat-send {
  align-self: flex-end;
  padding: 8px 16px;
  font-size: 0.85em;
  border-radius: 8px;
  white-space: nowrap;
}

/* ── File toolbar ────────────────────────────────────────────────────────── */

/* One bar, three groups: what you can add on the left, where you are in the
   middle, what you can do with what is here on the right. It used to be four
   controls of three different heights sitting in a row. */
.file-toolbar {
  display: flex;
  align-items: center;
  gap: 10px;
  /* A pinned band keeps this gap: `--band-margin` is both the margin in
     the flow and the width of the opaque ring the band paints around
     itself once pinned, so the two can never disagree (style.css,
     "Sticky chrome"). */
  --band-margin: 12px;
  margin-bottom: var(--band-margin);
  padding: 8px;
  background: var(--bg-raised);
  border: 1px solid var(--border);
  border-radius: 8px;
  flex-wrap: wrap;
}
.toolbar-group {
  display: flex;
  align-items: center;
  gap: 6px;
  flex-wrap: wrap;
  min-width: 0;
}
.toolbar-group.right { margin-left: auto; }

.tb-btn {
  display: inline-flex;
  align-items: center;
  gap: 6px;               /* the icon and the word are not the same word */
  height: 32px;
  padding: 0 11px;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--bg-surface);
  color: var(--text);
  font-size: 0.83em;
  white-space: nowrap;
  cursor: pointer;
  transition: border-color 0.12s, color 0.12s, background 0.12s;
}
.tb-btn:hover { border-color: var(--accent); color: var(--accent); }
.tb-btn:disabled { opacity: 0.45; cursor: not-allowed; }
.tb-btn:disabled:hover { border-color: var(--border); color: var(--text); }
.tb-btn .icon { width: 15px; height: 15px; }
/* Square, for a toolbar button whose name is in its tooltip rather than
   beside it. The horizontal padding goes; the height does not, so it lines up
   with the labelled buttons next to it. */
.tb-btn-icon { padding: 0; width: 32px; justify-content: center; gap: 0; }

/* Naming a new folder, in the toolbar. Not `window.prompt`, which throws in
   Electron — and an inline field can put the node's refusal next to the input
   rather than after a dialog has closed. */
.tb-mkdir { display: inline-flex; align-items: center; gap: 6px; }
.tb-mkdir-input {
  height: 32px; width: 170px; padding: 0 9px;
  border: 1px solid var(--border-focus); border-radius: 6px;
  background: var(--bg-surface); color: var(--text);
  font-family: inherit; font-size: 0.83em;
}
.tb-mkdir-input:focus { outline: none; }
.tb-btn.primary {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--accent-text);
}
.tb-btn.primary:hover { background: var(--accent-hover); color: var(--accent-text); }
.tb-btn.active {
  background: color-mix(in srgb, var(--accent) 12%, var(--bg-surface));
  border-color: var(--accent);
  color: var(--accent);
}

/* The filter is a field, not a button, and should not pretend otherwise. */
.tb-search {
  display: flex;
  align-items: center;
  gap: 6px;
  height: 32px;
  padding: 0 10px;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--bg-surface);
  color: var(--text-dim);
  /* A flex item defaults to min-width:auto and refuses to shrink below its
     content — which is how a 150px field pushed the toolbar off a phone. */
  min-width: 0;
}
.tb-search:focus-within { border-color: var(--border-focus); }
.tb-search .icon { width: 14px; height: 14px; flex-shrink: 0; }
.tb-search input {
  border: none;
  background: none;
  outline: none;
  padding: 0;
  width: 150px;
  min-width: 0;
  font-size: 0.83em;
  color: var(--text);
}

.breadcrumbs {
  display: flex;
  align-items: center;
  gap: 2px;
  flex-wrap: wrap;
  min-width: 0;
  font-size: 0.85em;
  min-width: 0;
  flex-wrap: wrap;
}
.crumb {
  display: inline-flex;
  align-items: center;
  cursor: pointer;
  color: var(--accent);
  padding: 2px 4px;
  border-radius: 4px;
}
.crumb:hover { background: var(--bg-raised); text-decoration: none; }
.crumb-sep { color: var(--text-dim); }
.crumb-refresh {
  background: none;
  border: 0;
  font: inherit;
  color: var(--text-secondary);
}
.crumb-refresh:hover:not(:disabled) { color: var(--text); }
.crumb-refresh:disabled { cursor: default; }
.crumb-refresh.spinning .icon { animation: spin 0.8s linear infinite; }


/* ── File table ──────────────────────────────────────────────────────────── */

.file-table {
  width: 100%;
  /* Separated, with no spacing, rather than collapsed. A collapsed border is
     shared between two cells and so belongs to the table rather than to either
     of them — it stays where the table's flow put it while a `position:
     sticky` column head moves away, so the head loses its rule the moment it
     pins. Only the bottom edge of any cell here carries a border, so nothing
     was being shared and the drawn result is the same single line; it now
     travels with the head. */
  border-collapse: separate;
  border-spacing: 0;
  font-size: 0.9em;
}

.file-table th {
  text-align: left;
  padding: 8px 12px;
  border-bottom: 2px solid var(--border);
  color: var(--text-secondary);
  font-size: 0.8em;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  white-space: nowrap;
  user-select: none;
}

.file-table th.sortable { cursor: pointer; }
.file-table th.sortable:hover { color: var(--text); }

.file-row td {
  padding: 8px 12px;
  border-bottom: 1px solid var(--border);
  vertical-align: middle;
}

.file-row:hover { background: var(--bg-raised); }
.dir-row { cursor: pointer; }
.dir-row td { font-weight: 500; }

/* Every cell whose text is a name somebody else chose.
   
   A table column is never narrower than its widest cell's minimum, and a name
   with no spaces in it — `Rage_Against_The_Machine_Discography_1992-2000_FLAC`,
   `musique_de_la_maison_2024` — is one unbreakable word. Left to itself such a
   cell sets the column's minimum to the whole string and the table grows past
   the window: measured on a phone at 390px, a 527px table from a folder name
   and a 442px one from a group name. That is not merely ugly. **On Android a
   document wider than the screen takes everything `position: sticky` out of
   the visible area with it** — the navigation bar included — so the reported
   symptom was "the header does not stay", and the cause was a table column.

   Both cells used to be bare `<td>`s: `.file-name` was on a file's name and on
   nothing else, so a *folder* row and the Search page's group column had no
   wrapping rule at all. `test_sticky_header.py::test_no_view_scrolls_sideways`
   measures the page against its window in both engines and at three widths. */
.file-name,
.td-group {
  word-break: break-word;
  min-width: 0;
}
/* The column heads are `white-space: nowrap` like every other one; the group
   head is two syllables and never needed the exception. */
.file-table th.td-group { white-space: normal; }
/* Where a search result lives, under its name; click to open that folder
   and clear the filter. */
.file-loc {
  display: block;
  margin-top: 2px;
  font-size: 0.78em;
  color: var(--text-dim);
  cursor: pointer;
}
.file-loc:hover { color: var(--accent); text-decoration: underline; }
.file-size { white-space: nowrap; color: var(--text-secondary); }
.file-type { color: var(--text-dim); }
.file-date { white-space: nowrap; color: var(--text-dim); }
.file-empty {
  text-align: center;
  padding: 24px 12px !important;
  color: var(--text-dim);
  font-style: italic;
}

/* ── Spinner ─────────────────────────────────────────────────────────────── */

.spinner {
  display: inline-block;
  width: 14px;
  height: 14px;
  border: 2px solid var(--border);
  border-top: 2px solid var(--accent);
  border-radius: 50%;
  animation: spin 0.8s linear infinite;
  vertical-align: middle;
}

@keyframes spin {
  0% { transform: rotate(0deg); }
  100% { transform: rotate(360deg); }
}

/* ── File action menu ───────────────────────────────────────────────────── */

.file-actions-cell {
  position: relative;
  width: 40px;
}

.file-menu-btn {
  background: none;
  border: 1px solid var(--border);
  color: var(--text-secondary);
  width: 30px;
  height: 30px;
  border-radius: 6px;
  font-size: 1.1em;
  padding: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  line-height: 1;
}
.file-menu-btn:hover { background: var(--bg-raised); border-color: var(--accent); color: var(--accent); }

.file-menu {
  position: absolute;
  right: 0;
  top: 100%;
  background: var(--bg-surface);
  border: 1px solid var(--border);
  border-radius: 8px;
  box-shadow: var(--shadow-lg);
  z-index: 30;
  min-width: 130px;
  padding: 4px 0;
  overflow: hidden;
}
.file-menu button {
  display: block;
  width: 100%;
  padding: 8px 14px;
  background: none;
  border: none;
  color: var(--text);
  font-size: 0.85em;
  text-align: left;
  cursor: pointer;
  border-radius: 0;
}
.file-menu button:hover { background: var(--bg-raised); }

/* ── File link (clickable name) ─────────────────────────────────────────── */

.file-link {
  color: var(--accent);
  cursor: pointer;
  text-decoration: none;
}
.file-link:hover { text-decoration: underline; }

/* ── File preview overlay ───────────────────────────────────────────────── */

.preview-image-wrap {
  display: flex;
  align-items: center;
  justify-content: center;
  max-width: 90vw;
  max-height: calc(100vh - 100px);
  padding: 40px 20px 20px;
}

.preview-image {
  max-width: 100%;
  max-height: calc(100vh - 120px);
  object-fit: contain;
  border-radius: 4px;
}

.preview-text-wrap {
  width: min(90vw, 900px);
  max-height: calc(100vh - 100px);
  overflow: auto;
  padding: 60px 20px 20px;
}

.preview-text {
  background: var(--bg-surface);
  color: var(--text);
  padding: 20px;
  border-radius: 8px;
  font-size: 0.85em;
  line-height: 1.6;
  white-space: pre-wrap;
  word-break: break-word;
  overflow-x: auto;
  margin: 0;
  font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
  tab-size: 4;
}

/* ── Chat attachment ────────────────────────────────────────────────────── */

.chat-attach {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 38px;
  height: 38px;
  font-size: 1.2em;
  cursor: pointer;
  border-radius: 8px;
  flex-shrink: 0;
  color: var(--text-secondary);
}
.chat-attach:hover { background: var(--bg-raised); color: var(--accent); }

.chat-attachment {
  display: flex;
  flex-direction: column;
  gap: 2px;
}
.chat-att-img, .chat-att-file {
  font-weight: 500;
  font-size: 0.88em;
}
.chat-att-thumb {
  max-width: 200px;
  max-height: 200px;
  border-radius: 6px;
  object-fit: contain;
  display: block;
  cursor: pointer;
}
.chat-att-size {
  font-size: 0.75em;
  color: var(--text-dim);
}
.chat-bubble-own .chat-att-size { color: rgba(255, 255, 255, 0.6); }

.file-menu button { display: flex; align-items: center; gap: 8px; }
.file-menu button.danger { color: var(--error); }
.file-menu button.danger:hover { background: var(--error-bg); }
.fmi { font-size: 0.9em; width: 16px; text-align: center; flex-shrink: 0; }

/* ── Download button + progress ──────────────────────────────────────────── */

.dl-btn {
  background: none;
  border: 1px solid var(--border);
  color: var(--accent);
  width: 30px;
  height: 30px;
  border-radius: 6px;
  font-size: 0.9em;
  padding: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
}
.dl-btn:hover { background: var(--bg-raised); border-color: var(--accent); }
.dl-btn:disabled { opacity: 0.3; cursor: not-allowed; }



.dl-progress {
  flex: 1;
  height: 6px;
  background: var(--bg-raised);
  border-radius: 3px;
  overflow: hidden;
}

.dl-fill {
  height: 100%;
  background: var(--accent);
  border-radius: 3px;
  transition: width 0.2s;
}


/* ── Settings page ───────────────────────────────────────────────────────── */

.settings-section {
  background: var(--bg-surface);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 16px 20px;
  margin-bottom: 16px;
}

.settings-heading {
  font-size: 0.8em;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--text-dim);
  font-weight: 600;
  margin-bottom: 12px;
}

.settings-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  flex-wrap: wrap;
  padding: 8px 0;
}
.settings-row + .settings-row {
  border-top: 1px solid var(--border);
}

.settings-label {
  font-size: 0.9em;
  color: var(--text);
}
.settings-label input[type="number"] {
  display: block;
  width: 90px;
  margin-top: 4px;
  padding: 6px 8px;
  border-radius: 6px;
  border: 1px solid var(--border);
  background: var(--bg-base);
  color: var(--text);
  font-size: 0.95em;
}
/* A number that sits beside its label rather than under it — the mail bounds
   are a column of short values, and one input per line reads as a table. */
.settings-number {
  width: 110px;
  padding: 6px 8px;
  border-radius: 6px;
  border: 1px solid var(--border);
  background: var(--bg-base);
  color: var(--text);
  font-size: 0.95em;
  text-align: right;
}
.settings-number:disabled {
  opacity: 0.6;
}

.settings-label input[type="password"],
.settings-label input[type="text"],
.settings-label select {
  display: block;
  width: 100%;
  max-width: 320px;
  margin-top: 4px;
  padding: 6px 8px;
  border-radius: 6px;
  border: 1px solid var(--border);
  background: var(--bg-base);
  color: var(--text);
  font-size: 0.95em;
}

.settings-value {
  font-size: 0.9em;
  color: var(--text-secondary);
}

/* Collapsible settings sections (2026-08-24) — every section but a form
   someone might be mid-typing into (invite, pair-operator, approve-device,
   left as plain .settings-section blocks). Reuses .video-flat-chevron for
   the rotate-on-open animation rather than declaring a near-duplicate. */
.settings-collapsible-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  width: 100%;
  background: none;
  border: none;
  padding: 0;
  margin: 0;
  cursor: pointer;
  color: inherit;
  font: inherit;
  text-align: left;
}
.settings-collapsible-header .settings-heading { margin-bottom: 0; }
.settings-collapsible-body { margin-top: 12px; }
/* The header button stretches; the action (an app's on/off switch) keeps its
   own width at the end, and its own click. */
.settings-collapsible-bar { display: flex; align-items: center; gap: 12px; }
.settings-collapsible-bar .settings-collapsible-header { flex: 1 1 auto; min-width: 0; }
.settings-collapsible-action { flex: 0 0 auto; }

/* One app's settings pane, inside its section. */
.app-settings > .settings-row:first-child { margin-top: 0; }
/* The Save button of an app's settings pane.
 *
 * It was `btn btn-small btn-secondary`, and there is no `.btn` rule at all —
 * so it inherited `.btn-secondary`: no background, a transparent border, dim
 * grey text. Enabled, it already looked like a disabled control; disabled, it
 * was the same thing at 40% opacity. "You cannot always click Save, you do not
 * notice, and it does not work" was one sentence describing all of that.
 *
 * It looks like a button now, and a disabled one looks disabled. */
.app-save {
  display: inline-flex; align-items: center; gap: 6px;
  height: 30px; padding: 0 14px; margin-top: 4px;
  border: 1px solid var(--accent); border-radius: 6px;
  background: var(--accent); color: var(--accent-text);
  font-family: inherit; font-size: 0.83em; cursor: pointer;
}
.app-save:hover:not(:disabled) { background: var(--accent-hover); }
/* No transition on the enabled/disabled swap. It animates a colour change that
   coincides with a text-colour change, which reads as a flicker — and it made
   the state genuinely hard to observe: a measurement taken during it reports a
   transparent background on a button that is not transparent. */
.app-save:disabled {
  background: none; color: var(--text-dim);
  border-color: var(--border); opacity: 1; cursor: default;
}
/* Why it is inert, beside it — a disabled control that explains itself is the
   difference between "nothing changed" and "this is broken". */

.app-settings-sub {
  margin: 18px 0 4px; font-size: 0.9em; font-weight: 600;
  padding-top: 12px; border-top: 1px solid var(--border);
}

.settings-meta-title {
  display: flex;
  align-items: center;
  gap: 8px;
  text-transform: none;
  letter-spacing: normal;
  font-weight: 600;
  font-size: 1.1em;
  color: var(--text);
}
.settings-meta-title .icon { width: 16px; height: 16px; }
.settings-meta-badge {
  font-size: 0.75em;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.03em;
  padding: 2px 8px;
  border-radius: 999px;
  background: var(--bg-raised);
  color: var(--text-dim);
}
.settings-meta-badge.on { background: var(--accent); color: #fff; }

/* A modern on/off switch — replaces a checkbox or a "Turn on/off" button
   wherever the setting is a straight binary. */
.toggle-switch {
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: 10px;
  cursor: pointer;
  font-size: 0.9em;
  color: var(--text);
}
.toggle-switch input {
  position: absolute;
  opacity: 0;
  width: 0;
  height: 0;
}
.toggle-switch-track {
  position: relative;
  width: 38px;
  height: 21px;
  border-radius: 11px;
  background: var(--border);
  flex-shrink: 0;
  transition: background 0.15s;
}
.toggle-switch-thumb {
  position: absolute;
  top: 2px;
  left: 2px;
  width: 17px;
  height: 17px;
  border-radius: 50%;
  background: #fff;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.35);
  transition: transform 0.15s;
}
.toggle-switch input:checked + .toggle-switch-track { background: var(--accent); }
.toggle-switch input:checked + .toggle-switch-track .toggle-switch-thumb {
  transform: translateX(17px);
}
.toggle-switch input:focus-visible + .toggle-switch-track { outline: 2px solid var(--accent); outline-offset: 2px; }
.toggle-switch-disabled { opacity: 0.5; cursor: default; }

/* A sub-block within a merged settings section (Directories: shared roots,
   Videos root, Music root, all under one CollapsibleSection). */
.settings-root-row { padding: 10px 0; }
.settings-root-row + .settings-root-row { border-top: 1px solid var(--border); }
.settings-root-row-title {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-bottom: 4px;
}
.settings-root-row-title .icon { width: 15px; height: 15px; color: var(--text-dim); }
.settings-root-row-title h4 {
  font-size: 0.85em;
  font-weight: 600;
  color: var(--text);
  margin: 0;
}

/* Photos app's root add/remove list (docs/MESHBAY_DESIGN.md §9.9) — a set, unlike
   the Videos/Music single-value picker above. */
.settings-root-list { list-style: none; margin: 4px 0 8px; padding: 0; }
.settings-root-list-item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  padding: 4px 0;
  font-size: 0.88em;
}
.settings-root-list-item .link-btn { padding: 2px; }
.settings-root-list-item .icon { width: 14px; height: 14px; }

.settings-select {
  padding: 6px 10px;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--bg-base);
  color: var(--text);
  font-size: 0.9em;
  cursor: pointer;
  min-width: 180px;
}
.settings-select:focus { outline: none; border-color: var(--border-focus); }

/* ── Video player overlay ─────────────────────────────────────────────────── */

.video-overlay {
  position: fixed;
  inset: 0;
  z-index: 200;
  background: rgba(0, 0, 0, 0.92);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
}

.video-top-bar {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  padding: 12px 20px;
  z-index: 210;
}

.video-title {
  color: #e2e8f0;
  font-size: 0.9em;
  font-weight: 500;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  flex: 1;
  min-width: 0;
}

.video-close {
  background: rgba(255, 255, 255, 0.12);
  border: none;
  color: #e2e8f0;
  width: 36px;
  height: 36px;
  border-radius: 50%;
  font-size: 1.2em;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
}
.video-close:hover { background: rgba(255, 255, 255, 0.25); }
.video-close.dl-active { background: rgba(34, 197, 94, 0.25); pointer-events: none; }
.video-close.cast-active { background: rgba(59, 130, 246, 0.35); }
.video-close.cast-active:hover { background: rgba(59, 130, 246, 0.5); }
.cast-url-input {
  background: rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.15);
  border-radius: 4px; color: #e2e8f0; font-size: 0.75em; font-family: inherit;
  padding: 4px 8px; min-width: 0; flex: 1; max-width: 420px;
  cursor: text; outline: none;
}
.cast-url-input:focus { border-color: rgba(59, 130, 246, 0.6); }
.cast-wrapper { display: inline-block; }
.cast-picker {
  position: absolute; top: calc(100% + 4px); right: 0; z-index: 100;
  background: rgba(28, 28, 32, 0.97); border: 1px solid rgba(255, 255, 255, 0.14);
  border-radius: 8px; padding: 4px 0; min-width: 230px;
  box-shadow: 0 6px 20px rgba(0, 0, 0, 0.45);
}
.cast-picker-item {
  display: flex; align-items: center; gap: 8px; width: 100%;
  padding: 9px 14px; background: none; border: none;
  color: #e2e8f0; font-size: 0.82em; cursor: pointer; text-align: left;
  font-family: inherit;
}
.cast-picker-item:hover { background: rgba(255, 255, 255, 0.09); }
.cast-picker-item svg { width: 16px; height: 16px; flex-shrink: 0; }
.cast-picker-scanning { color: #94a3b8; cursor: default; }
.cast-picker-scanning:hover { background: none; }
.cast-picker-empty { color: #64748b; cursor: default; font-style: italic; }
.cast-picker-empty:hover { background: none; }
.cast-picker-sep { height: 1px; margin: 4px 10px; background: rgba(255,255,255,0.1); }
.cast-status-label {
  color: #94a3b8; font-size: 0.78em; display: flex; align-items: center;
  max-width: 420px; overflow: hidden; white-space: nowrap;
}

.video-container {
  width: 100%;
  max-width: min(90vw, 1280px);
  max-height: calc(100vh - 120px);
  display: flex;
  align-items: center;
  justify-content: center;
}

.video-container video {
  width: 100%;
  max-height: calc(100vh - 120px);
  border-radius: 4px;
  outline: none;
}

/* Where the film was picked up again. Sits over the picture rather than
   pushing it down, and clears itself once the viewer is watching. */
.video-container { position: relative; }

.video-resumed {
  position: absolute;
  left: 12px;
  top: 12px;
  display: flex;
  gap: 10px;
  align-items: center;
  padding: 6px 12px;
  border-radius: 999px;
  background: rgba(15, 23, 42, 0.82);
  color: #e2e8f0;
  font-size: 0.85rem;
  pointer-events: auto;
  animation: video-resumed-fade 6s forwards;
}

@keyframes video-resumed-fade {
  0%, 70% { opacity: 1; }
  100% { opacity: 0; visibility: hidden; }
}

.video-resumed .linklike {
  background: none;
  border: 0;
  padding: 0;
  color: #7dd3fc;
  cursor: pointer;
  font: inherit;
  text-decoration: underline;
}

.video-loading {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  text-align: center;
  color: #94a3b8;
  pointer-events: none;
  z-index: 1;
}

.video-loading-label {
  font-size: 0.95em;
}

.video-progress-bar {
  width: min(400px, 80vw);
  height: 6px;
  background: rgba(255, 255, 255, 0.1);
  border-radius: 3px;
  overflow: hidden;
  margin-bottom: 8px;
}

.video-progress-fill {
  height: 100%;
  background: var(--accent);
  border-radius: 3px;
  transition: width 0.15s;
}

.video-progress-text {
  font-size: 0.8em;
  color: #64748b;
}

.video-error {
  color: var(--error);
  font-size: 0.9em;
  text-align: center;
  max-width: 400px;
}


.play-btn {
  background: none;
  border: 1px solid var(--border);
  color: var(--success);
  width: 30px;
  height: 30px;
  border-radius: 6px;
  font-size: 0.85em;
  padding: 0;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  margin-right: 4px;
}
.play-btn:hover { background: var(--bg-raised); border-color: var(--success); }
.play-btn:disabled { opacity: 0.3; cursor: not-allowed; }

/* ── Admin panel ─────────────────────────────────────────────────────────── */

.admin-tabs {
  display: flex;
  gap: 4px;
  margin-bottom: 20px;
  border-bottom: 2px solid var(--border);
  flex-wrap: wrap;
}

.admin-tab {
  padding: 8px 16px;
  border: none;
  background: none;
  color: var(--text-secondary);
  font-size: 0.9em;
  cursor: pointer;
  border-bottom: 2px solid transparent;
  margin-bottom: -2px;
  transition: color 0.15s, border-color 0.15s;
}
.admin-tab:hover { color: var(--text); }
.admin-tab.active {
  color: var(--accent);
  border-bottom-color: var(--accent);
  font-weight: 600;
}

.admin-stats {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  gap: 16px;
  margin-bottom: 20px;
}

.stat-card {
  background: var(--bg-surface);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 20px;
  text-align: center;
}
.stat-card .stat-value {
  font-size: 2rem;
  font-weight: 700;
  color: var(--accent);
}
.stat-card .stat-label {
  font-size: 0.8em;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--text-dim);
  margin-top: 4px;
}

.admin-toolbar {
  display: flex;
  align-items: center;
  gap: 12px;
  margin-bottom: 12px;
  flex-wrap: wrap;
}

.admin-search {
  flex: 1;
  min-width: 180px;
  max-width: 300px;
  padding: 7px 12px;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--bg-base);
  color: var(--text);
  font-size: 0.9em;
}
.admin-search:focus { outline: none; border-color: var(--border-focus); }

.admin-select {
  padding: 7px 10px;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--bg-base);
  color: var(--text);
  font-size: 0.85em;
  cursor: pointer;
}

.admin-table {
  width: 100%;
  border-collapse: collapse;
  font-size: 0.88em;
}
/* A column head is a *translated* string, so its length is not ours to assume:
   "Username" is one word in English and "Nom d'utilisateur" is three in French,
   and uppercased with letter-spacing on top. Under `white-space: nowrap` the
   longer one sets the column's minimum width, and the whole table hangs off the
   side of a phone — which on Android takes every `position: sticky` element
   with it, the fault already recorded against the Files table one table along.
   A head that wraps onto two lines is legible; a table the reader cannot reach
   the right of is not. */
.admin-table th {
  text-align: left;
  padding: 8px 10px;
  border-bottom: 2px solid var(--border);
  color: var(--text-secondary);
  font-size: 0.8em;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  white-space: normal;
}
/* And the same rule as `.file-name`, for the same reason: these cells carry
   names somebody else chose — a username, a group name — and a name with no
   spaces in it has nowhere to break unless it is told to. The heads alone cost
   11px of overflow at 360px; with a realistic username in the fixture it was
   248px, and it reached 420px-wide windows too. */
.admin-table td {
  padding: 8px 10px;
  border-bottom: 1px solid var(--border);
  vertical-align: middle;
  word-break: break-word;
  min-width: 0;
}
.admin-table tr:hover { background: var(--bg-raised); }
.admin-table .admin-empty {
  text-align: center;
  padding: 24px;
  color: var(--text-dim);
  font-style: italic;
}

/* Two things this cell has to do: hold a row of buttons, and stay as tall as
   the rest of its row even when it is empty — the group owner cannot be
   removed, and neither can your own account in the admin table.

   `display: flex` did the second one and broke the first. **A flex <td> stops
   being a table cell**: it no longer stretches to the height of its row, so
   its bottom border is drawn wherever its own content ends. Measured, in a row
   whose other cells were `top 76, height 40`, the actions cell came out
   `top 77, height 30` — its rule nine pixels above the rest, which is the
   staggered lines across the members table.

   So the cell goes back to being a table cell, and an empty one is held open
   by a zero-width strut the height of a button. Reported as "un décalage sur
   les lignes du tableau listant les membres". */
.admin-actions {
  white-space: nowrap;
}
.admin-actions::before {
  content: '';
  display: inline-block;
  width: 0;
  height: 30px;
  vertical-align: middle;
}
.admin-actions > * { vertical-align: middle; }
.admin-actions > * + * { margin-left: 6px; }

/* ── Group settings sections ──────────────────────────────────────────────
   The group tab was three forms and a table, each laid out with inline styles
   on the element that needed it, which is why no two of them lined up. These
   are the pieces they all wanted. */

/* An input and its button. Wraps rather than overflowing on a phone, and the
   input takes the slack so the button keeps its size. */
.form-row {
  display: flex;
  gap: 8px;
  align-items: center;
  flex-wrap: wrap;
}
.form-row input {
  flex: 1;
  min-width: 160px;
}

/* A pairing or invitation code: typed one character at a time, off a screen or
   a piece of paper, so it is spaced and unambiguous. */
.code-input,
.code-display {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.code-display {
  font-size: 1.4em;
  letter-spacing: 2px;
  margin: 6px 0;
}

.settings-subform {
  margin-top: 14px;
  padding-top: 14px;
  border-top: 1px solid var(--border);
}

.device-list {
  list-style: none;
  margin: 10px 0 0;
  padding: 0;
}
.device-row {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 8px 0;
  border-bottom: 1px solid var(--border);
}
.device-row:last-child { border-bottom: none; }
.device-key {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 0.85em;
}
.device-meta {
  color: var(--text-secondary);
  font-size: 0.85em;
  flex: 1;
  min-width: 0;
}

.badge-owner {
  background: var(--accent);
  color: var(--accent-text);
}

.admin-btn {
  padding: 4px 10px;
  border: 1px solid var(--border);
  border-radius: 5px;
  background: var(--bg-base);
  color: var(--text);
  font-size: 0.82em;
  cursor: pointer;
  white-space: nowrap;
}
.admin-btn:hover { border-color: var(--accent); color: var(--accent); }
.admin-btn.danger { color: var(--error); }
.admin-btn.danger:hover { border-color: var(--error); }
.admin-btn:disabled { opacity: 0.4; cursor: not-allowed; }

/* Used in four places and never defined, so it rendered as a bare browser
   button next to styled ones. Same shape as .admin-btn, quieter. */
.btn-secondary {
  padding: 4px 10px;
  border: 1px solid transparent;
  border-radius: 5px;
  background: none;
  color: var(--text-secondary);
  font-size: 0.82em;
  cursor: pointer;
  white-space: nowrap;
}
.btn-secondary:hover { border-color: var(--border); color: var(--text); }
.btn-secondary:disabled { opacity: 0.4; cursor: not-allowed; }

/* Text that acts as a button: the "add a description" affordance should not
   compete with the group's name for attention. */
.link-btn {
  display: inline-flex;
  align-items: center;
  gap: 2px;
  padding: 0;
  border: none;
  background: none;
  color: var(--text-dim);
  font-size: 0.8em;
  cursor: pointer;
}
.link-btn:hover { color: var(--accent); }

/* The header is a flex row, so a textarea in it collapses to its content
   width unless told otherwise. */
.group-desc-edit { margin: 4px 0 6px; width: min(460px, 60vw); min-width: 260px; }
.group-desc-edit textarea {
  width: 100%;
  padding: 6px 8px;
  border: 1px solid var(--border);
  border-radius: 5px;
  background: var(--bg-surface);
  color: var(--text);
  font: inherit;
  font-size: 0.85em;
  resize: vertical;
}
.group-desc-edit textarea:focus { outline: none; border-color: var(--border-focus); }
.group-desc-edit div { display: flex; gap: 6px; margin-top: 4px; }

.settings-choice {
  display: flex;
  align-items: flex-start;
  gap: 8px;
  padding: 7px 0;
  cursor: pointer;
}
.settings-choice input { margin-top: 3px; flex-shrink: 0; }
.settings-choice strong { display: block; font-size: 0.88em; color: var(--text); }
.settings-choice .settings-hint { display: block; margin-top: 2px; }

/* A pasted address can be longer than the bubble; breaking it is better than
   a message that scrolls sideways. */
.chat-link {
  color: var(--accent);
  text-decoration: underline;
  word-break: break-all;
}
.chat-bubble-own .chat-link { color: inherit; }

/* ── Transfers widget ────────────────────────────────────────────────────── */

/* Green while transfers are running: the count in the badge says how many, but
   the colour is what is readable without looking at it. Back to the ordinary
   nav colour the moment the last one finishes. */
@keyframes pulse-green {
  0%, 100% { color: #86efac; }
  50% { color: #15803d; }
}
.transfer-btn.active .icon { animation: pulse-green 2s ease-in-out infinite; }

.transfer-wrap { position: relative; display: flex; align-items: center; }
.transfer-btn {
  background: none;
  border: none;
  padding: 0;
  cursor: pointer;
  display: flex;
  align-items: center;
}
.transfer-panel {
  position: absolute;
  top: calc(100% + 10px);
  right: -8px;
  width: 330px;
  max-height: 60vh;
  overflow-y: auto;
  background: var(--bg-surface);
  border: 1px solid var(--border);
  border-radius: 8px;
  box-shadow: var(--shadow-lg);
  z-index: 60;
  padding: 6px;
}
.transfer-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  font-weight: 600;
  font-size: 0.85em;
  color: var(--text);
  padding: 4px 6px 6px;
  border-bottom: 1px solid var(--border);
}
.transfer-item { padding: 8px 6px; border-bottom: 1px solid var(--border); }
.transfer-item:last-child { border-bottom: none; }
.transfer-line { display: flex; align-items: center; gap: 6px; }
.transfer-kind { color: var(--text-dim); display: flex; }
.transfer-name {
  flex: 1;
  font-size: 0.82em;
  color: var(--text);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
a.transfer-name {
  /* `--link` is not defined here; the palette's link colour is `--accent`. */
  color: var(--accent);
  text-decoration: underline;
  cursor: pointer;
}
.transfer-cancel {
  background: none;
  border: none;
  color: var(--text-dim);
  cursor: pointer;
  padding: 0 2px;
  display: flex;
}
.transfer-cancel:hover { color: var(--error); }
/* Same shape as cancel, and beside it: pausing and cancelling are the two
   things a person does to a transfer, and one of them is not destructive. */
.transfer-pause {
  background: none;
  border: none;
  color: var(--text-dim);
  cursor: pointer;
  padding: 0 2px;
  display: flex;
}
.transfer-pause:hover { color: var(--accent); }
/* Not a button: there is nothing to click. Dimmer than the cancel beside it,
   and it carries its explanation in a tooltip rather than in the row, which
   would be four lines of prose in a panel that has none. */
.transfer-nopause {
  color: var(--text-dim);
  opacity: .45;
  padding: 0 2px;
  display: flex;
  cursor: help;
}
/* A paused bar keeps its fill -- what was written is still on disk -- but stops
   looking like something in progress. */
.dl-fill.dl-paused { background: var(--text-dim); }
.transfer-meta {
  display: flex;
  justify-content: space-between;
  font-size: 0.72em;
  color: var(--text-dim);
  margin-top: 3px;
}
.transfer-failed { color: var(--error); }

/* ── File selection ──────────────────────────────────────────────────────── */

.sel-cell { width: 28px; text-align: center; }
.sel-cell input { cursor: pointer; }
.actions-wrap { position: relative; display: inline-flex; margin-left: 8px; }
/* Anchored to the right edge: the button sits at the end of the toolbar, so a
   menu opening leftwards is a menu half off the screen. */
.actions-wrap .file-menu { top: calc(100% + 4px); right: 0; left: auto; }
.actions-wrap .file-menu button { white-space: nowrap; }
.admin-btn.active { border-color: var(--accent); color: var(--accent); }

.admin-role-select {
  padding: 3px 6px;
  border: 1px solid var(--border);
  border-radius: 4px;
  background: var(--bg-base);
  color: var(--text);
  font-size: 0.85em;
  cursor: pointer;
}

.admin-detail-overlay {
  position: fixed;
  inset: 0;
  z-index: 150;
  background: rgba(0, 0, 0, 0.5);
  display: flex;
  align-items: center;
  justify-content: center;
}

.admin-detail-card {
  background: var(--bg-surface);
  border: 1px solid var(--border);
  border-radius: 12px;
  padding: 24px;
  min-width: 340px;
  max-width: 90vw;
  box-shadow: var(--shadow-lg);
}

.admin-detail-card h3 {
  margin-bottom: 16px;
  font-size: 1.1em;
}

.admin-detail-row {
  display: flex;
  justify-content: space-between;
  padding: 6px 0;
  font-size: 0.9em;
}
.admin-detail-row + .admin-detail-row {
  border-top: 1px solid var(--border);
}
.admin-detail-label { color: var(--text-secondary); }
.admin-detail-value { font-weight: 500; }

.admin-load-more {
  display: block;
  margin: 16px auto;
  padding: 8px 24px;
}

.blocklist-form {
  display: flex;
  gap: 8px;
  margin-bottom: 16px;
  flex-wrap: wrap;
}
.blocklist-form input {
  flex: 1;
  min-width: 180px;
  padding: 7px 12px;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--bg-base);
  color: var(--text);
  font-size: 0.9em;
}
.blocklist-form input:focus { outline: none; border-color: var(--border-focus); }

/* ── Overlay (mobile sidebar backdrop) ────────────────────────────────────── */

.overlay {
  display: none;
  position: fixed;
  inset: 0;
  top: 52px;
  background: rgba(0, 0, 0, 0.4);
  z-index: 40;
}
.overlay.visible { display: block; }

/* ── Responsive ───────────────────────────────────────────────────────────── */

@media (max-width: 768px) {
  .nav-hamburger { display: flex; }

  /* No tray to minimise into on a narrow layout, and the nav has the
     hamburger to fit here. The capability already keeps this out of any
     browser; this is the app's own window dragged narrow. */
  .nav-tray { display: none; }

  .sidebar {
    position: fixed;
    left: -240px;
    top: 52px;
    height: calc(100vh - 52px - var(--music-bar-h) - var(--index-dock-h));
    height: calc(100dvh - 52px - var(--music-bar-h) - var(--index-dock-h));
    z-index: 50;
    transition: left 0.2s;
    box-shadow: none;
  }
  .sidebar.open { left: 0; box-shadow: var(--shadow-lg); }

  .main { padding: 16px; }
  .page-center { padding: 16px; }
  .th-type, .td-type { display: none; }
  .th-date, .td-date { display: none; }
  /* And the group column, on the same grounds as those two: there is no room
     for it, and the name column is what a phone is short of. Nothing is lost
     with it. Search's Files view re-roots every result under a folder named
     after its group, so you are always inside exactly one group and the
     breadcrumb above the table already says which — the column repeated it on
     every row. */
  .td-group { display: none; }

  /* The toolbar's three groups each take a line rather than competing for one.
     `margin-left: auto` on the right-hand group is what pushed it off the
     screen once it no longer fitted. */
  .toolbar-group,
  .toolbar-group.right { width: 100%; margin-left: 0; }
  .tb-search { flex: 1 1 120px; }
  .tb-search input { width: 100%; }
  /* The actions sit on their own line under the toggle, and wrap within it. */
  .tb-actions { width: 100%; }

  /* video-app.js's toolbar isn't built from .toolbar-group (nothing else in
     it needs a right-hand group), so it never got the row-per-group
     treatment above — mode buttons + the All/Movies/Series control left no
     room at all for the filter field once both were on the same line. */
  .video-toolbar { flex-wrap: wrap; }
  .video-toolbar .tb-search { flex-basis: 100%; margin-left: 0; }

  /* ── Shared directories: not a table on a phone ────────────────────────
     Four columns — name, Writable, Removable, and the eject/remove pair —
     have a combined minimum of about 440px, and none of it is padding that
     can be squeezed: the two switches are 90px apiece by design and the
     buttons are touch targets. On a 390px screen that is ~100px hanging off
     the right, with the two buttons the first thing to go over the edge.
     Reported from a phone, with short directory names, so no amount of
     wrapping the *name* would have helped.

     So the row stops being a row. The head goes (each switch carries its own
     label instead, see `.sdt-col-toggle .toggle-switch-label`), the name
     takes a line of its own, and the two switches sit under it with the
     actions pushed to the right margin:

         📁 Musique
         [•] Writable   [•] Removable        ⏏  ✕

     Everything that was on screen is still on screen, and nothing is
     truncated. The rules are scoped to `.shared-directories-table` on
     purpose: `folder-tree.js` borrows `.shared-dirs-tbl` for a two-column
     list — a name and one button — which fits a phone as it is, and stacking
     it would only make it taller. */
  .shared-directories-table .shared-dirs-tbl,
  .shared-directories-table .shared-dirs-tbl tbody,
  .shared-directories-table .shared-dirs-tbl tr,
  .shared-directories-table .shared-dirs-tbl td { display: block; }
  .shared-directories-table .shared-dirs-tbl thead { display: none; }

  /* Two rows, not three. Left to wrap, the eject/remove pair ends up alone on
     a line of its own under the switches — legible, but a third of the height
     for two buttons, and they read as belonging to nothing. A grid puts them
     back beside the name they act on, which is where they were:

       ┌──────────────────────────┬────────┐
       │ 📁 Musique               │  ⏏  ✕  │
       ├──────────────────────────┴────────┤
       │ [•] Writable   [•] Removable      │
       └───────────────────────────────────┘

     The switches are not placed: they fall into the second row on their own,
     so a group with one switch (the create-group page has no Removable) or
     none needs no rule of its own. */
  .shared-directories-table .shared-dirs-tbl tr {
    display: grid;
    grid-template-columns: minmax(0, 1fr) auto;
    align-items: center;
    gap: 8px 16px;
    padding: 10px 0;
  }
  .shared-directories-table .shared-dirs-tbl td { padding: 0; }
  /* The separator moves to the row: with the cells turned into blocks, a
     border on each of them draws four lines instead of one. */
  .shared-directories-table .shared-dirs-tbl tbody tr + tr td { border-top: none; }
  .shared-directories-table .shared-dirs-tbl tbody tr + tr {
    border-top: 1px solid var(--border);
  }

  .shared-directories-table .sdt-col-dir {
    grid-column: 1;
    grid-row: 1;
    min-width: 0;
  }
  /* No longer a single truncated line: it has the width of the row to itself
     and there is no column left to protect from it. */
  .shared-directories-table .sdt-col-path {
    grid-column: 1 / -1;
    max-width: none;
    white-space: normal;
    overflow-wrap: anywhere;
  }
  .shared-directories-table .sdt-col-toggle {
    width: auto;
    min-width: 0;
    text-align: left;
  }
  .shared-directories-table .sdt-col-toggle .toggle-switch {
    justify-content: flex-start;
    gap: 8px;
  }
  .shared-directories-table .sdt-col-toggle .toggle-switch-label { display: inline; }
  /* Beside the name, against the right margin, exactly where the wide layout
     puts them. */
  .shared-directories-table .sdt-col-actions {
    grid-column: 2;
    grid-row: 1;
    justify-self: end;
  }

  /* Every pixel here is one the conversation does not get. */
  .group-header { margin-bottom: 10px; gap: 8px; }
  .group-tabs { --band-margin: 10px; }
  /* Six tabs at 18px of padding a side come to 380px, plus the main column's
     own 32px — wider than a 360px phone, and a page wider than the screen is
     what takes every pinned band out of the visible area on Android. They
     share the row instead of each claiming a fixed width, which also means a
     seventh application costs nothing: the icon is what identifies a tab, the
     padding around it never did. */
  .group-tab { flex: 1 1 0; min-width: 0; padding: 10px 0; }
  .chat-messages { padding: 12px; }

  /* The transfers panel stops hanging off its button.
     It is 330 px wide and anchored to the button's right edge, but that button
     is not at the right edge of the screen — the bell and the user menu come
     after it. So the panel extended past the left of the viewport: measured at
     138 px lost on a 320 px screen, 98 px at 360. What is cut is the left-hand
     side, which is where the file names are, so what remained was a strip of
     progress bars belonging to nothing.
     Anchoring to the viewport instead of to the button is the only thing that
     helps: capping the width does not, since the overflow comes from where the
     right edge is pinned. */
  .transfer-panel {
    position: fixed;
    top: 52px;
    right: 8px;
    /* Full width on a phone, and no wider than the desktop panel on a tablet,
       where stretching it across 768 px would be silly. Both edges are pinned
       to the viewport, so the width follows from the screen rather than from
       where the button happens to sit. */
    left: max(8px, calc(100vw - 428px));
    width: auto;
    max-height: calc(100vh - 68px);
  }
}

/* ── Minimise to tray ────────────────────────────────────────────────────── */

/* Monochrome on purpose: it sits next to the bell, and the bell's badge is the
   only thing in this corner allowed to pull the eye. Inherits --nav-text like
   every other nav control, so it follows the theme rather than carrying its own
   colour, and the glyph is a stroked `currentColor` icon with nothing to tint. */
.nav-tray {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  background: none;
  border: 0;
  padding: 4px;
  margin-right: 2px;
  color: var(--nav-text);
  cursor: pointer;
  border-radius: 6px;
  opacity: 0.75;
}
.nav-tray:hover,
.nav-tray:focus-visible {
  opacity: 1;
  background: rgba(255, 255, 255, 0.1);
}

/* ── Notification bell ───────────────────────────────────────────────────── */

.nav-notif {
  position: relative;
  /* --text is the page's text colour and the nav is dark: the emoji bell was
     coloured so it showed anyway, a stroked one would have been invisible. */
  color: var(--nav-text);
  text-decoration: none;
  font-size: 1.25rem;
  margin-right: 4px;
}
.notif-badge {
  position: absolute;
  top: -6px;
  right: -8px;
  /* `--danger` is not one of this stylesheet's variables — the palette calls
     it `--error`. The declaration was invalid, so the unread-count badge had
     white text on no background at all. Pre-existing; found while fixing four
     more of the same in the folder picker. */
  background: var(--error);
  color: #fff;
  font-size: 0.65rem;
  padding: 1px 5px;
  border-radius: 10px;
  font-weight: 700;
}

/* ── Notification feed ───────────────────────────────────────────────────── */

.notif-feed {
  margin-bottom: 24px;
}
.notif-feed h3 {
  margin: 0 0 8px;
  font-size: 1rem;
}
.notif-item {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 8px 12px;
  /* `--radius` was never defined — 8px is what the other cards use. */
  border-radius: 8px;
  background: var(--bg-surface);
  margin-bottom: 4px;
  cursor: pointer;
  border: 1px solid var(--border);
}
.notif-item:hover {
  background: var(--bg-raised);
}
.notif-unread {
  /* `--primary` is not defined here either; it is `--accent`. */
  border-left: 3px solid var(--accent);
  font-weight: 600;
}
.notif-kind {
  font-size: 0.75rem;
  color: var(--text-secondary);
  text-transform: uppercase;
  white-space: nowrap;
}
.notif-text {
  flex: 1;
  font-size: 0.9rem;
}
.notif-time {
  font-size: 0.75rem;
  color: var(--text-secondary);
  white-space: nowrap;
}

/* ── Create Group page ───────────────────────────────────────────────────── */




.form-field { margin-bottom: 20px; }
.form-label {
  display: block;
  font-size: 0.82em;
  font-weight: 600;
  color: var(--text-secondary);
  text-transform: uppercase;
  letter-spacing: 0.04em;
  margin-bottom: 8px;
}





.form-textarea {
  width: 100%;
  padding: 10px 12px;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--bg-base);
  color: var(--text);
  font-size: 0.95em;
  font-family: inherit;
  resize: vertical;
  min-height: 60px;
  line-height: 1.5;
}
.form-textarea:focus { outline: none; border-color: var(--border-focus); }
.form-char-count { text-align: right; font-size: 0.75em; color: var(--text-dim); margin-top: 4px; }

.group-desc {
  font-size: 0.85em;
  color: var(--text-secondary);
  line-height: 1.4;
  margin-bottom: 4px;
}

.group-card-desc {
  font-size: 0.8em;
  color: var(--text-dim);
  line-height: 1.3;
  margin: 4px 0 6px;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

/* PDF preview: fills the overlay, scrolls in its own viewer. */
.preview-pdf {
  width: min(1000px, 92vw);
  height: 82vh;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--bg-surface);
}

/* Irreversible actions look like it. */
.btn-danger {
  background: var(--error);
  color: #fff;
  border: none;
  padding: 8px 16px;
  border-radius: 6px;
  cursor: pointer;
  font-size: 0.9em;
}
.btn-danger:hover { filter: brightness(1.1); }
.btn-danger:disabled { opacity: 0.5; cursor: not-allowed; }

.notif-feed h3 { display: flex; align-items: center; justify-content: space-between; }
.notif-purge { font-size: 0.8em; padding: 4px 10px; }


/* ── Presence ──────────────────────────────────────────────────────────────
   Three states, and the third is deliberately not a colour: "unknown" must
   not read as a verdict. Colour alone never carries the meaning — each dot
   also has a title and an aria-label, since red and green are exactly the
   pair most colour-blind readers cannot separate. */

.presence {
  flex-shrink: 0;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  border: 1px solid transparent;
  transition: background 0.2s, border-color 0.2s;
}
.presence-online  { background: var(--success); }
.presence-offline { background: var(--error); }
.presence-unknown { background: transparent; border-color: var(--text-dim); }
/* The node is scanning this group's files right now (initial import, or a
   directory just added) — a state, not a verdict, so it pulses rather than
   sitting on a fixed color; green at rest, green again once scanning stops
   (driven by IndexProgress.scanning, guaranteed to turn back off — see
   daemon.py _progress_pusher and webrtc_server.py's handshake ack). */
.presence-indexing {
  background: var(--success);
  animation: presence-indexing-pulse 1.6s ease-in-out infinite;
}
@keyframes presence-indexing-pulse {
  0%, 100% { background: var(--success); }
  50%      { background: #f59e0b; }
}

.sidebar-item-name {
  overflow: hidden;
  min-width: 0;
}

/* Group name + its @owner handle, on one line. Names are unique only per owner
   account, so the handle is what tells two "photos" groups apart. */
.gn { display: flex; align-items: baseline; gap: 6px; min-width: 0; }
.gn-name {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.gn-owner {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: 0.8em;
  font-weight: 400;
  color: var(--text-dim);
  flex-shrink: 0;
}
h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; }
/* A card's h3 is reset to 1em (unlike a page heading, which is much larger),
   so the 0.55em above would render the @host at about half the body size —
   smaller than the name, as intended, but past the point of being readable.
   0.8em is the same ratio the standalone .gn-owner uses. */
.group-card h3 .gn-owner { font-size: 0.8em; }

/* ── Chat: paging and orientation ─────────────────────────────────────────── */

.chat-older-row { display: flex; justify-content: center; padding: 4px 0 10px; }

.chat-older-btn {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 5px 14px;
  border-radius: 14px;
  border: 1px solid var(--border);
  background: var(--bg-raised);
  color: var(--text-secondary);
  font-size: 0.8em;
  cursor: pointer;
}
.chat-older-btn:hover:not(:disabled) { background: var(--bg-surface); color: var(--text); }
.chat-older-btn:disabled { opacity: 0.6; cursor: default; }
.chat-older-icon { width: 13px; height: 13px; transform: rotate(180deg); }

.chat-start {
  text-align: center;
  color: var(--text-dim);
  font-size: 0.75em;
  padding: 2px 0 12px;
}

/* A rule with the date sitting on it — cheaper to scan than a date on every
   bubble, which is why the per-message stamp stays as just a time. */
.chat-day, .chat-unread {
  display: flex;
  align-items: center;
  gap: 10px;
  margin: 12px 0 6px;
  font-size: 0.72em;
  text-transform: uppercase;
  letter-spacing: 0.05em;
}
.chat-day::before, .chat-day::after,
.chat-unread::before, .chat-unread::after {
  content: '';
  flex: 1;
  height: 1px;
  background: var(--border);
}
.chat-day { color: var(--text-dim); }
.chat-day span, .chat-unread span { white-space: nowrap; }
.chat-unread { color: var(--accent); }
.chat-unread::before, .chat-unread::after { background: var(--accent); opacity: 0.4; }

/* Consecutive messages from one person: tighten the gap instead of repeating
   the name, so a burst reads as one turn in the conversation. */
.chat-msg-tight { margin-top: -2px; }

.chat-jump {
  position: absolute;
  left: 50%;
  transform: translateX(-50%);
  bottom: 72px;
  z-index: 5;
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 6px 14px;
  border-radius: 16px;
  border: 1px solid var(--border);
  background: var(--bg-surface);
  color: var(--text-secondary);
  font-size: 0.8em;
  cursor: pointer;
  box-shadow: var(--shadow-lg);
}
.chat-jump:hover { color: var(--text); }
.chat-jump.unread {
  background: var(--accent);
  color: var(--accent-text);
  border-color: var(--accent);
}
.chat-jump-icon { width: 13px; height: 13px; }


/* ── File toolbar actions ─────────────────────────────────────────────────────
   These replaced a dropdown labelled "Actions (n)". With at most four verbs,
   hiding them behind a menu cost a click and a guess about what was inside. */

.tb-actions { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }

.tb-icon-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 30px;
  height: 30px;
  padding: 0;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--bg-surface);
  color: var(--text-secondary);
  cursor: pointer;
  transition: background 0.12s, color 0.12s, border-color 0.12s;
}
.tb-icon-btn:hover:not(:disabled) {
  background: var(--bg-raised);
  color: var(--text);
  border-color: var(--accent);
}
/* Present but inert: the bar shows what exists here, and greys out what this
   selection cannot do. `not-allowed` rather than `default` so the reason is
   legible before the click. */
.tb-icon-btn:disabled {
  opacity: 0.35;
  cursor: not-allowed;
}
.tb-icon-btn .icon { width: 16px; height: 16px; }
.tb-icon-btn.danger { color: var(--error); }
.tb-icon-btn.danger:hover:not(:disabled) {
  background: var(--error-bg);
  border-color: var(--error);
  color: var(--error);
}


/* ── Choice lists ─────────────────────────────────────────────────────────────
   Replaces two side-by-side <button> panels. They carried the selection in a
   border colour and nothing else: a button conveys no chosen/not-chosen state
   to a screen reader, and side by side they read as two independent actions
   rather than one either/or. A radio group says exactly one of these, is
   navigable with the arrow keys, and can be labelled. */

.choice-list { display: flex; flex-direction: column; gap: 8px; }

.choice {
  display: flex;
  align-items: flex-start;
  gap: 10px;
  padding: 10px 12px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--bg-base);
  cursor: pointer;
  transition: border-color 0.12s, background 0.12s;
}
.choice:hover { border-color: var(--accent); }
.choice.selected { border-color: var(--accent); background: var(--bg-raised); }
.choice:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; }

.choice input[type="radio"] {
  margin: 3px 0 0;
  accent-color: var(--accent);
  flex-shrink: 0;
}

.choice-icon {
  width: 18px;
  height: 18px;
  margin-top: 1px;
  flex-shrink: 0;
  color: var(--text-secondary);
}
.choice.selected .choice-icon { color: var(--accent); }

.choice-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.choice-title { font-size: 0.9em; font-weight: 600; color: var(--text); }
.choice-desc { font-size: 0.82em; color: var(--text-secondary); line-height: 1.4; }

.btn-primary {
  padding: 10px 22px;
  border: none;
  border-radius: 8px;
  background: var(--accent);
  color: var(--accent-text);
  font-size: 0.95em;
  font-weight: 600;
  cursor: pointer;
}
.btn-primary:hover:not(:disabled) { background: var(--accent-hover); }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }

/* A root whose volume is absent. Its files stay listed — they are frozen, not
   deleted — so the row has to carry the difference. */
.root-offline {
  font-size: 0.85em;
  font-weight: 500;
  color: var(--warn, #d97706);
  margin-left: 6px;
}
.dir-row .root-offline { white-space: nowrap; }

/* ── Node management (D5) ────────────────────────────────────────────────── */

/* Deliberately no width of its own. It used to be `max-width: 700px`, which
   put the six-column audit table — timestamp, event, user, IP, group, detail —
   into a box narrower than the table, so it scrolled sideways inside the page
   while 260px of `.main` sat empty beside it. The Node page is a settings-
   shaped page and takes `.main`'s width like Settings, Profile and the
   create-group wizard. The class stays as the anchor for the test that says
   so. */

.node-group {
  background: var(--bg-surface);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 16px 20px;
  margin-bottom: 16px;
}
.node-group-stale {
  opacity: 0.65;
  border-color: var(--warn, #d97706);
}
.node-group-danger {
  border-color: var(--error, #dc2626);
}
.node-group-header {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
  flex-wrap: wrap;
  margin-bottom: 12px;
}
.node-group-header h3 { font-size: 1.05em; margin: 0; }
.node-group-stats {
  display: flex;
  gap: 12px;
  font-size: 0.85em;
  color: var(--text-secondary);
}
.node-warn { color: var(--warn, #d97706); font-weight: 500; }

.node-roots-header { margin-bottom: 8px; }

/* Shared directories table */
.shared-dirs-tbl { width: 100%; border-collapse: collapse; border: none; }
.shared-dirs-tbl th {
  text-align: left; font-size: 0.78em; font-weight: 500;
  color: var(--text-dim); padding: 0 12px 6px 0;
  text-transform: uppercase; letter-spacing: 0.04em;
  border: none;
}
.shared-dirs-tbl td { padding: 7px 12px 7px 0; border: none; vertical-align: middle; }
.shared-dirs-tbl tbody tr + tr td { border-top: 1px solid var(--border); }
.sdt-col-dir { min-width: 140px; }
/* The path is the only thing separating two libraries whose folders happen to
   share a basename, so it is shown — truncated, because it is usually long and
   rarely the thing being read. */
.sdt-col-path {
  color: var(--text-dim); font-size: 0.85em;
  max-width: 260px; overflow: hidden; text-overflow: ellipsis;
  white-space: nowrap;
}
.sdt-add-row { display: flex; gap: 6px; align-items: center; margin-top: 8px; }
.sdt-add-input {
  flex: 1 1 auto; min-width: 0; padding: 5px 8px;
  border: 1px solid var(--border); border-radius: 4px;
  background: var(--bg-surface); color: var(--text);
  font-family: inherit; font-size: 0.9em;
}
.sdt-col-toggle { width: 90px; text-align: center; }
.sdt-col-toggle th { text-align: center; }
.sdt-col-toggle .toggle-switch { justify-content: center; }
/* The switch carries its own label for the phone layout below, where there is
   no column head to read it from. Wherever the head *is* there, it would be
   the same word twice on every row. */
.sdt-col-toggle .toggle-switch-label { display: none; }
.sdt-col-actions { white-space: nowrap; text-align: right; }
.sdt-action-btn {
  background: none; border: 1px solid var(--border); border-radius: 4px;
  padding: 4px 7px; cursor: pointer; font-size: 0.85em; color: var(--text-dim);
  line-height: 1; vertical-align: middle;
}
.sdt-action-btn + .sdt-action-btn { margin-left: 6px; }
.sdt-action-btn:hover:not(:disabled) { background: var(--bg-raised); color: var(--text); }
.sdt-action-btn:disabled { opacity: 0.3; cursor: default; }
.sdt-action-danger:hover:not(:disabled) { color: var(--error); border-color: var(--error); }
.sdt-dir-name { display: inline-flex; align-items: center; gap: 6px; font-weight: 500; }
.sdt-dir-name .icon { width: 16px; height: 16px; flex-shrink: 0; }
.sdt-row-ejected { opacity: 0.5; }
.sdt-row-unavail { opacity: 0.6; }
.root-eject-btn {
  background: none; border: 1px solid var(--border); border-radius: 4px;
  padding: 2px 6px; cursor: pointer; font-size: 0.85em; color: var(--text-dim);
  line-height: 1;
}
.root-eject-btn:hover { background: var(--bg-raised); }
.root-eject-btn { margin-left: 8px; }
/* An ejected root in the Files table: still listed, deliberately — its files
   are frozen, not gone — but not somewhere you can walk into. */
.file-row.root-ejected { opacity: 0.5; }
.file-row.root-ejected td:not(.sel-cell) { cursor: default; }

/* The paperclip with no writable directory to write to. Shown rather than
   hidden, so the reason is discoverable instead of the control just being
   absent. */
.chat-attach-off { opacity: 0.35; cursor: not-allowed; }

.node-root {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  padding: 6px 0;
}
.node-root + .node-root { border-top: 1px solid var(--border); }
.node-root-info {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-wrap: wrap;
  min-width: 0;
}
.node-root-name {
  display: flex;
  align-items: center;
  gap: 4px;
  font-weight: 500;
}
.node-root-name .icon { width: 16px; height: 16px; flex-shrink: 0; }
.node-root-badge {
  font-size: 0.75em;
  padding: 1px 6px;
  border-radius: 4px;
  background: var(--accent);
  color: var(--accent-text);
}
.node-root-badge-warn {
  background: var(--warn, #d97706);
  color: #fff;
}
.node-root-kind {
  font-size: 0.8em;
  color: var(--text-dim);
}
.node-root-unavailable { opacity: 0.6; }

.node-message {
  font-size: 0.9em;
  color: var(--text-secondary);
  margin-bottom: 12px;
  padding: 8px 12px;
  background: var(--bg-raised);
  border-radius: 6px;
}

.btn-small {
  padding: 4px 10px;
  font-size: 0.82em;
  border-radius: 5px;
}

.node-roots > .btn-small { margin-top: 8px; }

.node-header {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
  margin-bottom: 4px;
}
.node-header h2 { margin-bottom: 0; }

.node-service {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: 10px;
  padding: 10px 12px;
  margin-bottom: 16px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--bg-raised);
}
.node-service-status {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 0.9em;
}
.node-service-actions { display: flex; gap: 8px; }
.node-service .error-msg { flex-basis: 100%; margin: 0; }

.node-section { margin-top: 16px; }
.node-section-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  margin-bottom: 8px;
}
.node-hint {
  font-size: 0.85em;
  color: var(--text-secondary);
  margin: 4px 0;
}

.node-table {
  width: 100%;
  font-size: 0.85em;
  border-collapse: collapse;
  margin-top: 4px;
}
.node-table th {
  text-align: left;
  padding: 4px 8px;
  border-bottom: 1px solid var(--border);
  color: var(--text-secondary);
  font-weight: 500;
}
.node-table td {
  padding: 4px 8px;
  border-bottom: 1px solid var(--border);
}
.node-table-scroll {
  overflow-x: auto;
  margin-top: 4px;
}
/* An Ed25519 key is 44 base64 characters with no break opportunity in them. */
.node-key {
  word-break: break-all;
  overflow-wrap: anywhere;
  margin-right: 8px;
}
/* Audit rows: keep the fixed-shape columns on one line (the IP must never be
   clipped); only the free-text detail column wraps. */
.node-table-audit td,
.node-table-audit th { white-space: nowrap; }
.node-table-audit td:last-child,
.node-table-audit th:last-child {
  white-space: normal;
  word-break: break-word;
  overflow-wrap: anywhere;
  min-width: 16ch;
  max-width: 48ch;
}
.node-audit-pager {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-top: 10px;
}

.node-deny-entry {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  padding: 4px 0;
  font-size: 0.85em;
}
.node-deny-entry + .node-deny-entry { border-top: 1px solid var(--border); }

.node-attach-form {
  display: flex;
  flex-direction: column;
  gap: 8px;
  margin-top: 8px;
  max-width: 400px;
}
.node-attach-form label {
  font-size: 0.82em;
  color: var(--text-secondary);
  margin-top: 4px;
}
.node-attach-form input,
.node-attach-form select {
  padding: 6px 10px;
  border: 1px solid var(--border);
  border-radius: 5px;
  background: var(--bg-base);
  color: var(--text);
  font-size: 0.9em;
  cursor: pointer;
}
.node-attach-form select option {
  background: var(--bg-base);
  color: var(--text);
}
.node-attach-dir-row {
  display: flex;
  gap: 6px;
  align-items: center;
}
.node-attach-dir-row input { flex: 1; }

.node-settings-grid {
  display: flex;
  flex-direction: column;
  gap: 0;
  margin-top: 8px;
}
.node-setting {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 8px 0;
  border-bottom: 1px solid var(--border);
  cursor: default;
}
.node-setting-label {
  font-size: 0.85em;
  color: var(--text-secondary);
}
.node-setting-value {
  font-size: 0.85em;
  font-weight: 500;
}
.node-setting-input {
  display: flex;
  align-items: center;
  gap: 6px;
}
.node-setting-input input[type="number"] {
  width: 80px;
  padding: 4px 8px;
  border: 1px solid var(--border);
  border-radius: 5px;
  background: var(--bg-base);
  color: var(--text);
  font-size: 0.85em;
  text-align: right;
}
.node-setting-input input[type="checkbox"] {
  width: 16px;
  height: 16px;
  accent-color: var(--accent);
}
.node-setting-unit {
  font-size: 0.8em;
  color: var(--text-secondary);
}
.node-settings-actions {
  margin-top: 12px;
  display: flex;
  gap: 8px;
}

/* ── STUN server list ─────────────────────────────────────────────────────── */
.stun-server-list {
  display: flex;
  flex-direction: column;
  gap: 0;
  margin-top: 8px;
}
.stun-server-entry {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  padding: 6px 0;
  border-bottom: 1px solid var(--border);
}
.stun-server-url {
  font-family: monospace;
  font-size: 0.85em;
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.stun-server-actions {
  display: flex;
  gap: 2px;
  flex-shrink: 0;
}
.btn-icon {
  min-width: 28px;
  padding: 2px 6px;
  font-size: 0.75em;
  line-height: 1;
}
.stun-add-row {
  display: flex;
  gap: 6px;
  margin-top: 10px;
}
.stun-add-row input {
  flex: 1;
  padding: 6px 10px;
  border: 1px solid var(--border);
  border-radius: 5px;
  background: var(--bg-base);
  color: var(--text);
  font-family: monospace;
  font-size: 0.85em;
}

/* ── Create Group Wizard ──────────────────────────────────────────────────── */

.wizard-root {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 8px 12px;
  background: var(--bg-surface);
  border: 1px solid var(--border);
  border-radius: 8px;
  gap: 8px;
}
.wizard-root + .wizard-root { margin-top: 6px; }
.wizard-root-info {
  display: flex;
  align-items: center;
  gap: 8px;
  flex: 1;
  min-width: 0;
}
.wizard-root-name { font-weight: 500; }
.wizard-root-path {
  font-size: 0.82em;
  color: var(--text-dim);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.wizard-root-actions {
  display: flex;
  gap: 4px;
  flex-shrink: 0;
}

.wizard-progress {
  display: flex;
  flex-direction: column;
  gap: 10px;
  margin-top: 16px;
}
.wizard-step {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 10px 14px;
  background: var(--bg-surface);
  border: 1px solid var(--border);
  border-radius: 8px;
  font-size: 0.9em;
}
.wizard-step-icon {
  width: 20px;
  text-align: center;
  font-weight: 600;
}
.wizard-step-done .wizard-step-icon { color: var(--success); }
.wizard-step-running .wizard-step-icon { color: var(--accent); }
.wizard-step-error .wizard-step-icon { color: var(--error); }
.wizard-step-pending { opacity: 0.5; }

/* ── Indexing progress (Create Group wizard, and "add a directory" in
   Settings) — bytes-based, not file-count-based; see the two call sites of
   platform.watchIndexProgress. ── */
.index-progress {
  padding: 10px 14px;
  background: var(--bg-surface);
  border: 1px solid var(--border);
  border-radius: 8px;
  font-size: 0.9em;
}
.index-progress-bar {
  height: 6px;
  border-radius: 3px;
  background: var(--border);
  overflow: hidden;
  margin-bottom: 8px;
}
.index-progress-fill {
  height: 100%;
  background: var(--accent);
  transition: width 0.3s ease;
}
.index-progress-label { font-weight: 600; }
.index-progress-dir {
  color: var(--text-dim);
  margin-top: 2px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.warning-msg {
  padding: 10px 14px;
  background: color-mix(in srgb, #f59e0b 15%, transparent);
  border: 1px solid #f59e0b;
  border-radius: 8px;
  font-size: 0.85em;
}

/* ── Videos app (video-app.js) ──────────────────────────────────────────────── */

.video-toolbar {
  display: flex;
  align-items: center;
  gap: 6px;
  /* A pinned band keeps this gap: `--band-margin` is both the margin in
     the flow and the width of the opaque ring the band paints around
     itself once pinned, so the two can never disagree (style.css,
     "Sticky chrome"). */
  --band-margin: 14px;
  margin-bottom: var(--band-margin);
}
.video-toolbar .tb-search { margin-left: auto; }

/* The page arrows (pager.js): pushed to the right edge, just before the filter
   when there is one — on a phone the filter wraps onto its own line and the
   arrows stay with the controls. */
.video-toolbar .tb-pager { margin-left: auto; }
.video-toolbar .tb-pager + .tb-search { margin-left: 0; }
.tb-pager {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  flex-shrink: 0;
}

/* Dropping files onto Files (files-app.js). Fixed and click-through: the drag
   events belong to the window, and nothing pinned may move while it shows. */
.drop-overlay {
  position: fixed;
  inset: 0;
  z-index: 900;
  pointer-events: none;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 16px;
  background: color-mix(in srgb, var(--accent) 10%, transparent);
  outline: 3px dashed var(--accent);
  outline-offset: -12px;
}
.drop-overlay.refused {
  background: color-mix(in srgb, var(--text-dim) 10%, transparent);
  outline-color: var(--border);
}
.drop-overlay-label {
  max-width: min(90vw, 480px);
  padding: 10px 16px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--bg-surface);
  color: var(--text);
  text-align: center;
  word-break: break-word;
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
}

.tb-pager-range {
  font-size: 0.83em;
  color: var(--text-dim);
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
}

/* All/Movies/Series — a segmented control, not three separate .tb-btn: the
   three are mutually exclusive (unlike the view-mode buttons, which read
   fine standalone), and grouping them under one shared border is what says
   so at a glance. */
.tb-typefilter {
  display: inline-flex;
  height: 32px;
  border: 1px solid var(--border);
  border-radius: 6px;
  overflow: hidden;
  flex-shrink: 0;
}
.tb-typefilter button {
  display: inline-flex;
  align-items: center;
  height: 100%;
  padding: 0 12px;
  border: none;
  background: var(--bg-surface);
  color: var(--text-dim);
  font-size: 0.83em;
  white-space: nowrap;
  cursor: pointer;
  transition: background 0.12s, color 0.12s;
}
.tb-typefilter button + button { border-left: 1px solid var(--border); }
.tb-typefilter button:hover { color: var(--accent); }
.tb-typefilter button.active {
  background: var(--accent);
  color: var(--accent-text);
}

/* Mode A — poster grid */

.video-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
  gap: 16px;
}

.video-tile-slot {
  min-height: 260px;
}

.video-card {
  cursor: pointer;
  border-radius: 8px;
  overflow: hidden;
  background: var(--bg-raised);
  border: 1px solid var(--border);
  transition: border-color 0.12s, transform 0.12s;
}
.video-card:hover { border-color: var(--accent); transform: translateY(-2px); }

.video-poster {
  width: 100%;
  aspect-ratio: 2 / 3;
  object-fit: cover;
  display: block;
  background: var(--bg-surface);
}

.video-poster-loading {
  display: flex;
  align-items: center;
  justify-content: center;
}

.video-poster-slot { display: contents; }

.video-thumb {
  width: 100%;
  aspect-ratio: 2 / 3;
  object-fit: cover;
  display: block;
  background: var(--bg-surface);
}
.video-thumb-empty {
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--text-dim);
}
.video-thumb-empty .icon { width: 28px; height: 28px; }

.video-card-info { padding: 8px 10px; }
.video-card-title {
  font-size: 0.88em;
  font-weight: 600;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.video-card-sub {
  font-size: 0.78em;
  color: var(--text-dim);
  margin-top: 2px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.video-card-group,
.music-card-group,
.photo-card-group {
  font-size: 0.72em;
  color: var(--accent);
  margin-top: 2px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.search-group-badge {
  margin-left: auto;
  flex-shrink: 0;
}

/* Detail modal — sits inside the existing .video-overlay */

.video-detail {
  width: min(720px, 92vw);
  max-height: calc(100vh - 100px);
  margin-top: 60px;
  background: var(--bg-surface);
  border-radius: 10px;
  overflow: hidden;
  display: flex;
  flex-direction: column;
}

/* A multi-season show: one size, one position, every season.
   Everything above the episode list is already a fixed height (three lines of
   synopsis, two of cast), so the last thing that could move the season picker
   is the list itself — the body scrolled as a whole, and a season with twice
   the episodes pushed the modal to its max height. That moved the modal as
   well as its contents: `.video-overlay` centres its child, so the taller
   season *started higher up the screen*, title bar and all. A constant height
   settles both — centred, a box of constant height is centred in the same
   place — which is why this is a `height` and not a `max-height`. Scoped to multi-season shows on purpose: a movie or a
   single-season show has no season to switch to, and a fixed height would buy
   it nothing but empty space. Capped at 760px so a tall desktop window does
   not turn a six-episode season into a mostly empty column. */
.video-detail.video-detail-steady { height: min(calc(100vh - 100px), 760px); }
.video-detail.video-detail-steady .video-detail-body { overflow: hidden; }
.video-detail.video-detail-steady .video-season-list {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  /* Reserved whether or not this particular season overflows: without it a
     scrolling season is a scrollbar narrower than a non-scrolling one, which
     re-wraps the file path above it and shifts everything below by a line —
     the same jump by a different route. */
  scrollbar-gutter: stable;
}
.video-detail .video-top-bar {
  position: static;
  background: var(--bg-raised);
  border-bottom: 1px solid var(--border);
}
.video-detail .video-title { color: var(--text); }
.video-detail .video-close { background: var(--bg-surface); color: var(--text); }
.video-detail .video-close:hover { background: var(--border); }

.video-detail-body {
  padding: 16px 20px;
  overflow-y: auto;
  /* A column so the episode list can be given the leftover height and scroll
     inside it. Safe against the usual flex surprise — `* { margin: 0 }` at the
     top of this file means there are no collapsing margins here to lose. */
  display: flex;
  flex-direction: column;
  min-height: 0;
}
/* The synopsis, and why it is not a -webkit-line-clamp.
   Three lines, the third ending in "… Read more" rather than at the right
   margin: line-clamp only ever puts its ellipsis at the end of the last line,
   and cannot leave room for anything after it. A float can — .video-detail-
   overview.clamped::before is a zero-width float two lines tall, so the
   toggle floated after it (clear: right) lands on line three and shortens
   that line box alone. The toggle is written before the text in video-app.js
   for the same reason: a float only pushes the line boxes that follow it.
   `.reserved` pins the same three lines from below for a multi-season show,
   so the height is a constant and not a range: min and max are the same
   number on purpose. A season whose synopsis runs two lines and one whose
   synopsis runs twelve must put the season menu, and every episode under it,
   at exactly the same place — anything less and the thing just clicked is no
   longer under the pointer. Three lines is what makes that constant cheap:
   at five, a short synopsis sat over three blank lines.
   +2px absorbs the sub-pixel line height that otherwise clips the last
   line's descenders. */
.video-overview-wrap { --ov-lh: 1.5em; --ov-lines: 3; }
.video-detail-overview {
  font-size: 0.9em;
  color: var(--text);
  line-height: 1.5;
}
.video-detail-overview.clamped {
  overflow: hidden;
  max-height: calc(var(--ov-lh) * var(--ov-lines) + 2px);
}
/* The same number as the max-height above, deliberately: clamped from above
   and pinned from below is a constant, and a constant is the whole point. */
.video-detail-overview.clamped.reserved {
  min-height: calc(var(--ov-lh) * var(--ov-lines) + 2px);
}
/* Only when there is actually something to expand. `overflow: hidden` above
   makes this a block formatting context, so it *contains* the float and grows
   to its height — a one-line movie synopsis with the spacer always present
   would stand two lines tall for no reason. `.has-more` is set by the measure
   that decides to render the toggle at all. */
.video-detail-overview.clamped.has-more::before {
  content: '';
  float: right;
  width: 0;
  height: calc(var(--ov-lh) * (var(--ov-lines) - 1));
}
.video-overview-toggle {
  float: right;
  clear: right;
  padding: 0 0 0 6px;
  border: none;
  background: none;
  color: var(--accent);
  font: inherit;
  cursor: pointer;
}
.video-overview-toggle:hover { text-decoration: underline; }
.video-overview-toggle.standalone {
  float: none;
  display: inline-block;
  padding: 0;
  margin-top: 4px;
  font-size: 0.9em;
}
.video-detail-facts {
  font-size: 0.82em;
  color: var(--text-dim);
  margin-top: 6px;
}
/* Two lines, ellipsised at the end of the second. A ten-name cast and a
   three-name one must not give the modal two different heights — and unlike
   the synopsis there is nothing to expand to, so the plain line clamp is the
   right tool here. */
.video-detail-cast {
  font-size: 0.82em;
  color: var(--text-secondary);
  margin-top: 6px;
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  overflow: hidden;
}
.video-detail-source {
  font-size: 0.78em;
  color: var(--text-dim);
  margin-top: 6px;
  word-break: break-all;
}
.video-detail-tmdbref { white-space: nowrap; }
.video-detail-nomatch {
  font-size: 0.85em;
  color: var(--warn, #d98324);
  margin-bottom: 4px;
}
.video-card-unmatched { outline: 1px dashed var(--warn, #d98324); outline-offset: -1px; }
.video-admin-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 8px 0; }
.video-card-flag {
  display: inline-block;
  margin-left: 4px;
  padding: 0 5px;
  border-radius: 8px;
  font-size: 0.8em;
  font-weight: 700;
  color: var(--warn, #d98324);
  border: 1px solid var(--warn, #d98324);
  cursor: help;
}

.video-season-list { margin-top: 14px; }
.video-season { margin-bottom: 14px; }
.video-season-header {
  font-weight: 600;
  font-size: 0.85em;
  color: var(--text-dim);
  margin-bottom: 6px;
  text-transform: uppercase;
  letter-spacing: 0.02em;
}
.video-episode-row {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  padding: 8px 10px;
  background: none;
  border: 1px solid var(--border);
  border-radius: 6px;
  color: var(--text);
  font-size: 0.85em;
  cursor: pointer;
  text-align: left;
  margin-bottom: 4px;
}
.video-episode-row:hover { border-color: var(--accent); color: var(--accent); }
.video-episode-row .icon { width: 14px; height: 14px; flex-shrink: 0; }
.video-episode-thumb-slot { width: 64px; height: 40px; flex-shrink: 0; }
.video-episode-thumb {
  width: 64px;
  height: 40px;
  object-fit: cover;
  border-radius: 4px;
  display: block;
  background: var(--bg-raised);
}
.video-episode-thumb.video-thumb-empty { border: 1px solid var(--border); }
.video-episode-label {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.video-episode-meta {
  color: var(--text-dim);
  font-size: 0.9em;
  flex-shrink: 0;
}

/* Mode B — flat list */

.video-flat-list { display: flex; flex-direction: column; gap: 4px; }

.video-flat-row {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 6px 8px;
  border-radius: 6px;
  cursor: pointer;
}
.video-flat-row:hover { background: var(--bg-raised); }

.video-flat-thumb-slot { width: 64px; height: 40px; flex-shrink: 0; }
.video-flat-thumb {
  width: 64px;
  height: 40px;
  object-fit: cover;
  border-radius: 4px;
  display: block;
  background: var(--bg-raised);
}
.video-flat-thumb.video-thumb-empty { border: 1px solid var(--border); }

.video-flat-info { min-width: 0; flex: 1; }
.video-flat-title {
  font-size: 0.88em;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.video-flat-sub { font-size: 0.78em; color: var(--text-dim); margin-top: 1px; }

.video-flat-folder { border-bottom: 1px solid var(--border); padding-bottom: 4px; margin-bottom: 4px; }
.video-flat-chevron { transition: transform 0.12s; flex-shrink: 0; }
.video-flat-chevron.open { transform: rotate(180deg); }
.video-flat-season { padding-left: 24px; margin-bottom: 8px; }
.video-flat-season .video-season-header { margin: 6px 0 4px; }

/* Season picker — docs/MESHBAY_DESIGN.md §9.7's per-season overview view.
   Was a row of pills with `overflow-x: auto`: a show with a dozen seasons hid
   most of them behind a horizontal scrollbar, worst on the narrow screens that
   can least afford it. One trigger and a menu is one row high whatever the
   season count, so nothing below it moves either. The menu is absolutely
   positioned inside .video-detail-body, which scrolls — the picker sits near
   the top of that body, so the panel opens over the episode list rather than
   past the bottom of the scroll area. */
.video-season-menu { position: relative; margin: 10px 0; }
.video-season-trigger {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  width: 100%;
  padding: 8px 12px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--bg-surface);
  color: var(--text);
  font-size: 0.85em;
  cursor: pointer;
  text-align: left;
}
.video-season-trigger:hover { border-color: var(--accent); color: var(--accent); }
.video-season-current { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.video-season-caret { width: 14px; height: 14px; flex-shrink: 0; color: var(--text-dim); }

/* Fixed to the viewport, with left/width/top-or-bottom/max-height supplied by
   placeSeasonPanel() in video-app.js — see the comment there. Absolutely
   positioned inside the modal it was clipped by the modal's own
   `overflow: hidden` whenever the seasons outran the room under the picker. */
.video-season-options {
  position: fixed;
  z-index: 240;
  background: var(--bg-surface);
  border: 1px solid var(--border);
  border-radius: 8px;
  box-shadow: var(--shadow-lg);
  padding: 4px 0;
  /* Twenty-five seasons will not fit a phone: the panel scrolls, the modal
     behind it does not have to. The inline max-height narrows this further to
     whatever room the trigger actually has. */
  max-height: 320px;
  overflow-y: auto;
}
.video-season-option {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  width: 100%;
  padding: 8px 14px;
  border: none;
  border-radius: 0;
  background: none;
  color: var(--text);
  font-size: 0.85em;
  text-align: left;
  cursor: pointer;
}
.video-season-option:hover { background: var(--bg-raised); }
.video-season-option.active { color: var(--accent); }
.video-season-option-count { color: var(--text-dim); font-size: 0.85em; flex-shrink: 0; }

/* No own margin/size: it sits in .video-admin-actions next to the plain
   .admin-btn "Re-match", and any extra here made the two different sizes. */
.video-fix-match { font-size: 0.82em; }

/* Operator search-and-correct overlay, layered on top of the detail modal.
   Must beat .video-overlay's own z-index: 200 — both are position: fixed,
   so without an explicit higher value here this one loses the stacking
   order despite being the later sibling in the DOM. */
.video-search-overlay { z-index: 210; }
.video-search-panel { max-width: 480px; }
.video-search-form { display: flex; gap: 8px; }
.video-search-form input {
  flex: 1;
  padding: 7px 10px;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--bg-surface);
  color: var(--text);
}
.video-search-hint { font-size: 0.78em; color: var(--text-dim); margin: 6px 0 2px; }
.video-search-error { font-size: 0.85em; color: var(--error); }
.video-search-results { display: flex; flex-direction: column; gap: 4px; margin-top: 10px; }
.video-search-result {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 6px;
  border: 1px solid transparent;
  border-radius: 6px;
  background: none;
  text-align: left;
  cursor: pointer;
}
.video-search-result:hover:not(:disabled) { border-color: var(--accent); background: var(--bg-raised); }
.video-search-result:disabled { opacity: 0.5; cursor: not-allowed; }
.video-search-result-thumb { width: 46px; height: 68px; flex-shrink: 0; }
.video-search-result-poster {
  width: 100%;
  height: 100%;
  object-fit: cover;
  border-radius: 4px;
}

/* ── Music app (docs/MESHBAY_DESIGN.md §9.8) ──────────────────────────────
   Reuses .video-overlay/.video-top-bar/.video-title/.video-close, .tb-btn/
   .tb-search, .video-flat-list/.video-flat-row/.video-flat-info/
   .video-flat-title/.video-flat-sub/.video-flat-folder/.video-flat-chevron
   and .video-thumb-empty as-is for a *folder* row (artist/album headers) —
   that part of Videos' flat list genuinely fits. A *track* row does not:
   it reuses .music-track-row (Mode A's own tracklist style) instead, and
   the tree gets its own .music-flat-children for indent/nesting, which
   Videos' single-level flat list never needed. Also genuinely different
   from Videos: album art is square, not 2:3; there is a persistent player
   bar; grouping is artist -> album, not movie/show. */

.music-artist-section { margin-bottom: 22px; }
.music-artist-heading {
  font-size: 0.95em;
  font-weight: 600;
  margin: 0 0 10px;
  color: var(--text);
}

.music-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
  gap: 16px;
}
.music-tile-slot { min-height: 200px; }

.music-card {
  /* The dots button floats over the cover's top-right corner. */
  position: relative;
  cursor: pointer;
  border-radius: 8px;
  overflow: hidden;
  background: var(--bg-raised);
  border: 1px solid var(--border);
  transition: border-color 0.12s, transform 0.12s;
}
.music-card:hover { border-color: var(--accent); transform: translateY(-2px); }

.music-cover {
  width: 100%;
  aspect-ratio: 1 / 1;
  object-fit: cover;
  display: block;
  background: var(--bg-surface);
}
/* The drawn-CD placeholder (DiscPlaceholder, music-app.js) — sized relative
   to the tile so it scales with .music-grid's auto-fill columns instead of
   one fixed px value, and never clipped by the square tile's own overflow. */
.music-disc-empty {
  width: 100%;
  aspect-ratio: 1 / 1;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--bg-surface);
  overflow: hidden;
}
.music-disc-svg { width: 82%; height: 82%; }

.music-card-info { padding: 8px 10px; }
.music-card-title {
  font-size: 0.88em;
  font-weight: 600;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.music-card-sub {
  font-size: 0.78em;
  color: var(--text-dim);
  margin-top: 2px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* Detail modal — sits inside the shared .video-overlay */

.music-detail {
  width: min(560px, 92vw);
  max-height: calc(100vh - 100px);
  margin-top: 60px;
  background: var(--bg-surface);
  border-radius: 10px;
  overflow: hidden;
  display: flex;
  flex-direction: column;
}
.music-detail .video-top-bar {
  position: static;
  background: var(--bg-raised);
  border-bottom: 1px solid var(--border);
}
.music-detail .video-title { color: var(--text); }
.music-detail .video-close { background: var(--bg-surface); color: var(--text); }
.music-detail .video-close:hover { background: var(--border); }

.music-queue-now-playing {
  padding: 10px 20px;
  border-bottom: 1px solid var(--border);
  font-size: 0.9em;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.music-queue-now-playing-title { font-weight: 600; color: var(--accent); }
.music-queue-now-playing-artist { color: var(--text-dim); }

.music-detail-body { padding: 16px 20px; overflow-y: auto; }
.music-detail-header {
  display: flex;
  align-items: flex-start;
  gap: 14px;
  margin-bottom: 14px;
}
.music-detail-cover {
  width: 96px;
  height: 96px;
  aspect-ratio: 1 / 1;
  object-fit: cover;
  border-radius: 6px;
  flex-shrink: 0;
  background: var(--bg-raised);
}
.music-detail-cover.music-disc-empty {
  width: 96px;
  height: 96px;
  border-radius: 6px;
  flex-shrink: 0;
  background: var(--bg-raised);
}
.music-detail-meta { min-width: 0; }
.music-detail-artist { font-weight: 600; font-size: 0.95em; }
.music-detail-date { font-size: 0.8em; color: var(--text-dim); margin: 2px 0 8px; }

.music-tracklist { display: flex; flex-direction: column; gap: 2px; }
.music-track-row {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  padding: 6px 8px;
  background: none;
  border: 1px solid transparent;
  border-radius: 6px;
  color: var(--text);
  font-size: 0.85em;
  cursor: pointer;
  text-align: left;
}
/* The row holds two controls — play it, and its menu — so it is a div, and
   the play action is the button inside it. A button cannot contain another
   button: the browser reparents the inner one and the row comes apart.
   music-player.js's queue panel has no menu and is still a plain button, which
   is why `cursor` stays on the row rather than moving to the child. */
.music-track-main {
  display: flex;
  align-items: center;
  gap: 10px;
  flex: 1;
  min-width: 0;
  padding: 0;
  border: none;
  background: none;
  color: inherit;
  font: inherit;
  text-align: left;
  cursor: pointer;
}
.music-track-row:hover { background: var(--bg-raised); border-color: var(--border); }
.music-track-row.active { border-color: var(--accent); color: var(--accent); }
.music-track-no { width: 24px; flex-shrink: 0; color: var(--text-dim); text-align: right; }
.music-track-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.music-track-duration { color: var(--text-dim); font-size: 0.9em; flex-shrink: 0; }

/* Mode B — flat list. .video-flat-thumb-slot's sizing is reused verbatim;
   only the empty-box icon differs, so it gets a name of its own. */
.music-flat-thumb {
  width: 64px;
  height: 40px;
  flex-shrink: 0;
  border-radius: 4px;
  border: 1px solid var(--border);
}
/* A folder's expanded contents — artist -> album -> track. Videos' own flat
   list never needed this (one level, folders of episodes, no further
   nesting) so there was nothing to reuse: every depth used to sit flush
   left, indistinguishable from its parent except by which chevron happened
   to be open. The rule line gives the eye a rail to follow down the tree;
   it nests for free (each level wraps its own children in one more of
   these), so an artist with a real album inside it reads as two visible
   steps in, not one. */
.music-flat-children {
  margin-left: 12px;
  padding-left: 12px;
  border-left: 2px solid var(--border);
  display: flex;
  flex-direction: column;
  gap: 2px;
}
/* A touch tighter vertically than Mode A's tracklist rows
   (.music-track-row, reused as the base) — this one already sits behind
   up to two levels of indent, so it gives a little of its own padding
   back rather than compounding with the rail. */
.music-flat-track { padding: 5px 8px; }

/* ── Persistent player bar (docs/MESHBAY_DESIGN.md §9.8) ──────────────────
   `position: sticky`, not `fixed` — deliberately: CLAUDE.md's own history
   records more than one layout bug from a fixed-position element quietly
   double-reserving space against a page that also sized itself against the
   viewport. Sticky stays in normal flow, so it never needs a height this
   stylesheet has to know about anywhere else. */
.music-player-bar {
  position: sticky;
  bottom: 0;
  z-index: 50;
  display: flex;
  align-items: center;
  gap: 16px;
  flex-wrap: wrap;
  margin-top: 16px;
  padding: 8px 16px;
  background: var(--bg-raised);
  border: 1px solid var(--border);
  border-radius: 10px;
}

/* ── Indexing dock (index-dock.js) ────────────────────────────────────────
   Sticky for the music bar's reason. It is rendered right before that bar, so
   `bottom: var(--music-bar-h)` stacks it on top when both are pinned, and it
   publishes its own height as `--index-dock-h` for the sidebar to stop above
   the two of them. */
.index-dock {
  position: sticky;
  bottom: var(--music-bar-h);
  z-index: 50;
  display: flex;
  flex-direction: column;
  gap: 6px;
  margin-top: 16px;
}
.index-dock-row {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 8px 12px;
  background: var(--bg-raised);
  border: 1px solid var(--border);
  border-radius: 10px;
}
.index-dock-main {
  flex: 1 1 auto;
  min-width: 0;
  display: block;
  padding: 0;
  border: 0;
  background: none;
  color: inherit;
  font: inherit;
  text-align: left;
  cursor: pointer;
}
.index-dock-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
  font-size: 0.85em;
}
.index-dock-title {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.index-dock-meta {
  color: var(--text-dim);
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
}
.index-dock-bar { display: block; margin: 6px 0 0; }
.index-dock-bar .index-progress-fill { display: block; }
.index-dock-done .index-progress-fill { background: var(--success); }
/* Walking the tree, or waiting for another root: no percentage to show yet,
   and a bar parked at 0 % reads as stuck. */
.index-dock-indeterminate { animation: index-dock-sweep 1.4s ease-in-out infinite; }
@keyframes index-dock-sweep {
  from { transform: translateX(-100%); }
  to   { transform: translateX(340%); }
}
.index-dock-next {
  display: block;
  margin-top: 4px;
  font-size: 0.78em;
  color: var(--text-dim);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.index-dock-hide {
  flex-shrink: 0;
  display: inline-flex;
  padding: 4px;
  border: 0;
  border-radius: 6px;
  background: none;
  color: var(--text-dim);
  cursor: pointer;
}
.index-dock-hide:hover { background: var(--bg-surface); }
.index-dock-hide .icon { width: 16px; height: 16px; }
@media (max-width: 640px) {
  .index-dock-head { flex-direction: column; gap: 2px; }
  .index-dock-title,
  .index-dock-meta { white-space: normal; }
}
@media (prefers-reduced-motion: reduce) {
  .index-dock-indeterminate { animation: none; }
}

.music-player-info {
  display: flex;
  align-items: center;
  gap: 10px;
  min-width: 0;
  flex: 1 1 160px;
}
.music-player-icon { width: 20px; height: 20px; color: var(--text-dim); flex-shrink: 0; }
.music-player-text { min-width: 0; }
.music-player-title {
  font-size: 0.85em;
  font-weight: 600;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.music-player-sub {
  font-size: 0.75em;
  color: var(--text-dim);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.music-player-transport {
  display: flex;
  align-items: center;
  gap: 4px;
  flex-shrink: 0;
}
.music-player-btn {
  background: none;
  border: none;
  color: var(--text-dim);
  width: 32px;
  height: 32px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  position: relative;
}
/* (hover: hover) — a tap on a touchscreen fires a synthetic hover that
   never gets a "pointer left" to end it, so next/prev/shuffle stayed
   highlighted as if still being pressed until some other element was
   tapped (found live on mobile Chrome, 2026-08-26; not reproducible on
   desktop, which actually has a mouse to move away with). Scoped to
   devices that can genuinely hover — real hover behaviour is unaffected. */
@media (hover: hover) {
  .music-player-btn:hover { background: var(--bg-surface); color: var(--text); }
}
.music-player-btn.active { color: var(--accent); }
.music-player-btn .icon { width: 16px; height: 16px; }
.music-player-play {
  width: 38px;
  height: 38px;
  background: var(--accent);
  color: var(--accent-text);
}
@media (hover: hover) {
  .music-player-play:hover { background: var(--accent-hover); color: var(--accent-text); }
}
.music-player-play:disabled { opacity: 0.6; cursor: not-allowed; }
.music-repeat-badge {
  position: absolute;
  bottom: 1px;
  right: 1px;
  font-size: 0.55em;
  font-weight: 700;
  line-height: 1;
  background: var(--accent);
  color: var(--accent-text);
  border-radius: 50%;
  width: 11px;
  height: 11px;
  display: flex;
  align-items: center;
  justify-content: center;
}

.music-player-seek {
  display: flex;
  align-items: center;
  gap: 8px;
  flex: 2 1 200px;
  min-width: 120px;
}
.music-player-time { font-size: 0.75em; color: var(--text-dim); flex-shrink: 0; width: 34px; }
.music-player-time:last-child { text-align: right; }
.music-player-seek input[type="range"] { flex: 1; }

.music-player-volume {
  display: flex;
  align-items: center;
  gap: 6px;
  flex-shrink: 0;
}
.music-player-volume .icon { width: 16px; height: 16px; color: var(--text-dim); }
.music-player-volume input[type="range"] { width: 80px; }

.music-player-extra { display: flex; align-items: center; gap: 2px; flex-shrink: 0; }

.music-player-error { flex-basis: 100%; font-size: 0.78em; color: var(--error); }

@media (max-width: 640px) {
  .music-player-bar { gap: 8px; }
  .music-player-seek { order: 4; flex-basis: 100%; }
  .music-player-volume { display: none; }
}

/* ── Photos app (photos-app.js, docs/MESHBAY_DESIGN.md §9.9) ──────────────── */

.photo-toolbar {
  display: flex;
  align-items: center;
  gap: 6px;
  /* A pinned band keeps this gap: `--band-margin` is both the margin in
     the flow and the width of the opaque ring the band paints around
     itself once pinned, so the two can never disagree (style.css,
     "Sticky chrome"). */
  --band-margin: 14px;
  margin-bottom: var(--band-margin);
}
.photo-toolbar .tb-search { margin-left: auto; }

/* Open-album title bar: name + year on the left, two big monochrome
   icon-only actions (back, zip) on the right — same row, same size as a
   group tab's own icon (.tab-icon, 22px) so they read as "app-level"
   controls rather than small inline buttons. */
.photo-album-bar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  /* A pinned band keeps this gap: `--band-margin` is both the margin in
     the flow and the width of the opaque ring the band paints around
     itself once pinned, so the two can never disagree (style.css,
     "Sticky chrome"). */
  --band-margin: 16px;
  margin-bottom: var(--band-margin);
}
.photo-album-heading {
  display: flex;
  align-items: center;
  gap: 6px;
  min-width: 0;
}
.photo-album-heading-text {
  display: flex;
  align-items: baseline;
  gap: 10px;
  min-width: 0;
}
.photo-album-heading-title {
  font-weight: 600;
  font-size: 1.15em;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.photo-album-heading-year { font-size: 0.85em; color: var(--text-dim); flex-shrink: 0; }

.photo-icon-btn {
  background: none;
  border: none;
  cursor: pointer;
  color: var(--text-secondary);
  padding: 7px;
  border-radius: 6px;
  display: flex;
  align-items: center;
  justify-content: center;
}
.photo-icon-btn:hover { color: var(--text); background: var(--bg-surface); }
.photo-icon-btn-icon { width: 22px; height: 22px; }

/* .tb-btn's own chevron is drawn pointing down; rotated here to read as
   "back" (left) without a second icon in icon.js. */
.photo-back-icon { transform: rotate(90deg); }

/* Landing view — one card per album (a directory containing images) */

.photo-album-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
  gap: 16px;
  margin-bottom: 24px;
}
.photo-album-tile-slot { min-height: 190px; }

.photo-album-card {
  cursor: pointer;
  border-radius: 8px;
  overflow: hidden;
  background: var(--bg-raised);
  border: 1px solid var(--border);
  transition: border-color 0.12s, transform 0.12s;
}
.photo-album-card:hover { border-color: var(--accent); transform: translateY(-2px); }

.photo-album-cover {
  width: 100%;
  aspect-ratio: 1 / 1;
  object-fit: cover;
  display: block;
  background: var(--bg-surface);
}
/* MediaThumb (video-app.js) always adds its own .video-thumb-empty class
   for the placeholder state, whatever `cls` is passed — that global rule
   already centers the icon; only the square sizing above is Photos-specific. */
.photo-album-cover.video-thumb-empty .icon,
.photo-tile-thumb.video-thumb-empty .icon { width: 24px; height: 24px; }

.photo-album-info { padding: 8px 10px; }
.photo-album-title {
  font-size: 0.86em;
  font-weight: 600;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.photo-album-sub { font-size: 0.75em; color: var(--text-dim); margin-top: 2px; }

/* Open-album view — a grid of the album's own photos */

.photo-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
  gap: 8px;
}
.photo-tile-slot { min-height: 110px; }
.photo-tile {
  cursor: pointer;
  border-radius: 6px;
  overflow: hidden;
  background: var(--bg-raised);
  border: 1px solid var(--border);
}
.photo-tile:hover { border-color: var(--accent); }
.photo-tile-thumb {
  width: 100%;
  aspect-ratio: 1 / 1;
  object-fit: cover;
  display: block;
  background: var(--bg-surface);
}

/* Lightbox — sits inside the shared .video-overlay/.video-top-bar/.video-title/
   .video-close, same as music-app.js's detail modal reuses them. */

/* Scoped to .photo-lightbox specifically — .video-top-bar itself stays
   transparent everywhere else that reuses it (VideoPlayer, the video/music
   detail modals), where nothing zooms underneath it. */
.photo-lightbox .video-top-bar {
  background: linear-gradient(to bottom, rgba(0, 0, 0, 0.8), transparent);
  padding-bottom: 24px;
}

.photo-lightbox-body {
  /* Bounded strictly between the top bar and the info strip, rather than
     the full-viewport height it used to take — a zoomed-in image, larger
     than the window, painted straight through that space (nothing ever
     reserved it) and sat visually behind the zoom controls, which had only
     a faint translucent highlight of their own to read against a bright,
     busy photo. Positioning the image area below the bar's own real,
     opaque strip (below) is what actually keeps them apart, not z-index —
     the buttons were never behind the image in stacking order, just hard
     to see in front of it. */
  position: absolute;
  top: 60px;
  bottom: 44px;
  left: 0;
  right: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 4px;
  padding: 0 8px;
}
.photo-nav {
  background: rgba(255, 255, 255, 0.1);
  border: none;
  color: #e2e8f0;
  width: 40px;
  height: 40px;
  border-radius: 50%;
  cursor: pointer;
  flex-shrink: 0;
  display: flex;
  align-items: center;
  justify-content: center;
}
.photo-nav:hover { background: rgba(255, 255, 255, 0.22); }
.photo-nav:disabled { opacity: 0.3; cursor: default; }
.photo-nav-icon { width: 20px; height: 20px; }
.photo-nav-prev-icon { transform: rotate(90deg); }
.photo-nav-next-icon { transform: rotate(-90deg); }

.photo-lightbox-image-slot {
  flex: 1;
  min-width: 0;
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  /* A zoomed-in image is routinely larger than the viewport — this is what
     makes it pannable instead of just clipped. Harmless when the image
     fits (fit-to-window, or zoomed below 100%): no scrollbar appears. */
  overflow: auto;
}
/* flex centering (align-items/justify-content: center) plus overflow:auto
   is a well-known trap: the browser centers overflowing content by
   shifting it, but the "leading" half of that overflow — here, the top of
   a zoomed-in photo — sits outside the range the scrollport actually
   exposes, so it can never be scrolled into view at all. Reported live as
   "unusable" (the top of the photo was gone, with no way to reach it).
   Switching to top/left alignment for the zoomed state fixes exactly that:
   the top-left corner is always where the image starts, and scrolling
   down/right reaches the rest — the same convention most image viewers
   use once you're past fit-to-window anyway. */
.photo-lightbox-image-slot.zoomed {
  align-items: flex-start;
  justify-content: flex-start;
}
.photo-lightbox-image {
  /* Relative to the now-properly-bounded slot (.photo-lightbox-body's
     top/bottom insets), not a guessed viewport fraction — correct at any
     window size without re-tuning a magic number. */
  max-width: 100%;
  max-height: 100%;
  object-fit: contain;
}
/* An explicit pixel size (photos-app.js's imgStyle) replaces the
   fit-to-window constraints above — object-fit has nothing left to do
   once both dimensions are set directly. */
.photo-lightbox-image.zoomed {
  max-width: none;
  max-height: none;
  object-fit: initial;
  display: block;
}

/* Zoom controls sit in the dark lightbox chrome (.video-top-bar), not the
   app's light Settings/toolbar chrome — .photo-icon-btn's own colors
   (meant for the light chrome elsewhere in Photos) are overridden here to
   match .video-close's existing treatment instead. */
.photo-zoom-controls { display: flex; align-items: center; gap: 2px; }
.photo-zoom-controls .photo-icon-btn {
  color: #e2e8f0;
  padding: 5px;
  border-radius: 4px;
}
.photo-zoom-controls .photo-icon-btn:hover { background: rgba(255, 255, 255, 0.15); }
.photo-zoom-controls .photo-icon-btn.active { background: rgba(255, 255, 255, 0.18); }
.photo-zoom-controls .photo-icon-btn:disabled { opacity: 0.35; cursor: default; }
.photo-zoom-controls .photo-icon-btn:disabled:hover { background: none; }
.photo-zoom-controls .photo-icon-btn-icon { width: 18px; height: 18px; }
.photo-icon-btn-text { font-size: 0.72em; font-weight: 600; padding: 5px 8px; }
.photo-zoom-percent {
  font-size: 0.75em;
  color: #94a3b8;
  min-width: 38px;
  text-align: center;
  flex-shrink: 0;
}

.photo-lightbox-info {
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 14px;
  flex-wrap: wrap;
  padding: 10px 20px;
  color: #cbd5e1;
  font-size: 0.78em;
  background: linear-gradient(to top, rgba(0, 0, 0, 0.55), transparent);
}
.photo-lightbox-count { color: #94a3b8; }

/* ── Folder tree picker (folder-tree.js) ─────────────────────────────────── */

.ftp-backdrop {
  position: fixed; inset: 0; z-index: 1200;
  background: rgba(0, 0, 0, 0.55);
  display: flex; align-items: center; justify-content: center;
  padding: 16px;
}
.ftp-panel {
  background: var(--bg-surface); color: var(--text);
  border: 1px solid var(--border); border-radius: 8px;
  width: min(520px, 100%); max-height: min(70vh, 640px);
  display: flex; flex-direction: column; padding: 16px;
  box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4);
}
.ftp-panel:focus { outline: none; }
.ftp-title { margin: 0 0 8px; font-size: 1em; }
/* The tree is the only part that scrolls: the title, the selection and the
   buttons stay put, so OK never travels off-screen in a deep library. */
.ftp-tree, .ftp-children { list-style: none; margin: 0; padding: 0; }
.ftp-tree {
  flex: 1 1 auto; overflow-y: auto; overflow-x: hidden;
  border: 1px solid var(--border); border-radius: 4px;
  padding: 4px 0; margin: 8px 0;
}
.ftp-row { display: flex; align-items: center; gap: 2px; }
.ftp-row.chosen { background: var(--bg-raised); }
/* 0.4 was low enough that a greyed-out root read as an artefact rather than
   a disabled control — the names were barely legible in the screenshot that
   prompted this. */
.ftp-row.blocked { opacity: 0.55; }
.ftp-twisty {
  flex: 0 0 auto; width: 20px; height: 20px; padding: 0;
  background: none; border: none; color: var(--text-dim);
  font-family: inherit; font-size: 0.95em; line-height: 1; cursor: pointer;
}
.ftp-twisty:disabled { cursor: default; opacity: 0; }
.ftp-label {
  flex: 1 1 auto; min-width: 0;
  display: flex; align-items: center; gap: 6px;
  background: none; border: none; color: inherit;
  font: inherit; text-align: left; padding: 3px 6px; cursor: pointer;
}
.ftp-label:disabled { cursor: not-allowed; }
.ftp-label .icon { width: 15px; height: 15px; flex-shrink: 0; }
.ftp-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ftp-badge {
  flex: 0 0 auto; margin-left: 8px;
  font-size: 0.72em; padding: 1px 5px; border-radius: 3px;
  border: 1px solid var(--border); background: var(--bg-raised);
  color: var(--text-dim); white-space: nowrap;
  text-transform: uppercase; letter-spacing: 0.03em;
}
.ftp-badge.rw { color: var(--accent); border-color: currentColor; }
.ftp-badge.warn { color: var(--error); border-color: currentColor; }
.ftp-check { margin-left: auto; flex: 0 0 auto; }
.ftp-selection {
  display: flex; flex-wrap: wrap; gap: 4px;
  max-height: 84px; overflow-y: auto; margin-bottom: 10px;
}
.ftp-chip {
  font-size: 0.82em; padding: 2px 6px; border-radius: 3px;
  background: var(--bg-raised); border: 1px solid var(--border);
}
.ftp-actions { display: flex; justify-content: flex-end; gap: 8px; }
/* An app's chosen folders. Deliberately not a .settings-row: that is a
   space-between flex row, and a label, a hint and a list laid out inside one
   end up spread across a single line in source order. */
.folder-field { padding: 8px 0; }
.folder-field-head { margin-bottom: 6px; }
.folder-field-label {
  margin: 0 0 2px; font-size: 0.9em; font-weight: 600; color: var(--text);
}
.folder-field-head .settings-hint { margin-bottom: 0; }
/* Borrows .shared-dirs-tbl so the two lists of directories on this page read
   as the same kind of thing. */
.folder-field-tbl { margin-bottom: 8px; }
.folder-field-tbl .sdt-col-dir { font-size: 0.9em; }
.folder-field-actions { display: flex; align-items: center; gap: 10px; }

/* HelloWorld (docs/MESHBAY_DESIGN.md §9.4) — the reference app, hidden
   behind ?dev=1. Deliberately plain: it exists to prove the plumbing, and
   anything decorative here would be a second thing to keep working. */
.hw-list { list-style: none; margin: 12px 0 0; padding: 0; }
.hw-item {
  display: flex; align-items: center; gap: 8px;
  padding: 6px 0; border-bottom: 1px solid var(--border);
}
.hw-item .icon { width: 15px; height: 15px; flex-shrink: 0; }
.hw-name { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hw-size { flex: 0 0 auto; color: var(--text-dim); font-size: 0.85em; }

/* A chat message this client could not open. Deliberately visible rather than
   hidden: a conversation quietly missing messages is worse than a gap, because
   nobody can notice what they were never shown. */
.chat-bubble-unreadable {
  background: var(--bg-raised);
  border: 1px dashed var(--border);
  color: var(--text-secondary);
}
.chat-unreadable {
  font-style: italic;
  opacity: 0.75;
}

/* "This account is using a key you have not seen before." Deliberately quiet:
   it is a notice, not an error, and it appears on a message that is otherwise
   perfectly readable. An alarm here would be clicked through. */
.chat-key-changed {
  font-size: 0.82em;
  color: var(--warn);
  margin-bottom: 2px;
}

/* A transfer waiting for a slot. Deliberately not a progress bar at 0%: it is
   not stalled and nothing is wrong, and a bar that never moves is exactly how a
   queue comes to look like a hang. The stripes say "not yet", not "broken". */
.dl-progress.dl-waiting {
  background-color: var(--bg-raised);
  background-image: repeating-linear-gradient(
    45deg,
    var(--accent-bg) 0 8px,
    transparent 8px 16px);
  animation: dl-waiting-slide 1.1s linear infinite;
}
@keyframes dl-waiting-slide {
  from { background-position: 0 0; }
  to   { background-position: 22.6px 0; }
}
/* The state has to survive without the motion: someone who asked for less of it
   still needs to see that this row is waiting rather than stopped. */
@media (prefers-reduced-motion: reduce) {
  .dl-progress.dl-waiting { animation: none; }
}

/* ── Transfers panel (grouped) ───────────────────────────────────────────── */

.transfer-head-title { font-weight: 600; }
.transfer-head-summary {
  margin-left: auto;
  margin-right: .5rem;
  font-size: .85em;
  color: var(--text-dim);
  /* Never wraps to a second line: it is the one part of the header that grows
     with what is happening, and a header that changes height as transfers come
     and go pushes every row under it. */
  white-space: nowrap;
}
.transfer-group + .transfer-group { border-top: 1px solid var(--border); }
.transfer-group-head {
  padding: .35rem .6rem .2rem;
  font-size: .78em;
  text-transform: uppercase;
  letter-spacing: .04em;
  color: var(--text-dim);
}
/* Finished rows recede rather than disappear: somebody who just downloaded
   three files wants to see that all three are there. */
.transfer-item.transfer-done .transfer-name,
.transfer-item.transfer-cancelled .transfer-name { color: var(--text-dim); }

/* Announced, not shown. The transfers panel needs a live region that says what
   changed state without drawing anything — used with aria-live, so it must
   stay in the accessibility tree: `display: none` would remove it from there
   too and announce nothing at all, which is the usual way this is got wrong. */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* ── Pop-up menus (menu.js) ────────────────────────────────────────────────
   One panel, opened at a point, for right-click on a cover and for the dots
   button beside a track. `position: fixed` because the coordinates come from
   a pointer or a `getBoundingClientRect()` — both viewport-relative, and an
   absolutely-positioned panel would be offset by whatever happens to be
   scrolled. menu.js measures and flips it; nothing here knows where it goes.
   docs/playlists.md §10.1. */

.ctx-menu {
  position: fixed;
  /* Above everything, modals included: this menu is opened *from* the album
     detail overlay, and `.video-overlay` is itself `position: fixed` at 200.
     Equal values would leave it to DOM order, which is not a decision. */
  z-index: 300;
  min-width: 220px;
  max-width: calc(100vw - 16px);
  /* A playlist's tracklist expands inside this panel rather than flying out
     sideways, so it can be far taller than the window. Same answer the
     account menu's language list already has.

     A floor, not the real value: this says how tall the panel may be and
     nothing about where its bottom lands, so a panel opened partway down the
     window overruns it and its last rows end up in a region of itself that is
     off-screen. `menu.js` measures where it put the panel and overrides this
     with the room actually left below that point. */
  max-height: calc(100vh - 16px);
  overflow-x: hidden;
  overflow-y: auto;
  /* Wheeling past the last track must not carry on into the page behind: that
     scroll is a real page scroll, and a page scroll closes this menu on
     purpose (menu.js). Without this, reaching the end of a long tracklist
     dismisses the menu, which is the same symptom the scroll handler was just
     fixed for, arriving by a different door. */
  overscroll-behavior: contain;
  padding: 4px 0;
  background: var(--bg-surface);
  border: 1px solid var(--border);
  border-radius: 10px;
  box-shadow: var(--shadow-lg);
}

.ctx-menu-item {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  padding: 9px 14px;
  border: none;
  background: none;
  color: var(--text);
  font: inherit;
  font-size: 0.9em;
  text-align: left;
  cursor: pointer;
}
.ctx-menu-item:hover:not(:disabled) { background: var(--sidebar-hover); }
.ctx-menu-item:disabled { color: var(--text-dim); cursor: default; }
.ctx-menu-item.danger { color: var(--error); }

.ctx-menu-icon { width: 16px; height: 16px; flex-shrink: 0; }
.ctx-menu-label {
  flex: 1;
  /* A track title is arbitrarily long and the panel is not: one line, cut,
     rather than a menu as wide as the longest filename in the library. */
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.ctx-menu-hint { color: var(--text-dim); font-size: 0.85em; flex-shrink: 0; }
.ctx-menu-caret {
  width: 14px; height: 14px; flex-shrink: 0;
  color: var(--text-dim);
  transition: transform 0.15s;
}
.ctx-menu-caret.flip { transform: rotate(180deg); }

.ctx-menu-divider { height: 1px; background: var(--border); margin: 4px 0; }

.ctx-menu-empty {
  padding: 8px 14px;
  color: var(--text-dim);
  font-size: 0.85em;
}

/* The dots button on a cover or a track row. Revealed on hover where there is
   a pointer to hover with, and permanently visible where there is not — a
   touchscreen has no hover state, and this is the *only* way in there. */
.ctx-dots {
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  width: 28px; height: 28px;
  padding: 0;
  border: none;
  border-radius: 6px;
  background: none;
  color: var(--text-dim);
  cursor: pointer;
  opacity: 0;
  transition: opacity 0.12s, background 0.12s;
}
.ctx-dots svg { width: 18px; height: 18px; }
.ctx-dots:hover { background: var(--sidebar-hover); color: var(--text); }
.ctx-dots:focus-visible { opacity: 1; outline: 2px solid var(--border-focus); }
.music-card:hover .ctx-dots,
.music-track-row:hover .ctx-dots { opacity: 1; }

@media (hover: none), (pointer: coarse) {
  .ctx-dots { opacity: 1; }
}

/* On a cover, floated over the top-right corner rather than taking a row of
   its own — the grid's tiles are sized by their art. */
.music-card-dots {
  position: absolute;
  top: 6px; right: 6px;
  background: rgba(0, 0, 0, 0.55);
  color: #fff;
  backdrop-filter: blur(2px);
}
.music-card-dots:hover { background: rgba(0, 0, 0, 0.75); color: #fff; }

/* A flat folder row expands on click; its menu button sits in the same row and
   must not take the whole width with it. */
.video-flat-row .ctx-dots { margin-left: auto; }
.video-flat-row .ctx-dots + .video-flat-chevron { margin-left: 8px; }

/* "Play all" and the album's menu, side by side under the cover. */
.music-detail-actions { display: flex; align-items: center; gap: 6px; }
.music-detail-actions .ctx-dots { opacity: 1; }

/* ── Playlists (playlist-menu.js) ─────────────────────────────────────────
   docs/playlists.md §10.3. One button in Music's sticky toolbar, and the two
   small surfaces behind it: a field for naming a playlist, and a one-line
   result that clears itself. */

.playlist-modal { max-width: 420px; }
.playlist-modal-body { padding: 16px; display: flex; flex-direction: column; gap: 12px; }
.playlist-modal-body input {
  width: 100%;
  padding: 9px 12px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--bg-base);
  color: var(--text);
  font: inherit;
}
.playlist-modal-body input:focus {
  outline: none;
  border-color: var(--border-focus);
}
.playlist-modal-error { color: var(--error); font-size: 0.85em; }
.playlist-modal-actions {
  display: flex;
  justify-content: flex-end;
  gap: 8px;
  flex-wrap: wrap;
}

/* ask.js — the page's own confirm/alert, in the playlist modal's frame. */
.ask-message { white-space: pre-line; line-height: 1.5; overflow-wrap: anywhere; }

/* The result of an action, said once and then gone. Not a dialog: adding an
   album to a playlist is not a thing anyone should have to dismiss. Fixed
   above the player bar, which is itself pinned to the bottom — `--music-bar-h`
   is published by the bar and is 0 when there is no bar. */
.playlist-note {
  position: fixed;
  left: 50%;
  bottom: calc(var(--music-bar-h, 0px) + 16px + env(safe-area-inset-bottom, 0px));
  transform: translateX(-50%);
  z-index: 250;
  max-width: calc(100vw - 32px);
  padding: 9px 16px;
  border-radius: 999px;
  background: var(--bg-surface);
  border: 1px solid var(--border);
  box-shadow: var(--shadow-lg);
  color: var(--text);
  font-size: 0.85em;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* A run of single-album artists, pooled into one grid so five of them fill one
   row instead of spending five (music-app.js `albumSections`).

   Each cell keeps its artist's name above the cover, in the same type as a
   multi-album artist's heading. Dropping it — the first version of this — made
   scrolling alternate between artists written large and artists written small,
   which is a worse problem than the whitespace it saved. */
.music-artist-pool { margin-bottom: 22px; }
.music-pool-cell { display: flex; flex-direction: column; min-width: 0; }
/* One line, clipped. A cell is about 170px wide, and a heading that wrapped to
   two lines would push its own cover below the others on its row — the grid
   stretches the cell, it does not align what is inside it. */
.music-pool-heading {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
