/* ==========================================================================
   app.css — touch shell foundation: self-hosted fonts, design tokens,
   blueprint primitives, and shared component/text-role classes.

   Values are ported verbatim from the committed design references:
   docs/design/touch-app-handoff/README.md (token block) and
   docs/design/touch-app-handoff/design/industry-tokens.css (.blueprint,
   .corner, .duotone, .btn*, .input, .field, .tag rules), with dark-theme
   overrides from the page-level <style> of
   docs/design/touch-app-handoff/design/thomas.fm Touch.dc.html.
   ========================================================================== */

/* -- fonts -- */
@font-face {
  font-family: 'Barlow';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('/static/fonts/barlow-400.woff2') format('woff2');
}
@font-face {
  font-family: 'Barlow';
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url('/static/fonts/barlow-600.woff2') format('woff2');
}
@font-face {
  font-family: 'Barlow Condensed';
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url('/static/fonts/barlow-condensed-600.woff2') format('woff2');
}

/* -- design tokens -- */
:root {
  /* ground */
  --color-bg:        #15100d;  /* screen ground */
  --color-surface:   #1d1712;  /* sheets, drawer */
  --page-ground:     #0c0907;  /* outside the app frame */
  --player-field:    #1f1710;  /* player chrome */
  --video-field:     #0c0806;  /* letterbox around video */
  --video-black:     #070504;  /* the picture itself */
  --track:           #2b211a;  /* progress track */
  --still:           #33281f;  /* image placeholder */

  /* ink */
  --color-text:      #f0ece8;
  --ink-muted:       #a2958a;  /* labels, secondary */
  --ink-dim:         #8d8074;  /* metadata, captions */

  /* accent (unchanged from the design system) */
  --color-accent:     #5980a6;
  --color-accent-300: #b5d9fd;  /* pressed on dark */
  --color-accent-400: #94bce3;  /* hover on dark, links */
  --accent-tint:      rgba(89,128,166,.12);  /* selected row / active rail */

  /* hairlines */
  --rule-strong: rgba(240,236,232,.16);  /* structural dividers */
  --rule-soft:   rgba(240,236,232,.12);  /* list row rules */
  --rule-faint:  rgba(240,236,232,.25);  /* control outlines on stills */

  /* type */
  --font-heading: "Barlow Condensed", system-ui, sans-serif;  /* 600 */
  --font-body:    "Barlow", system-ui, sans-serif;            /* 400 / 600 */

  --shadow-plate: 0 14px 40px rgba(0,0,0,.55);
  --scrim:        rgba(8,5,3,.7);
  --scrim-drawer: rgba(0,0,0,.58);

  --color-divider: rgba(240,236,232,.18);
  --subtitle-size: 22px;
}

/* -- reset --
   Scoped to `.shell` (the SPA shell's own `<body class="shell">`) rather
   than a bare `body` selector. Historical note: this scoping was
   originally required because views/layout.ejs linked this stylesheet
   alongside the legacy site.css for logged-in pages, and an unscoped
   body/:focus/::selection reset here would have silently restyled every
   old per-feature page (media, files, torrent, ...) that depended on
   site.css. Task 15 deleted site.css and those old views outright, so the
   original hazard is gone — the scoping is kept anyway (harmless, and
   loosening it is a separate, unnecessary risk). The logged-out pages
   rendered through that same layout (sign in, splash, 404, error, about)
   get their own equivalent reset scoped to `.app-public` / `.auth` — see
   "Public pages" below. */
*, *::before, *::after { box-sizing: border-box; }
body.shell {
  margin: 0;
  background: var(--color-bg);
  color: var(--color-text);
  font: 400 15px/1.55 var(--font-body);
}
body.shell :focus { outline: none; }
body.shell :focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
body.shell ::selection { background: rgba(89,128,166,.3); }

/* -- blueprint / corner / duotone / buttons / inputs / fields / tags /
   text roles / icon sprite base: every rule in this block is scoped under
   `:is(.shell, .auth, .app-public)` rather than a bare class selector.
   Historical note: these class names (.btn, .input, .field, .tag,
   .kicker, .meta, ...) are generic enough that the old per-feature views
   this scoping was built to protect (media/index.ejs, files/index.ejs,
   public/notes.js, public/torrent.js) reused the *same* class names for
   their own site.css-styled elements — an unscoped `.btn` class selector
   (0,1,0) always beats site.css's bare `button{}` element selector
   (0,0,1) regardless of stylesheet order, so those old buttons would have
   silently lost their site.css styling the moment views/layout.ejs linked
   app.css unconditionally. Task 15 deleted all of those old views and
   site.css itself, so the original collision is gone — the scoping is
   kept anyway (harmless). Scoping under the three ancestor contexts that
   actually opt into the new tokens (the shell's `<body class="shell">`,
   the sign-in `.auth` section, the `.app-public` main wrapper used by
   splash/404/error/about) makes every rule below inert anywhere else
   while leaving it fully active everywhere it's meant to apply. -- */
:is(.shell, .auth, .app-public) .blueprint {
  position: relative;
  border: 1px solid var(--color-divider);
  border-radius: 0;
}
/* The overlay image treatments (halftone, duotone) clip their overlay
   (overflow:hidden); a blueprint wrapper draws its registration marks
   outside the box, so when both classes share a wrapper the frame must
   win. */
:is(.shell, .auth, .app-public) .blueprint.halftone,
:is(.shell, .auth, .app-public) .blueprint.plate,
:is(.shell, .auth, .app-public) .blueprint.duotone { overflow: visible; }
:is(.shell, .auth, .app-public) .blueprint > .corner {
  position: absolute; width: 11px; height: 11px;
  color: color-mix(in srgb, var(--color-text) 55%, transparent);
}
:is(.shell, .auth, .app-public) .blueprint > .corner::before,
:is(.shell, .auth, .app-public) .blueprint > .corner::after {
  content: ""; position: absolute; background: currentColor;
}
:is(.shell, .auth, .app-public) .blueprint > .corner::before { left: 5px; top: 0; width: 1px; height: 100%; }
:is(.shell, .auth, .app-public) .blueprint > .corner::after  { top: 5px; left: 0; width: 100%; height: 1px; }
:is(.shell, .auth, .app-public) .blueprint > .corner.tl { top: -6px; left: -6px; }
:is(.shell, .auth, .app-public) .blueprint > .corner.tr { top: -6px; right: -6px; }
:is(.shell, .auth, .app-public) .blueprint > .corner.bl { bottom: -6px; left: -6px; }
:is(.shell, .auth, .app-public) .blueprint > .corner.br { bottom: -6px; right: -6px; }

:is(.shell, .auth, .app-public) .duotone{position:relative;overflow:hidden}
:is(.shell, .auth, .app-public) .duotone::after{content:"";position:absolute;inset:0;pointer-events:none;
  background:var(--color-accent);mix-blend-mode:color}

/* -- buttons / inputs / fields / tags (ported from industry-tokens.css:139-233,
   dark overrides from thomas.fm Touch.dc.html, squared corners) -- */
:is(.shell, .auth, .app-public) .btn {
  display: inline-flex; align-items: center; justify-content: center; gap: 6px;
  cursor: pointer; text-decoration: none;
  font-family: var(--font-heading); font-weight: 600;
  font-size: 14px; line-height: 1.2; color: var(--color-text);
  background: transparent; border: 1px solid transparent;
  padding: 6.8px 12.24px; /* var(--space-2) calc(var(--space-3) * 1.2) */
  border-radius: 0;
}
:is(.shell, .auth, .app-public) .btn svg { display: block; }
:is(.shell, .auth, .app-public) .btn:disabled { opacity: 0.45; cursor: not-allowed; }
:is(.shell, .auth, .app-public) .btn-primary { background: var(--color-accent); color: #15100d; }
:is(.shell, .auth, .app-public) .btn-primary:hover { background: var(--color-accent-400); }
:is(.shell, .auth, .app-public) .btn-primary:active { background: var(--color-accent-300); }
:is(.shell, .auth, .app-public) .btn-secondary { border-color: var(--color-divider); }
:is(.shell, .auth, .app-public) .btn-secondary:hover { background: rgba(240,236,232,.09); }
:is(.shell, .auth, .app-public) .btn-secondary:active { background: color-mix(in srgb, var(--color-text) 14%, transparent); }
:is(.shell, .auth, .app-public) .btn-ghost { color: var(--color-accent); padding-inline: 3.4px; } /* var(--space-1) */
:is(.shell, .auth, .app-public) .btn-ghost:hover { background: color-mix(in srgb, var(--color-accent) 10%, transparent); }
:is(.shell, .auth, .app-public) .btn-ghost:active { background: color-mix(in srgb, var(--color-accent) 18%, transparent); }
:is(.shell, .auth, .app-public) .btn-icon { width: 36px; height: 36px; padding: 0; }
:is(.shell, .auth, .app-public) .btn-block { width: 100%; margin-top: 6.8px; } /* var(--space-2) */

:is(.shell, .auth, .app-public) .field > label {
  display: block; font-size: 12px; margin-bottom: 5px;
  color: color-mix(in srgb, var(--color-text) 70%, transparent);
}
:is(.shell, .auth, .app-public) .input {
  width: 100%; min-height: 36px; padding: 6px 10px; font: inherit;
  font-size: 14px; color: var(--color-text); caret-color: var(--color-accent);
  background: var(--color-surface);
  border: 1px solid var(--color-divider); border-radius: 0;
}
:is(.shell, .auth, .app-public) .input::placeholder { color: var(--ink-dim); }
:is(.shell, .auth, .app-public) .input:hover { border-color: color-mix(in srgb, var(--color-text) 45%, transparent); }
:is(.shell, .auth, .app-public) .input:focus-visible { border-color: var(--color-accent); outline-offset: 0; }
:is(.shell, .auth, .app-public) textarea.input { min-height: 90px; resize: vertical; }

:is(.shell, .auth, .app-public) .tag {
  display: inline-flex; align-items: center; font-size: 11px;
  letter-spacing: 0.02em; padding: 3px 10px;
  border-radius: 0;
}
:is(.shell, .auth, .app-public) .tag-outline { border: 1px solid var(--color-accent-400); color: var(--color-accent-400); }

/* -- switch (real checkbox behind the squared track/knob) — used by the
   sign-in "Keep user signed in" toggle and Settings' interface options -- */
:is(.shell, .auth, .app-public) .switch {
  display: flex; align-items: center; gap: 10px;
  cursor: pointer; user-select: none;
}
:is(.shell, .auth, .app-public) .switch input {
  position: absolute; opacity: 0; width: 1px; height: 1px; /* stays focusable for keyboard toggling */
}
:is(.shell, .auth, .app-public) .switch-track {
  width: 44px; height: 26px; flex: none;
  border: 1px solid var(--color-divider);
  display: flex; align-items: center; padding: 2px;
  transition: border-color .14s ease, background .14s ease;
}
:is(.shell, .auth, .app-public) .switch-knob {
  width: 20px; height: 20px;
  background: var(--ink-dim);
  transition: transform .14s ease, background .14s ease;
}
:is(.shell, .auth, .app-public) .switch input:checked + .switch-track {
  border-color: var(--color-accent);
  background: var(--accent-tint);
}
:is(.shell, .auth, .app-public) .switch input:checked + .switch-track .switch-knob {
  transform: translateX(18px);
  background: var(--color-accent);
}
:is(.shell, .auth, .app-public) .switch input:focus-visible + .switch-track {
  outline: 2px solid var(--color-accent); outline-offset: 2px;
}

/* -- shared text roles (used by every screen) -- */
/* Ported from docs/design/touch-app-handoff/design/industry-tokens.css:121
   (`a { color: var(--color-accent); text-underline-offset: 3px; }`) — the
   design system's own default, never previously added here because no
   earlier screen needed a plain inline `<a>` (Task 13's About LinkedIn
   link is the first). Deliberately `:where(...)` here, not `:is(...)`
   like every other rule in this block: `:where()` contributes zero
   specificity, so this stays a low-specificity *default* — e.g.
   `.rss2-article-headline` (a bare, unscoped class rule, Task 11) keeps
   its own `color:var(--color-text)` regardless of source order. Using
   `:is(...)` here instead would make this element-default rule
   (specificity 0,1,1) beat that bare class rule (0,1,0) outright and
   silently recolor the RSS article links. */
:where(.shell, .auth, .app-public) a { color: var(--color-accent); text-underline-offset: 3px; }
:is(.shell, .auth, .app-public) .kicker{font:600 10px/1 var(--font-heading);letter-spacing:.16em;text-transform:uppercase;color:var(--color-accent)}
:is(.shell, .auth, .app-public) .section-label{font:600 11px/1 var(--font-heading);letter-spacing:.14em;text-transform:uppercase;color:var(--ink-muted)}
:is(.shell, .auth, .app-public) .screen-title{margin:0;font:600 34px/1 var(--font-heading);letter-spacing:-.015em;color:var(--color-text)}
:is(.shell, .auth, .app-public) .sheet-title{margin:0;font:600 26px/1 var(--font-heading);color:var(--color-text)}
:is(.shell, .auth, .app-public) .hairline{height:1px;background:var(--rule-strong)}
:is(.shell, .auth, .app-public) .meta{font:400 12px/1.4 var(--font-body);color:var(--ink-dim)}
:is(.shell, .auth, .app-public) .num{font-variant-numeric:tabular-nums}

/* -- icon sprite base (Lucide symbols, stroke-width 1.5, sized per context) -- */
:is(.shell, .auth, .app-public) .icon { display: block; width: 24px; height: 24px; flex-shrink: 0; }
/* Chromium (and historically other engines) resolve a bare `<svg>` root
   against SVG's own UA stylesheet, which has no `[hidden]{display:none}`
   rule (that rule lives in the HTML UA sheet) — so the shell's icon-sprite
   `<svg hidden aria-hidden="true">` defs wrapper rendered at its default
   inline 300x150 intrinsic box instead of collapsing, pushing every
   following sibling (`.shell-frame`) down by that much and making
   `.watch-live`'s bottom-pinned footer (and anything else relying on a
   screen filling exactly 100% height) run off the bottom of the viewport.
   Force it explicitly rather than relying on the attribute alone. */
.shell > svg[hidden] { display: none; }

/* ==========================================================================
   Shell layout: frame, rail (tablet) / bottom bar (phone), More drawer.
   Geometry read verbatim from docs/design/touch-app-handoff/README.md §2
   ("Rail and More drawer") and the tablet/phone plates in
   docs/design/touch-app-handoff/design/thomas.fm Touch.dc.html
   (rail markup ~L40-58, drawer ~L313-338, phone bottom bar ~L484-489).
   ========================================================================== */

.shell-frame {
  display: flex;
  width: 100vw;
  height: 100vh;
  height: 100dvh;
  overflow: hidden;
}

/* -- rail (tablet, >=820px) -- */
.rail {
  flex: none;
  width: 92px;
  background: transparent;
  border-right: 1px solid var(--rule-strong);
  display: flex;
  flex-direction: column;
  align-items: stretch;
  padding: 14px 0 12px;
}
.rail-item {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 7px;
  height: 78px;
  width: calc(100% - 16px);
  margin: 0 8px 4px;
  border: none;
  border-left: 3px solid transparent;
  cursor: pointer;
  background: transparent;
  font: 600 12px/1 var(--font-heading);
  letter-spacing: .09em;
  text-transform: uppercase;
  color: var(--ink-muted);
  transition: background .14s ease, border-color .14s ease, color .14s ease;
}
.rail-item .icon { width: 26px; height: 26px; }
.rail-item.is-active {
  color: var(--color-accent);
  background: var(--accent-tint);
  border-left-color: var(--color-accent);
}
.rail-spacer { flex: 1; }
.rail-item.rail-hide { height: 44px; margin-top: 4px; color: var(--ink-dim); }
.rail-item.rail-hide .icon { width: 20px; height: 20px; }

/* -- rail hide / auto-hide (tablet only — the phone bottom bar keeps its
   fixed chrome). body.rail-hidden slides the rail off-canvas and arms a
   thin hot strip on the left edge; hovering (or tapping) it adds
   body.rail-peek, which brings the rail back as an overlay until the
   pointer leaves. State lives in localStorage (railHidden / railAutoHide)
   — see the rail visibility block in app.js. -- */
.rail-hotzone { display: none; }
@media (min-width: 820px) {
  .rail { transition: transform .18s ease; }
  body.rail-hidden .rail {
    position: fixed;
    left: 0; top: 0; bottom: 0;
    width: 92px;
    z-index: 60;
    background: var(--color-surface);
    transform: translateX(-100%);
  }
  body.rail-hidden.rail-peek .rail {
    transform: none;
    box-shadow: 14px 0 34px rgba(0,0,0,.3);
  }
  body.rail-hidden .rail-hotzone {
    display: block;
    position: fixed;
    left: 0; top: 0; bottom: 0;
    width: 14px;
    z-index: 59;
  }
}

/* -- screens -- */
.screens {
  flex: 1;
  min-width: 0;
  height: 100%;
  overflow-y: auto;
}
.screen {
  min-height: 100%;
}
.screen[hidden] {
  display: none;
}

/* -- More drawer -- */
.drawer-scrim {
  position: fixed;
  inset: 0;
  background: var(--scrim-drawer);
  display: flex;
  justify-content: flex-start;
  z-index: 50;
}
.drawer-scrim[hidden] { display: none; }
.drawer-panel {
  flex: none;
  width: 330px;
  margin-left: 92px;
  background: var(--color-surface);
  border-right: 1px solid var(--rule-strong);
  padding: 26px 22px;
  display: flex;
  flex-direction: column;
  gap: 6px;
}
.drawer-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 12px;
}
.drawer-head .btn-close { height: 40px; width: 40px; padding: 0; }
.drawer-row {
  height: 60px;
  display: flex;
  align-items: center;
  gap: 14px;
  padding: 0 12px;
  background: transparent;
  border: none;
  border-bottom: 1px solid var(--rule-soft);
  cursor: pointer;
  font: 400 16px/1 var(--font-body);
  color: var(--color-text);
  text-align: left;
}
.drawer-row .icon { width: 22px; height: 22px; color: var(--color-accent); }
.drawer-row .tag { margin-left: auto; }
.drawer-spacer { flex: 1; }
.drawer-logout { height: 52px; width: 100%; }

/* -- phone (<820px): rail becomes a bottom bar -- */
@media (max-width: 819.98px) {
  .shell-frame { flex-direction: column-reverse; }
  .rail {
    width: auto;
    height: 82px;
    flex: none;
    display: grid;
    /* Column count is set by app.js to (visible entries + More), because the
       number of entries that fit now depends on the window's width rather than
       being fixed at four. A hardcoded 5 wrapped the bar onto a second row and
       clipped the More label the moment a fifth entry fit. */
    grid-template-columns: repeat(var(--rail-cols, 5), 1fr);
    padding: 0;
    border-right: none;
    border-top: 1px solid var(--rule-strong);
  }
  .rail-spacer, .rail-item.rail-hide { display: none; }
  .rail-item {
    width: auto;
    height: 100%;
    margin: 0;
    gap: 6px;
    border-left: none;
    border-top: 2px solid transparent;
    font-size: 10px;
    letter-spacing: .1em;
    color: var(--ink-dim);
  }
  .rail-item .icon { width: 24px; height: 24px; }
  .rail-item.is-active {
    background: transparent;
    border-left-color: transparent;
    border-top-color: var(--color-accent);
  }
  .drawer-panel { margin-left: 0; width: 100%; border-right: none; }
}

/* -- Watch screen — README §3 "Watch"; tablet values read verbatim from
   thomas.fm Touch.dc.html ~L62-108 (header, section rule, 3-col grid,
   footer live bar), phone hero + "Also ready" rows from the phone plate
   ~L453-478. Decision: no duration chip on tiles (the prototype has one;
   omitted per the task spec — duration isn't available from the API). --*/
/* .screen only sets min-height (Task 3: a floor, so tall screens can grow
   past the viewport and let .screens scroll) — Watch additionally needs a
   concrete height so its footer can pin to the bottom via margin-top:auto,
   scoped to this screen alone so other screens keep the min-height-only
   floor behavior. */
.screen[data-screen="watch"] { height: 100%; }
.watch-screen {
  display: flex;
  flex-direction: column;
  height: 100%;
  min-height: 0;
}
.watch-head {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: 20px;
  padding: 26px 30px 18px;
}
.watch-head .kicker { margin-bottom: 6px; }
.watch-head-actions { display: flex; gap: 8px; }
.watch-head-actions .btn { height: 44px; padding: 0 16px; }

.watch-rule {
  padding: 0 30px 12px;
  display: flex;
  align-items: center;
  gap: 10px;
}
.watch-rule .hairline { flex: 1; }

.watch-grid {
  padding: 8px 30px 0;
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 26px;
}
.watch-tile {
  display: flex;
  flex-direction: column;
  gap: 10px;
  text-align: left;
  background: transparent;
  border: none;
  padding: 0;
  cursor: pointer;
  font: inherit;
  color: inherit;
}
/* Outer .blueprint carries the registration marks and must not clip
   (default overflow:visible); the inner .duotone wrapper is the one that
   would clip real imagery (it isn't needed here — the still is a flat
   --still fill — but the split keeps the pattern consistent with any
   screen that does load an image into a duotone wrapper later). */
.watch-tile-still { width: 100%; aspect-ratio: 16 / 10; }
.watch-tile-media {
  width: 100%;
  height: 100%;
  background: var(--still);
  display: flex;
  align-items: flex-end;
  padding: 10px;
}
.watch-tile-play {
  width: 34px;
  height: 34px;
  flex: none;
  /* Play-chip border is a distinct, brighter value from --rule-faint
     (.25) — the prototype uses rgba(240,236,232,.45) for this control
     outline specifically (tablet grid ~L76, phone hero ~L495). Literal
     per the brief; --rule-faint itself stays .25 (still correct for the
     phone "Also ready" row's secondary play affordance below). */
  border: 1px solid rgba(240,236,232,.45);
  background: var(--color-bg);
  display: flex;
  align-items: center;
  justify-content: center;
}
.watch-tile-play .icon { width: 15px; height: 15px; color: var(--color-text); }
.watch-tile-info { display: flex; flex-direction: column; gap: 3px; }
.watch-tile-title { font: 600 18px/1.15 var(--font-heading); color: var(--color-text); }
.watch-tile-row-play { display: none; } /* phone "Also ready" rows + the tablet list rows below */

/* -- tablet (≥820px): the library is a line-delimited list, not a grid —
   quarter-size still at the left, title/meta beside it, play affordance at
   the right. Scoped to ≥820 so the phone hero + "Also ready" block below
   keeps its own layout untouched. -- */
@media (min-width: 820px) {
  .watch-grid {
    display: flex;
    flex-direction: column;
    gap: 0;
  }
  .watch-tile {
    flex-direction: row;
    align-items: center;
    gap: 16px;
    padding: 10px 0;
    border-bottom: 1px solid var(--rule-soft);
  }
  /* A quarter of the old grid still: the 3-col tile ran ~350px wide at the
     tablet width, so the list thumb is 88px, same 16:10 ratio. The corner
     registration marks and the overlay play chip don't survive the shrink
     legibly — the row-level play affordance replaces them. */
  .watch-tile-still { width: 88px; flex: none; }
  .watch-tile-still .corner { display: none; }
  .watch-tile-play { display: none; }
  .watch-tile-media { padding: 0; }
  .watch-tile-info { flex: 1; min-width: 0; gap: 2px; }
  .watch-tile-title {
    font-size: 16px;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }
  .watch-tile-row-play {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 34px;
    height: 34px;
    flex: none;
    border: 1px solid rgba(240,236,232,.45);
  }
  .watch-tile-row-play .icon { width: 15px; height: 15px; color: var(--color-accent); }
}

.watch-live {
  margin-top: auto;
  border-top: 1px solid var(--rule-strong);
  padding: 14px 30px;
  display: flex;
  align-items: center;
  gap: 14px;
}
.watch-live[hidden] { display: none; }
.watch-live-track {
  flex: 1;
  height: 6px;
  background: var(--track);
  border: 1px solid var(--rule-strong);
  position: relative;
}
.watch-live-fill { height: 100%; background: var(--color-accent); width: 0%; }
.watch-live-pct { font: 600 13px/1 var(--font-body); color: var(--color-text); }
.watch-live-open { height: 38px; padding: 0 14px; }

/* -- phone (<820px): one 16/9 hero tile + compact 64px "Also ready" rows,
   per the phone plate. The header actions and the "Just finished
   downloading" rule aren't present on that plate, so both are hidden. -- */
@media (max-width: 819.98px) {
  .watch-head { padding: 8px 20px 14px; align-items: flex-start; }
  .watch-head-actions { display: none; }
  .watch-rule { display: none; }

  .watch-grid {
    padding: 0 20px;
    display: flex;
    flex-direction: column;
    gap: 16px;
    position: relative;
  }
  /* "Also ready" label, reordered between the hero and the rows via flex
     order (there's no dedicated DOM node for it — renderWatch's tile list
     is flat, per its interface contract). Only shown once a second tile
     exists. */
  .watch-grid::before {
    order: 1;
    content: 'Also ready';
    display: none;
    font: 600 10px/1 var(--font-heading);
    letter-spacing: .14em;
    text-transform: uppercase;
    color: var(--ink-muted);
    padding-bottom: 10px;
    border-bottom: 1px solid var(--rule-strong);
  }
  .watch-grid:has(.watch-tile:nth-child(2))::before { display: block; }

  .watch-tile { order: 2; }
  .watch-tile:first-child {
    order: 0;
    flex-direction: column;
    gap: 9px;
  }
  .watch-tile:first-child .watch-tile-still { aspect-ratio: 16 / 9; }
  .watch-tile:first-child .watch-tile-play { width: 44px; height: 44px; }
  .watch-tile:first-child .watch-tile-play .icon { width: 17px; height: 17px; }
  .watch-tile:first-child .watch-tile-title { font-size: 19px; }

  .watch-tile:not(:first-child) {
    flex-direction: row;
    align-items: center;
    gap: 14px;
    height: 64px;
    border-bottom: 1px solid var(--rule-soft);
  }
  .watch-tile:not(:first-child) .watch-tile-still {
    width: 74px;
    height: 44px;
    aspect-ratio: auto;
    flex: none;
  }
  .watch-tile:not(:first-child) .watch-tile-media { padding: 0; }
  .watch-tile:not(:first-child) .watch-tile-play { display: none; }
  .watch-tile:not(:first-child) .watch-tile-info { flex: 1; min-width: 0; gap: 0; }
  .watch-tile:not(:first-child) .watch-tile-title {
    font-size: 16px;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }
  .watch-tile:not(:first-child) .watch-tile-row-play {
    display: flex;
    width: 44px;
    height: 44px;
    flex: none;
    border: 1px solid var(--rule-faint);
    align-items: center;
    justify-content: center;
  }
  .watch-tile:not(:first-child) .watch-tile-row-play .icon { width: 15px; height: 15px; color: var(--color-accent); }
}

/* ==========================================================================
   Player screen — README §4 "Player"; exact values ported from
   thomas.fm Touch.dc.html ~L111-147. Colors here are the player's own
   near-white ink (#f2f2f3 and its rgba() derivatives) rather than the rest
   of the shell's `--color-text` (#f0ece8) — the prototype sets that ink at
   the player wrapper explicitly and it's distinct enough (and used only
   here) that it isn't worth promoting to a shared token; ported as literals
   per the brief's "never invent a value" rule, same treatment as Task 5's
   play-chip border. `--color-accent-400` is reused for the scrub fill since
   it's the exact same hex (#94bce3) the prototype uses there.

   Two tokens split what the prototype drew as one placeholder div:
   `--video-field` (the letterbox area around the video) on `.player-picture`
   itself, `--video-black` (the picture) on the real `<video>` element —
   the README documents them as two distinct roles even though the
   non-interactive prototype (no real <video> tag) only ever painted the
   outer div. -- */
.screen[data-screen="player"] { height: 100%; } /* same fix as Watch: a concrete height so .player-picture's flex:1 can fill the remainder below the chrome */
/* README: "Zen maps to the site's existing body.zen treatment" — historical
   note: that treatment (site.css, deleted in Task 15) targeted old-UI
   class names (.topnav, .media-player, #media-container, ...) that never
   existed in the shell's markup. Scoped to `.shell.zen` (never bare
   `body.zen`) regardless — `.shell` only ever appears on the shell's own
   <body>, so this stays inert everywhere else. The section-level
   `.mode-zen` class (below) does the actual chrome/transport hiding; this
   is just the page-level dark backdrop the old treatment provided. */
body.shell.zen { background: var(--video-black); }
/* Zen owns the whole window: the rail (and its reveal hot strip) drop out
   of the flex frame entirely, so the player screen spans the full viewport;
   the picture paints pure black behind the aspect-preserving
   `object-fit: contain` video, letterboxing with black instead of the
   warmer player field. */
body.zen .rail, body.zen .rail-hotzone { display: none; }
.mode-zen .player-screen { background: var(--video-black); }
.mode-zen .player-picture { background: var(--video-black); }
.player-screen {
  height: 100%;
  min-height: 0;
  display: flex;
  flex-direction: column;
  background: var(--player-field);
  color: #f2f2f3;
}

.player-chrome {
  flex: none;
  display: flex;
  align-items: center;
  gap: 14px;
  padding: 16px 22px;
  border-bottom: 1px solid rgba(242,242,243,.16);
  background: var(--player-field);
  height: 77px;
  overflow: hidden;
}
/* zen collapses the chrome to 0 height with a transparent background (and
   hides the transport, below). Padding and the border are zeroed here too,
   not just height: the file-wide reset makes every element
   `box-sizing: border-box`, under which an explicit `height:0` can't
   shrink below the padding/border it's fighting — the box would still
   render ~33px tall (padding) plus a stray 1px line (the
   otherwise-unconditional border-bottom) even though `height` computes to
   0px. */
.mode-zen .player-chrome {
  height: 0;
  padding: 0;
  border-bottom-width: 0;
  background: transparent;
}
.player-back, .player-cast {
  height: 44px;
  padding: 0 14px;
  color: #f2f2f3;
  border-color: rgba(242,242,243,.35);
}
.player-cast .icon { width: 18px; height: 18px; }

/* -- Play on TV device sheet -- */
.cast-devices-panel { gap: 14px; }
.cast-devices-head-actions { display: flex; gap: 10px; flex: none; }
.cast-devices-list { display: flex; flex-direction: column; max-height: 50vh; overflow-y: auto; }
.cast-device-row {
  display: flex;
  align-items: center;
  gap: 12px;
  border-bottom: 1px solid var(--rule-soft);
}
.cast-device-main {
  flex: 1;
  min-width: 0;
  display: flex;
  align-items: center;
  gap: 14px;
  height: 58px;
  padding: 0 12px;
  background: transparent;
  border: none;
  cursor: pointer;
  text-align: left;
  font: 400 15px/1.2 var(--font-body);
  color: var(--color-text);
}
.cast-device-glyph { flex: none; display: flex; }
.cast-device-glyph .icon { width: 20px; height: 20px; color: var(--color-accent); }
.cast-device-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cast-device-host { flex: none; }
.cast-device-row .cast-device-stop { flex: none; height: 40px; padding: 0 14px; margin-right: 8px; }
.cast-devices-empty { padding: 10px 12px; }
.cast-subs { display: flex; flex-direction: column; border-bottom: 1px solid var(--rule-soft); }
.cast-subs-current {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  height: 48px;
  padding: 0 12px;
  background: transparent;
  border: none;
  cursor: pointer;
  text-align: left;
  font: 400 15px/1.2 var(--font-body);
  color: var(--color-text);
}
.cast-subs-value { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--color-accent); }
.cast-subs-list { display: flex; flex-direction: column; max-height: 30vh; overflow-y: auto; padding-bottom: 6px; }
.cast-subs-list[hidden] { display: none; }
.player-title-stack { min-width: 0; flex: 1; }
.player-title {
  font: 600 18px/1.15 var(--font-heading);
  color: #f2f2f3;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.player-path {
  font: 400 11px/1.3 var(--font-body);
  color: rgba(242,242,243,.6);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}

.player-picture {
  flex: 1;
  min-height: 0;
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--video-field);
  cursor: pointer;
}
#player-video {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: contain;
  background: var(--video-black);
}
/* Audio files keep the same shell (chrome + transport) but no picture —
   the <video> element still drives playback, just visually hidden. */
.player-picture.is-audio #player-video { display: none; }
/* Native browser fullscreen (the maximize icon in the transport) targets
   the picture, not the bare <video>, so the custom transport and cue
   overlay keep working inside it. */
.player-picture:fullscreen { background: var(--video-black); }

/* Cue rendering (Task 7, corrected after review): the <track> stays
   `mode='hidden'` — a pure cue-timing data source — and app.js renders the
   active cue's text into this div on `cuechange`. Native `::cue` styling
   was tried first and removed: it only accepts a narrow property set
   (color, font, text-shadow, background, outline, ...) with no
   positioning, so the UA places the cue box wherever it defaults to —
   README §5's "sits 88px above the bottom ... 60px side padding" is a
   position requirement `::cue` cannot satisfy. This div can, because every
   property below (including the position) is ours. */
.player-cue {
  position: absolute;
  left: 0; right: 0; bottom: 88px;
  text-align: center;
  padding: 0 60px;
  font: 400 var(--subtitle-size)/1.35 var(--font-body);
  color: #fff;
  text-shadow: 0 0 6px rgba(0,0,0,.85);
  pointer-events: none;
  white-space: pre-line; /* preserves the \n app.js joins multi-cue text with */
}
.player-error {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  text-align: center;
  padding: 0 40px;
  pointer-events: none;
}
.player-error[hidden] { display: none; } /* author `display:flex` above would otherwise beat the UA [hidden] rule */
/* Transient notices (cast failures) fade out before app.js hides them —
   see showPlayerNotice's 3s timer. */
.player-error { transition: opacity .45s ease; }
.player-error.is-fading { opacity: 0; }

.player-transport {
  position: absolute;
  left: 0; right: 0; bottom: 0;
  display: flex;
  flex-direction: column;
  gap: 12px;
  padding: 16px 22px;
  background: linear-gradient(to top, rgba(12,8,6,.94), rgba(12,8,6,0));
}
.mode-zen .player-transport { display: none; }
.player-transport .btn {
  color: #f2f2f3;
  border-color: rgba(242,242,243,.35);
}

.player-scrub-row { display: flex; align-items: center; gap: 12px; }
.player-time {
  width: 46px;
  font: 600 13px/1 var(--font-body);
  color: rgba(242,242,243,.8);
}
#player-time-dur { text-align: right; }
.player-scrub-hit {
  flex: 1;
  height: 20px;
  display: flex;
  align-items: center;
  cursor: pointer;
}
.player-scrub-bar {
  width: 100%;
  height: 4px;
  background: rgba(242,242,243,.25);
  position: relative;
}
.player-scrub-played { height: 100%; background: var(--color-accent-400); width: 0%; }
.player-scrub-head {
  position: absolute;
  top: -7px; left: 0%;
  width: 2px; height: 18px;
  background: #f2f2f3;
}

.player-controls { display: flex; align-items: center; gap: 10px; }
.player-controls-spacer { flex: 1; }
.player-toggle {
  width: 56px; height: 56px; padding: 0;
  background: rgba(242,242,243,.08);
}
.player-toggle .icon { width: 20px; height: 20px; }
.player-seek {
  width: 56px; height: 56px; padding: 0;
  background: transparent;
  font: 600 13px/1 var(--font-heading);
  letter-spacing: .06em;
}
.player-transport-btn { height: 56px; padding: 0 18px; }
/* Fullscreen lives inside the video frame (top-right, clear of the
   bottom transport), so it survives zen mode where the transport hides. */
.player-fs-btn {
  position: absolute;
  top: 14px;
  right: 14px;
  z-index: 3;
  width: 48px;
  height: 48px;
  padding: 0;
  background: color-mix(in srgb, var(--video-black) 55%, transparent);
}
.player-fs-btn .icon { width: 20px; height: 20px; }

/* ==========================================================================
   Subtitles sheet + .srt Files browser — README §5 "Subtitles sheet and
   Files browser"; exact geometry/copy from thomas.fm Touch.dc.html
   ~L336-391. `.sheet-scrim`/`.sheet-panel` are shared by both sheets here.
   (The torrent settings sheet that also used them is gone: those limits
   moved onto Settings -> Torrents beside the folder paths.)
   ========================================================================== */
.sheet-scrim {
  position: fixed;
  inset: 0;
  background: var(--scrim);
  display: flex;
  align-items: flex-end;
  z-index: 55;
}
.sheet-scrim[hidden] { display: none; }
/* Picker layers on top of (not instead of) the subs sheet — see app.js:
   closeSubsPicker() leaves #sheet-subs open underneath. DOM order alone
   would already paint it last/on-top; z-index makes that explicit. */
#sheet-subs-picker { z-index: 56; }
/* Literal prototype value (~L337): this one sheet's scrim alpha (.65) is
   not the shared --scrim token (.7) used by the other two — read verbatim,
   not reconciled. */
#sheet-subs { background: rgba(8,5,3,.65); }

.sheet-panel {
  width: 100%;
  background: var(--color-surface);
  border-top: 1px solid var(--rule-strong);
  padding: 22px 30px 26px;
  display: flex;
  flex-direction: column;
  gap: 18px;
}
.subs-picker-panel { gap: 14px; }

.subs-head, .subs-picker-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
}
.subs-head .btn-close, .subs-picker-head .btn-close { height: 44px; width: 44px; padding: 0; }

.subs-list, .subs-picker-list { display: flex; flex-direction: column; }
.subs-row {
  height: 60px;
  display: flex;
  align-items: center;
  gap: 14px;
  padding: 0 12px;
  width: 100%;
  background: transparent;
  border: none;
  border-bottom: 1px solid var(--rule-soft);
  cursor: pointer;
  text-align: left;
}
.subs-row.is-selected { background: var(--accent-tint); }
.subs-mark { width: 20px; flex: none; color: var(--color-accent); font: 600 15px/1 var(--font-body); }
.subs-name { font: 400 15px/1.2 var(--font-body); color: var(--color-text); }

.subs-browse-link {
  height: 50px;
  justify-content: flex-start;
  padding: 0 10px;
  font-size: 14px;
  letter-spacing: normal;
  text-transform: none;
}

.subs-cue-row { display: flex; align-items: center; gap: 16px; }
.subs-cue-seg { display: flex; border: 1px solid var(--rule-strong); }
.subs-cue-btn {
  height: 52px;
  padding: 0 26px;
  border: none;
  cursor: pointer;
  font: 600 14px/1 var(--font-heading);
  letter-spacing: .08em;
  text-transform: uppercase;
  background: transparent;
  color: var(--color-text);
}
.subs-cue-btn:not(:first-child) { border-left: 1px solid var(--rule-strong); }
.subs-cue-btn.is-selected { background: var(--color-accent); color: var(--color-bg); }
.subs-cue-spacer { flex: 1; }
.subs-apply { height: 52px; padding: 0 26px; font-size: 15px; }

.subs-picker-crumb {
  margin-top: 4px;
  font: 400 12px/1.4 var(--font-body);
  color: var(--ink-dim);
}
.subs-picker-crumb button {
  background: transparent;
  border: none;
  padding: 0;
  margin: 0;
  cursor: pointer;
  font: inherit;
  color: inherit;
}
.subs-picker-crumb button:hover,
.subs-picker-crumb button:focus-visible { color: var(--color-accent-400); }

.subs-picker-row {
  height: 58px;
  display: flex;
  align-items: center;
  gap: 14px;
  padding: 0 12px;
  width: 100%;
  background: transparent;
  border: none;
  border-bottom: 1px solid var(--rule-soft);
  cursor: pointer;
  text-align: left;
}
.subs-picker-row.is-dim { opacity: .45; cursor: default; }
.subs-picker-glyph { width: 22px; flex: none; color: var(--color-accent); }
.subs-picker-glyph .icon { width: 20px; height: 20px; }
.subs-picker-name {
  flex: 1;
  min-width: 0;
  font: 400 15px/1.2 var(--font-body);
  color: var(--color-text);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.subs-picker-size { font: 400 12px/1 var(--font-body); color: var(--ink-dim); font-variant-numeric: tabular-nums; }
/* Watch picker only: the folder rows' inline play chip (resolves the
   folder's largest video without navigating into it). */
.subs-picker-row-play {
  flex: none;
  width: 34px;
  height: 34px;
  display: flex;
  align-items: center;
  justify-content: center;
  border: 1px solid rgba(240,236,232,.45);
}
.subs-picker-row-play .icon { width: 14px; height: 14px; color: var(--color-accent); }

/* ==========================================================================
   Torrents screen + settings sheet + shared 3s torrent poll — README §6
   "Torrents"; exact geometry/copy read from
   thomas.fm Touch.dc.html ~L150-210 (cards) and ~L393-413 (settings sheet).
   New class names are prefixed `.tor-` deliberately: the pre-existing
   desktop torrent UI (site/public/torrent.js + its EJS view, both deleted
   in Task 15) owned `.torrent-*` names (`.torrent-card`, `.torrent-status`,
   `.torrent-health`, `.torrent-name`, `.torrent-folder`,
   `.torrent-progress`, `.torrent-eta`, `.torrent-speed-cell`, ...) —
   grepped first, zero overlap with a distinct prefix, so this stayed
   inert on the old `/torrent` page while it still existed.
   No phone-specific card redesign here: the task brief cites only the
   tablet card lines (150-210) and the settings-sheet lines (393-413), not
   the phone plate's separate compact layout further down the same file
   (combined stat line, 44px controls, full-width "Play now", dashed
   swipe-hint box) — out of scope for this task, same "deferred" treatment
   as the swipe/long-press gestures themselves (README's "Known gaps" #3;
   this screen's own footer explicitly drops the swipe-hint copy per the
   brief). The tablet layout below still renders (unclipped, scrollable)
   at phone widths via the base rail→bottom-bar breakpoint already in this
   file; it just isn't re-composed for the smaller plate.
   ========================================================================== */
.screen[data-screen="torrents"] { height: 100%; } /* same fix as Watch/Player: a concrete height so the card list can scroll and the footer sits at the bottom */
.tor-screen {
  display: flex;
  flex-direction: column;
  height: 100%;
  min-height: 0;
}
.tor-head {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: 20px;
  padding: 26px 30px 18px;
}
.tor-head .kicker { margin-bottom: 6px; }

.tor-start-row {
  padding: 0 30px 18px;
  display: flex;
  gap: 10px;
}
.tor-start-row .input { height: 52px; font-size: 15px; }
.tor-start-row .btn { height: 52px; padding: 0 22px; font-size: 15px; }

.tor-error { padding: 0 30px 14px; }
.tor-error[hidden] { display: none; }

.tor-list {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  padding: 0 30px;
  display: flex;
  flex-direction: column;
  gap: 14px;
}
.tor-card { padding: 16px 18px; display: flex; flex-direction: column; gap: 12px; cursor: pointer; } /* the whole card (minus its buttons) opens the detail sheet */

/* -- Finished (history) section: completed torrents from media_history.json
   — they survive server restarts, unlike the in-memory /active list. Its
   own capped scroll region below the live cards. -- */
.tor-history {
  flex: none;
  max-height: 38vh;
  overflow-y: auto;
  padding: 14px 30px 8px;
  border-top: 1px solid var(--rule-strong);
  display: flex;
  flex-direction: column;
  gap: 4px;
}
.tor-history[hidden] { display: none; }
.tor-hist-row {
  display: flex;
  align-items: center;
  gap: 14px;
  padding: 9px 0;
  border-bottom: 1px solid var(--rule-soft);
}
.tor-history-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.tor-history-head .btn { height: 44px; padding: 0 16px; }
.tor-hist-row.is-nav { cursor: pointer; }
.tor-hist-info { flex: 1; min-width: 0; }
.tor-hist-name {
  font: 600 15px/1.2 var(--font-heading);
  color: var(--color-text);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.tor-hist-glyph { width: 16px; height: 16px; margin-right: 8px; vertical-align: -2px; }
.tor-hist-actions { display: flex; gap: 10px; flex: none; }
.tor-hist-actions .btn { height: 40px; padding: 0 14px; }

/* -- torrent detail sheet -- */
.tor-detail-panel { max-height: 88vh; max-height: 88dvh; gap: 14px; }
.tor-detail-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; }
.tor-detail-head-info { min-width: 0; flex: 1; }
.tor-detail-head-info .sheet-title { overflow-wrap: anywhere; }
#tor-detail-sub { overflow-wrap: anywhere; }
.tor-detail-body {
  overflow-y: auto;
  min-height: 0;
  display: flex;
  flex-direction: column;
  gap: 12px;
}
.tor-detail-stats {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 12px 18px;
}
@media (max-width: 819.98px) { .tor-detail-stats { grid-template-columns: repeat(2, 1fr); } }
.tor-detail-list { display: flex; flex-direction: column; }
.tor-detail-row {
  display: flex;
  align-items: baseline;
  gap: 14px;
  padding: 7px 0;
  border-bottom: 1px solid var(--rule-soft);
  font: 400 13px/1.4 var(--font-body);
  color: var(--color-text);
}
/* Clickable variant: a finished text file's row is a real <button> that
   opens the file in notes — undo the UA button chrome, keep the row look. */
button.tor-detail-row {
  width: 100%;
  background: transparent;
  border-width: 0 0 1px; /* keep only .tor-detail-row's bottom rule */
  text-align: left;
  cursor: pointer;
}
button.tor-detail-row:hover .tor-detail-row-name,
button.tor-detail-row:focus-visible .tor-detail-row-name { color: var(--color-accent-400); }
.tor-detail-row-name { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.tor-detail-row-col { flex: none; color: var(--ink-muted); }
.tor-detail-empty { padding: 7px 0; }
.tor-detail-actions { display: flex; flex-wrap: wrap; gap: 10px; }
.tor-detail-actions .btn { height: 44px; padding: 0 16px; }
.tor-row1 { display: flex; align-items: flex-start; gap: 14px; }
.tor-titles { min-width: 0; flex: 1; }
.tor-name {
  font: 600 19px/1.15 var(--font-heading);
  color: var(--color-text);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.tor-status { height: 24px; flex: none; }

.tor-row2 { display: flex; align-items: center; gap: 14px; }
.tor-track { flex: 1; height: 8px; background: var(--track); border: 1px solid var(--rule-strong); position: relative; }
.tor-track-fill { height: 100%; background: var(--color-accent); width: 0%; }
.tor-pct { font: 600 15px/1 var(--font-body); color: var(--color-text); width: 46px; text-align: right; }

.tor-row3 { display: flex; align-items: center; gap: 22px; }
.tor-stats { display: flex; gap: 18px; flex: 1; min-width: 0; }
.tor-stat-label {
  font: 600 9px/1 var(--font-heading);
  letter-spacing: .14em;
  text-transform: uppercase;
  color: var(--ink-dim);
  margin-bottom: 3px;
}
.tor-stat-value { font: 400 13px/1 var(--font-body); font-variant-numeric: tabular-nums; color: var(--color-text); }
.tor-actions { display: flex; gap: 8px; flex: none; }
.tor-toggle, .tor-more { height: 48px; width: 48px; padding: 0; font-size: 15px; }

/* Per-card Stop/Remove row, revealed by "⋯" — anatomy the prototype leaves
   inert (its own ⋯ has no click behavior). Sized as 44px secondaries per
   the README's general "secondary buttons 44px" rule, since these sit
   below the card's primary 48px action row rather than inside it. */
.tor-more-row { display: flex; gap: 8px; }
.tor-more-row[hidden] { display: none; }
.tor-more-row .btn { height: 44px; padding: 0 16px; }

.tor-footer {
  border-top: 1px solid var(--rule-strong);
  padding: 12px 30px;
  display: flex;
  align-items: center;
  justify-content: flex-end; /* only the speeds line remains — no swipe hint, per the brief */
  font: 400 12px/1 var(--font-body);
  color: var(--ink-dim);
}
.tor-footer .num { font-variant-numeric: tabular-nums; }


/* ==========================================================================
   Files screen — README §7 "Files"; exact header/row/footer geometry read
   from thomas.fm Touch.dc.html ~L207-232. New class names prefixed `.fls-`
   deliberately: the old desktop Files UI (site/public/files.js + its EJS
   view, both deleted in Task 15) owned `.files-*` names (`.files-shell`,
   `.files-toolbar`, `.files-table`, `.files-breadcrumb`, `.files-content`,
   `.files-empty`) — grepped first, zero overlap with this distinct
   prefix, so this stayed inert on the old `/files` page while it still
   existed.
   The per-row `⋯` action bar (`.fls-row-menu`), inline rename/delete rows,
   Select-mode checkboxes and the Move picker sheet have no static mock to
   read pixel values from (the prototype's own row `⋯` is inert and there is
   no bulk/select chrome in the bundle at all — long-press is an explicitly
   deferred "known gap"). Sized on established patterns instead of invented:
   `.fls-row-menu` mirrors Torrents' `.tor-more-row` reveal (44px
   secondaries); the Move sheet reuses Subtitles' `.subs-picker-*` classes
   verbatim (Task 7's picker-sheet pattern), only adding the "New folder"
   row and footer button this screen needs on top.
   ========================================================================== */
.screen[data-screen="files"] { height: 100%; } /* same fix as Watch/Player/Torrents: a concrete height so the row list can scroll and the footer sits at the bottom */
.fls-screen {
  display: flex;
  flex-direction: column;
  height: 100%;
  min-height: 0;
}
.fls-head {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: 20px;
  padding: 26px 30px 18px;
}
.fls-head .kicker { margin-bottom: 6px; }
.fls-crumb { display: flex; flex-wrap: wrap; gap: 4px; }
.fls-crumb button,
.nts-picker-crumb button {
  background: transparent;
  border: none;
  padding: 0;
  margin: 0;
  cursor: pointer;
  font: inherit;
  color: inherit;
}
.fls-crumb button:hover,
.fls-crumb button:focus-visible,
.nts-picker-crumb button:hover,
.nts-picker-crumb button:focus-visible { color: var(--color-accent-400); }
.fls-crumb-sep { color: var(--ink-dim); }

.fls-head-actions { display: flex; align-items: center; gap: 8px; }
.fls-actions-normal, .fls-actions-select { display: flex; gap: 8px; }
.fls-actions-normal[hidden], .fls-actions-select[hidden] { display: none; }
.fls-head-actions .btn { height: 44px; padding: 0 16px; }

/* -- Bulk-delete confirm (F3 review fix) — the header Delete button no
   longer deletes on a single tap: it swaps `.fls-actions-select-main`
   (Move/Delete/Done) for this confirm bar, reusing the row-level delete
   confirm's own `.fls-confirm-text` class so the visual language matches
   exactly; button sizing already comes for free from `.fls-head-actions
   .btn` above. -- */
.fls-actions-select-main { display: flex; gap: 8px; }
.fls-actions-select-main[hidden] { display: none; }
.fls-bulk-confirm { display: flex; align-items: center; gap: 10px; }
.fls-bulk-confirm[hidden] { display: none; }

.fls-list {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  padding: 0 30px;
  display: flex;
  flex-direction: column;
}
.fls-empty { padding: 18px 0; }

.fls-row-wrap { display: flex; flex-direction: column; }
.fls-row {
  display: flex;
  align-items: center;
  gap: 16px;
  height: 66px;
  border-bottom: 1px solid var(--rule-soft);
}
.fls-row.is-dir, .fls-row.is-playable { cursor: pointer; }
.fls-row.is-selected { background: var(--accent-tint); }
.fls-check {
  width: 44px;
  height: 44px;
  flex: none;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
}
.fls-glyph { width: 34px; flex: none; display: flex; align-items: center; color: var(--color-accent); }
.fls-glyph .icon { width: 20px; height: 20px; }
.fls-name {
  flex: 1;
  min-width: 0;
  font: 400 15px/1.2 var(--font-body);
  color: var(--color-text);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.fls-size {
  width: 80px;
  flex: none;
  text-align: right;
  font: 400 13px/1 var(--font-body);
  color: var(--ink-dim);
}
.fls-modified {
  width: 130px;
  flex: none;
  text-align: right;
  font: 400 13px/1 var(--font-body);
  color: var(--ink-dim);
}
.fls-more { height: 44px; width: 44px; padding: 0; flex: none; }

.fls-row-menu {
  display: flex;
  gap: 8px;
  padding: 12px 0 16px;
  border-bottom: 1px solid var(--rule-soft);
}
.fls-row-menu .btn { height: 44px; padding: 0 16px; }

.fls-row-rename, .fls-row-confirm {
  display: flex;
  align-items: center;
  gap: 10px;
  min-height: 66px;
  border-bottom: 1px solid var(--rule-soft);
}
.fls-row-rename .input { flex: 1; height: 44px; }
.fls-row-rename .btn, .fls-row-confirm .btn { height: 44px; padding: 0 16px; flex: none; }
.fls-confirm-text { flex: 1; min-width: 0; font: 400 15px/1.2 var(--font-body); color: var(--color-text); }

.fls-footer {
  border-top: 1px solid var(--rule-strong);
  padding: 12px 30px;
  font: 400 12px/1 var(--font-body);
  color: var(--ink-dim);
}

/* -- Move folder-picker sheet (#sheet-files-move) — reuses .subs-picker-*
   (Task 7) for the shared row/crumb/glyph anatomy; only the "New folder"
   row internals and the footer button below are new. -- */
.fls-move-newfolder-row { gap: 10px; cursor: default; }
.fls-move-newfolder-row .input { flex: 1; height: 40px; }
.fls-move-newfolder-row .btn { height: 40px; padding: 0 14px; flex: none; }
.fls-move-footer { display: flex; justify-content: flex-end; }
#fls-move-here-btn { height: 52px; padding: 0 26px; font-size: 15px; }

/* ==========================================================================
   Notes screen — README §8 "Notes"; exact geometry/copy read from
   thomas.fm Touch.dc.html ~L262-300 (left pane, rows, right pane, footer).
   New class names prefixed `.nts-` — grepped `site/public/notes.js` and the
   old `site/notes/index.ejs` first (both deleted in Task 15), zero
   overlap, so this stayed inert on the old `/notes` page while it still
   existed.

   There is no notes-list endpoint (README's own "Known gaps" #1): the left
   pane is populated from GET /files/api/list?p=notes filtered client-side
   to `.md`, per the task brief's documented decision.

   Undocumented anatomy (no static mock for either): the inline Delete
   confirm (`.nts-confirm`, swapped in for `.nts-actions` in the same header
   slot) mirrors Files' row-level confirm pattern (Task 9) sized the same
   44px; the "no notes yet" / "no note open" empty-disabled states are this
   task's own interaction decision (README pictures only the populated
   state) — the editor is simply `disabled` with quiet placeholder copy,
   Delete/Save disabled via the shared `.btn:disabled` rule.
   ========================================================================== */
.screen[data-screen="notes"] { height: 100%; } /* same fix as every other screen: a concrete height so panes can fill it and lists/editor can scroll internally */
.nts-screen {
  display: flex;
  height: 100%;
  min-height: 0;
}

.nts-left {
  width: 288px;
  flex: none;
  border-right: 1px solid var(--rule-strong);
  display: flex;
  flex-direction: column;
  min-height: 0;
}
.nts-left-head {
  padding: 26px 16px 14px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
}
.nts-left-actions { display: flex; align-items: center; gap: 8px; }
.nts-new-btn,
.nts-open-btn { height: 40px; padding: 0 14px; }
.nts-new-btn[hidden] { display: none; } /* UA [hidden] loses to the scoped .btn display rule without this */

/* Open… picker: crumb row above the entry rows, same look as .fls-crumb */
.nts-picker-crumb {
  display: flex;
  flex-wrap: wrap;
  gap: 4px;
  padding: 10px 16px;
  border-bottom: 1px solid var(--rule-soft);
  font: 400 12px/1.4 var(--font-body);
}

.nts-list {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
}
.nts-empty { padding: 18px 16px; font: 400 12px/1.4 var(--font-body); color: var(--ink-dim); }

.nts-row {
  width: 100%;
  text-align: left;
  height: 72px;
  padding: 0 16px;
  border: none;
  border-bottom: 1px solid var(--rule-soft);
  cursor: pointer;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 4px;
  background: transparent;
  border-left: 3px solid transparent;
  font: inherit;
  color: inherit;
}
.nts-row.is-open { background: var(--accent-tint); border-left-color: var(--color-accent); }
.nts-row-name {
  font: 600 17px/1.1 var(--font-heading);
  color: var(--color-text);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.nts-row-meta {
  font: 400 11px/1.3 var(--font-body);
  color: var(--ink-dim);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.nts-right {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
  min-height: 0;
}
.nts-right-head {
  padding: 26px 30px 16px;
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: 18px;
}
.nts-right-head-info { min-width: 0; }
.nts-right-head .kicker { margin-bottom: 6px; }
/* Same values as .screen-title (34px condensed, -.015em) plus the ellipsis
   this dynamic note-name title needs — .screen-title itself is reused as-is
   by every other screen's static title text, none of which need to clip. */
.nts-title {
  margin: 0;
  font: 600 34px/1 var(--font-heading);
  letter-spacing: -.015em;
  color: var(--color-text);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.nts-actions, .nts-confirm {
  display: flex;
  align-items: center;
  gap: 8px;
  flex: none;
}
.nts-actions[hidden], .nts-confirm[hidden] { display: none; }
#nts-delete-btn, #nts-confirm-cancel, #nts-confirm-delete-btn { height: 44px; padding: 0 16px; }
#nts-save-btn { height: 44px; padding: 0 20px; }
.nts-confirm-text { font: 400 15px/1.2 var(--font-body); color: var(--color-text); }

.nts-error { padding: 0 30px 14px; }
.nts-error[hidden] { display: none; }

.nts-editor-wrap { flex: 1; min-height: 0; padding: 0 30px; }
/* Review fix (Finding 1): the shared primitives this textarea also carries
   the `.input` class for (`:is(.shell,.auth,.app-public) .input` at (0,2,0)
   and `... textarea.input { resize:vertical }` at (0,2,1)) out-specificity a
   bare `.nts-editor` (0,1,0) regardless of source order, so `resize:vertical`
   and the 6px/10px `.input` padding/font-size were silently winning over the
   values below. `.shell .nts-editor.input` computes to (0,3,0) — beats both
   without `!important`. */
.shell .nts-editor.input {
  width: 100%;
  height: 100%;
  resize: none;
  padding: 18px 20px;
  font: 400 15px/1.75 var(--font-body);
}
.shell .nts-editor.input:disabled { opacity: .5; cursor: not-allowed; }
.shell .nts-editor.input[hidden] { display: none; } /* markdown mode swaps in the preview; guard against any display rule beating UA [hidden] */

/* Markdown preview — same box as the editor, read-only, scrollable. */
.nts-preview {
  height: 100%;
  overflow-y: auto;
  padding: 18px 20px;
  font: 400 15px/1.75 var(--font-body);
  color: var(--color-text);
}
.nts-preview[hidden] { display: none; }
.nts-preview h1, .nts-preview h2, .nts-preview h3,
.nts-preview h4, .nts-preview h5, .nts-preview h6 {
  font-family: var(--font-heading);
  line-height: 1.25;
  margin: 1.1em 0 .45em;
}
.nts-preview h1 { font-size: 24px; }
.nts-preview h2 { font-size: 20px; }
.nts-preview h3 { font-size: 17px; }
.nts-preview h4, .nts-preview h5, .nts-preview h6 { font-size: 15px; }
.nts-preview > :first-child { margin-top: 0; }
.nts-preview p, .nts-preview ul, .nts-preview ol, .nts-preview blockquote { margin: 0 0 .9em; }
.nts-preview ul, .nts-preview ol { padding-left: 1.4em; }
.nts-preview blockquote {
  border-left: 3px solid var(--rule-strong);
  padding-left: 14px;
  color: var(--ink-dim);
}
.nts-preview pre {
  background: var(--accent-tint);
  border: 1px solid var(--rule-soft);
  padding: 12px 14px;
  overflow-x: auto;
  margin: 0 0 .9em;
}
.nts-preview code { font-family: var(--font-mono, monospace); font-size: 13px; }
.nts-preview :not(pre) > code { background: var(--accent-tint); padding: 1px 5px; }
.nts-preview hr { border: 0; border-top: 1px solid var(--rule-strong); margin: 1.2em 0; }
.nts-preview a { color: var(--color-accent); }
#nts-md-btn.is-selected { border-color: var(--color-accent); color: var(--color-accent); }

.nts-footer {
  border-top: 1px solid var(--rule-strong);
  padding: 12px 30px;
  display: flex;
  align-items: center;
  gap: 22px;
  font: 400 12px/1 var(--font-body);
  color: var(--ink-dim);
}
.nts-footer-spacer { flex: 1; }

/* ==========================================================================
   Feeds (RSS) screen — README §9 "Feeds (in More)"; exact geometry/copy
   read from thomas.fm Touch.dc.html ~L237-256. New class names prefixed
   `.rss2-` (not `.rss-`, already taken by the old `/rss` page's own CSS a
   few hundred lines down — grepped first, zero overlap, so this stays
   inert on the old page).

   The prototype's "All feeds" selected row is `background:#5980a6;
   color:#f2f2f3` — `#5980a6` is `--color-accent` verbatim, but `#f2f2f3`
   has no shell token (it's the same "near-white ink used only on a solid
   accent fill" the Player chrome already ports as a literal, per that
   block's own comment above) — kept as the literal here too rather than
   inventing/promoting a token for a single reused value.

   Undocumented anatomy (no static mock for either): the per-feed row's
   small ✕ delete affordance and the add/delete error slot are this task's
   own interaction decisions — the non-interactive prototype has no wired
   behavior for either. The ✕ is a real sibling `<button>` (not nested
   inside the row's own select button — two interactive elements can't
   nest), 44x44 hit target, hidden via `opacity:0` until the row is
   hovered/focused (`:focus-within` so Tab can still reach and reveal it).
   ========================================================================== */
.screen[data-screen="rss"] { height: 100%; } /* same fix as every other two-pane screen: a concrete height so panes can fill it and lists can scroll internally */
.rss2-screen {
  display: flex;
  height: 100%;
  min-height: 0;
}

.rss2-left {
  width: 268px;
  flex: none;
  border-right: 1px solid var(--rule-strong);
  padding: 26px 18px;
  display: flex;
  flex-direction: column;
  gap: 14px;
  min-height: 0;
}
.rss2-url-input { height: 46px; }
.rss2-add-error[hidden] { display: none; }

.rss2-feed-list {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
}
.rss2-empty { padding: 10px 12px; font: 400 12px/1.4 var(--font-body); color: var(--ink-dim); }
.rss2-articles .rss2-empty { padding: 10px 0; } /* .rss2-articles already carries its own 30px horizontal inset; the feed-list's 12px matches each row's own inset instead */

.rss2-feed-row {
  display: flex;
  align-items: stretch;
  height: 52px;
  flex: none;
  border-bottom: 1px solid var(--rule-soft);
}
.rss2-feed-row.is-selected { background: var(--color-accent); border-bottom-color: transparent; }

.rss2-feed-row-select {
  flex: 1;
  min-width: 0;
  height: 100%;
  padding: 0 12px;
  border: none;
  background: transparent;
  cursor: pointer;
  text-align: left;
  font: 400 14px/1.2 var(--font-body);
  color: var(--color-text);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.rss2-feed-row.is-selected .rss2-feed-row-select {
  font: 600 15px/1 var(--font-heading);
  letter-spacing: .03em;
  color: #f2f2f3;
}

.rss2-feed-row-delete {
  flex: none;
  width: 44px;
  height: 44px;
  align-self: center;
  border: none;
  background: transparent;
  color: var(--ink-dim);
  font: 600 13px/1 var(--font-body);
  cursor: pointer;
  opacity: 0;
  display: flex;
  align-items: center;
  justify-content: center;
}
.rss2-feed-row:hover .rss2-feed-row-delete,
.rss2-feed-row:focus-within .rss2-feed-row-delete { opacity: 1; }
.rss2-feed-row.is-selected .rss2-feed-row-delete { color: #f2f2f3; }

.rss2-right {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
  min-height: 0;
}
.rss2-right-head { padding: 26px 30px 14px; }
.rss2-title {
  margin: 0;
  font: 600 34px/1 var(--font-heading);
  letter-spacing: -.015em;
  color: var(--color-text);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.rss2-articles {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  padding: 0 30px;
}
.rss2-article {
  padding: 18px 0;
  border-bottom: 1px solid var(--rule-soft);
  display: flex;
  flex-direction: column;
  gap: 5px;
}
.rss2-article-head {
  display: flex;
  align-items: baseline;
  gap: 10px;
}
.rss2-article-source {
  font: 600 10px/1 var(--font-heading);
  letter-spacing: .14em;
  text-transform: uppercase;
  color: var(--color-accent);
}
.rss2-article-age { font: 400 11px/1 var(--font-body); color: var(--ink-dim); }
.rss2-article-headline, .rss2-article-headline:visited {
  font: 600 21px/1.15 var(--font-heading);
  color: var(--color-text);
  text-decoration: none;
}
.rss2-article-snippet { font: 400 13px/1.5 var(--font-body); color: var(--ink-muted); }

/* ==========================================================================
   Settings screen (profile fold, Task 12). No static plate exists for this
   screen — the prototype stubs `[data-screen="settings"]` entirely (per the
   brief). Geometry is built from the design system's own established
   patterns rather than invented from scratch: the header copies Watch/
   Torrents/Files' `.*-head` block verbatim (26px 30px 18px padding, 34px
   `.screen-title`, `.kicker` above it); the 2-col field grid + 56px/16px
   inputs mirror the sign-in form (`.auth .input`, thomas.fm's only other
   full-size text-input precedent). The right-aligned primary Save per
   section is a pattern this screen reuses directly. Class prefix `.set-`
   grepped against the whole stylesheet first — zero collisions.

   Since then the screen has grown tabs (one per feature) and absorbed the
   torrent bandwidth limits from the sheet that used to open off the
   Torrents screen, so every torrent setting sits on one tab.
   ========================================================================== */
.set-screen { padding-bottom: 40px; }
.set-head {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: 20px;
  padding: 26px 30px 18px;
}
.set-head .kicker { margin-bottom: 6px; }

.set-body {
  padding: 0 30px;
  display: flex;
  flex-direction: column;
}
.set-body .hairline { margin: 26px 0; }

.set-section-head { margin-bottom: 16px; }
.set-switch { font: 400 14px/1.4 var(--font-body); color: var(--color-text); margin-bottom: 10px; }
.set-grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 16px 22px;
}
.set-grid .input { height: 56px; font-size: 16px; }

.set-section-foot {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 16px;
  margin-top: 18px;
}
.set-status { flex: 1; min-height: 1.4em; }
.set-status.is-success { color: var(--color-accent); }
.set-save { height: 48px; padding: 0 28px; font-size: 15px; }

/* -- phone (<820px): the 2-col grid collapses to one column, same break as
   every other screen -- */
@media (max-width: 819.98px) {
  .set-grid { grid-template-columns: 1fr; }
}

/* ==========================================================================
   Terminal screen (Task 13). No static design plate (the prototype stubs
   this screen — README: "Terminal stays a desktop surface by the owner's
   decision"). Header reuses the established `.*-head` padding (26px 30px
   18px, kicker above a 34px `.screen-title`) but has no right-aligned
   action button like Watch/Torrents/Files/Settings — the `Desktop` tag
   sits directly next to the title instead, per the brief's "hint right of
   the title" wording, not at the opposite end of the row.

   The iframe keeps the exact class name (`terminal-frame`) the legacy
   terminal/index.ejs wrapper div used, per the brief (that view and
   site.css's own bare `.terminal-frame` rule were both deleted in
   Task 15 — until then this was a real, if inert, collision, avoided the
   same way this file avoids every other such collision: compound-scoped
   (`.term-screen .terminal-frame`) rather than a bare selector). Every
   other new class here is `.term-`-prefixed and grepped clean first.
   ========================================================================== */
.screen[data-screen="terminal"] { height: 100%; } /* same fix as every other screen: a concrete height so the iframe's flex:1 can fill it */
.term-screen {
  display: flex;
  flex-direction: column;
  height: 100%;
  min-height: 0;
  padding: 26px 30px 30px;
  gap: 18px;
}
.term-head .kicker { margin-bottom: 6px; }
.term-title-row { display: flex; align-items: center; gap: 14px; }
.term-screen .terminal-frame {
  display: block;
  width: 100%;
  flex: 1;
  min-height: 0;
  border: 1px solid var(--rule-strong);
}

/* ==========================================================================
   About screen (Task 13). No static design plate either (prototype stub,
   same as Settings/Terminal). Header reuses the same `.*-head` pattern as
   every other screen, except the 34px `.screen-title` itself IS the
   dynamic content (the profile's name) rather than a fixed word like
   "Watch" — intentional, per the brief. Body copy runs at the file's own
   base running-text size (15px/1.55 `--font-body`, same as `.app-public`)
   rather than the small dim `.meta` role, since this is primary page
   content, not secondary metadata. The LinkedIn line is a plain `<a>` —
   no extra class — so it picks up the new global `a{}` default added
   above (accent color, 3px underline-offset) rather than needing its own
   rule. Class prefix `.abt-` grepped against the whole stylesheet first —
   zero collisions.
   ========================================================================== */
.abt-screen {
  padding: 26px 30px 30px;
  display: flex;
  flex-direction: column;
  gap: 18px;
  max-width: 640px;
}
.abt-head .kicker { margin-bottom: 6px; }
/* Review fix (Critical): a dedicated status element for the load-failure
   banner, sibling to `.abt-body` rather than inside it — the original
   version rendered the error into `.abt-body` itself via
   App.quietError's replaceChildren, wiping the fixed #abt-location/
   #abt-tagline/#abt-linkedin nodes a later successful retry needs. Same
   hidden-by-default banner convention as `.tor-error`/`.nts-error`/
   `.rss2-add-error`. */
.abt-status[hidden] { display: none; }
.abt-body {
  display: flex;
  flex-direction: column;
  gap: 12px;
}
.abt-line {
  margin: 0;
  font: 400 15px/1.55 var(--font-body);
  color: var(--ink-muted);
}

/* ==========================================================================
   Public pages: sign in, splash, 404, error, about. Rendered through the
   shared views/layout.ejs. Historical note: this scoping (`.app-public` /
   `.auth`, never a bare element selector — each root carries its own
   explicit background/color/font/focus/selection treatment instead of
   depending on a shared body-level reset) was originally required because
   layout.ejs also linked the legacy site.css for logged-in pages. Task 15
   deleted site.css; the scoping is kept anyway (harmless, and it's what
   every other `:is(.shell, .auth, .app-public)` rule in this file already
   assumes).
   ========================================================================== */
.app-public {
  width: 100%;
  min-height: 100vh;
  display: flex;
  background: var(--color-bg);
  color: var(--color-text);
  font: 400 15px/1.55 var(--font-body);
}
.app-public :focus,
.auth :focus { outline: none; }
.app-public :focus-visible,
.auth :focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
.app-public ::selection,
.auth ::selection { background: rgba(89,128,166,.3); }

/* -- splash / 404 / error: a single centered message -- */
.public-page {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 18px;
  padding: 44px 24px;
  text-align: center;
}
/* Not part of the committed design system (the touch-app handoff only
   specs the post-login screens) — sized to echo the sign-in wordmark's
   weight/tracking below while staying fluid so it never overflows a phone
   viewport the way a fixed 96px would. */
.public-mark {
  font: 600 clamp(40px, 13vw, 96px)/.9 var(--font-heading);
  letter-spacing: -.03em;
  color: var(--color-text);
}

/* -- about (public splash + the always-public `/about/legacy` scrape
   source) — Task 15 review fix: about/index.ejs previously rendered with
   no contentClass override, defaulting to layout.ejs's bare
   `main.container`, which matches nothing in this file (everything above
   is scoped to `:is(.shell, .auth, .app-public)`) now that site.css is
   gone — so both `/about` (anonymous) and `/about/legacy` rendered as
   completely unstyled HTML. about/index.js now passes contentClass:
   'app-public' (see its own comment), which alone supplies the base
   background/color/font via inheritance from `.app-public` above; these
   rules add layout and the same type roles used elsewhere in the shell.

   Deliberately does NOT touch about/index.ejs's markup or add any class
   to it: the About screen's client-side scrape (aboutFieldsFrom in
   app.js) reads `.about .lead`, the *direct-child* `.about > p` at index
   1, and the first `.about a[href]` — purely structural/positional
   selectors with no styling hooks of their own, and touching that
   markup risks silently breaking them. Every selector below targets only
   the existing tag/class shape (`section.about` > h1/p, `.lead`) — no
   new elements, classes or attributes — so the scrape's assumptions are
   untouched (verified: ui-tests/shell_nav.spec.ts's two About tests
   still pass). The link's color already comes from the generic
   `:where(.shell, .auth, .app-public) a` rule above; no extra rule
   needed for it. -- */
.app-public .about {
  flex: 1;
  width: 100%;
  max-width: 640px;
  margin: 0 auto;
  padding: 44px 24px;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 12px;
}
.app-public .about h1 {
  margin: 0 0 6px;
  font: 600 34px/1 var(--font-heading);
  letter-spacing: -.015em;
  color: var(--color-text);
}
.app-public .about p {
  margin: 0;
  font: 400 15px/1.55 var(--font-body);
  color: var(--ink-muted);
}
.app-public .about .lead {
  font-size: 17px;
  color: var(--color-text);
}

/* -- legacy Settings page (GET /settings, profile/index.ejs) — F4 review
   fix: like About above, renderProfilePage never set contentClass,
   defaulting to layout.ejs's bare `main.container`, which (now that
   site.css is gone) matches nothing in this file — the page rendered as
   raw unstyled HTML. site/profile/index.js now passes contentClass:
   'app-public' (mirrors home/index.js and about/index.js above), and the
   rules below add just enough layout/type for legibility.

   Deliberately does NOT touch profile/index.ejs's markup: the shell's
   Settings screen scrapes this exact HTML by `[name=...]` attribute
   (loadSettingsValues in app.js), and per the task's own explicit
   constraint, no `<input name=...>` element may change. Every selector
   below targets only the page's existing tag/class shape (`.profile-grid`,
   `.card`, `.alert`, `.lead`, plain `form`/`label`/`input`/`button`/`small`
   inside `.app-public`) — no new elements, classes or attributes anywhere
   in the page. Checked for collisions with every other `.app-public` page
   before adding the bare `h1` rule: about/index.ejs's `<h1>` is matched by
   the more specific `.app-public .about h1` above, and 404/error's `<h1
   class="screen-title">` is matched by the more specific `:is(.shell,
   .auth, .app-public) .screen-title` (0,2,0) — both outrank a bare
   `.app-public h1` (0,1,1) regardless of source order, so this stays
   scoped to this page's own untitled `<h1>Settings</h1>` in practice.

   One structural difference from every other `.app-public` page: this is
   the only one whose rendered content is more than one top-level element
   (an alert `<section>`, a heading `<section>`, then `.profile-grid`) —
   every other page (splash/about/404/error) renders exactly one child, so
   `.app-public`'s own `display:flex` (row, its default axis) never
   mattered there: a lone flex item with `flex:1` fills the row either way.
   Three unstyled siblings in that same row axis would sit side by side
   instead of stacking. Scoped with `:has()` to this page alone (rather
   than flipping `.app-public`'s axis for every page) so the change can
   never affect the already-covered splash/about/404/error layouts. -- */
.app-public:has(.profile-grid) { flex-direction: column; }
.app-public h1 {
  margin: 24px auto 0;
  width: 100%;
  max-width: 720px;
  padding: 0 24px;
  font: 600 34px/1 var(--font-heading);
  letter-spacing: -.015em;
  color: var(--color-text);
}
.app-public .alert {
  max-width: 720px;
  margin: 16px auto 0;
  padding: 12px 16px;
  border: 1px solid var(--color-divider);
  color: var(--color-text);
  font: 400 14px/1.4 var(--font-body);
}
.app-public .alert.success { border-color: var(--color-accent-400); color: var(--color-accent-400); }
.app-public .profile-grid {
  flex: 1;
  width: 100%;
  max-width: 720px;
  margin: 0 auto;
  padding: 24px;
  display: flex;
  flex-direction: column;
  gap: 24px;
}
.app-public .card {
  border: 1px solid var(--color-divider);
  padding: 20px;
}
.app-public .card h2 {
  margin: 0;
  font: 600 20px/1.2 var(--font-heading);
  color: var(--color-text);
}
.app-public .card .lead {
  margin: 6px 0 0;
  font: 400 14px/1.5 var(--font-body);
  color: var(--ink-muted);
}
.app-public .card label {
  display: block;
  margin-top: 14px;
  font: 400 13px/1.4 var(--font-body);
  color: var(--ink-muted);
}
.app-public .card input {
  display: block;
  width: 100%;
  height: 44px;
  margin-top: 4px;
  padding: 0 12px;
  font: 400 14px/1 var(--font-body);
  color: var(--color-text);
  background: var(--color-surface);
  border: 1px solid var(--color-divider);
  border-radius: 0;
  box-sizing: border-box;
}
.app-public .card small.help-text {
  display: block;
  margin-top: 4px;
  font: 400 12px/1.4 var(--font-body);
  color: var(--ink-dim);
}
.app-public .card button[type="submit"] {
  display: block;
  margin-top: 18px;
  height: 44px;
  padding: 0 20px;
  background: var(--color-accent);
  color: #15100d;
  border: none;
  font: 600 14px/1 var(--font-body);
  cursor: pointer;
}
.app-public .card button[type="submit"]:hover { background: var(--color-accent-400); }

/* -- sign in (minimal): a single centred `thomas.fm` on an empty screen,
   fading in over 2.5s (the old site's splash aesthetic). Clicking it
   reveals the password-only form beneath (login.js adds `.is-open`; the
   server pre-renders it after a failed attempt so the error is never
   hidden behind a second click). -- */
/* The login render goes through views/layout.ejs with no body class, so
   the UA's default body margin (and the page background behind it) shows
   as a stray strip around the full-bleed screen. Scope the fix to bodies
   actually containing the sign-in rather than touching the shared layout. */
body:has(> main > .auth) {
  margin: 0;
  background: var(--color-bg);
}
.auth {
  /* Break the sign-in out of the shared layout's default `main.container`
     into a true full-bleed screen, without touching views/layout.ejs's
     main-class logic that other renders through the same layout still
     depend on. */
  width: 100vw;
  position: relative;
  left: 50%;
  margin-left: -50vw;
  min-height: 100vh;
  min-height: 100dvh;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 38px;
  padding: 24px;
  background: var(--color-bg);
  color: var(--color-text);
  font: 400 15px/1.55 var(--font-body);
}
.auth-brand {
  border: none;
  background: transparent;
  cursor: pointer;
  padding: 8px 14px;
  font: 600 clamp(44px, 8vw, 84px)/1 var(--font-heading);
  letter-spacing: -.03em;
  color: var(--color-text);
  animation: auth-brand-fade 2.5s ease both;
}
@keyframes auth-brand-fade {
  from { opacity: 0; }
  to { opacity: 1; }
}
.auth-form {
  /* Absolutely positioned below the centred brand so the closed form takes
     no layout space — `thomas.fm` sits dead-centre until it's clicked. */
  position: absolute;
  left: 50%;
  top: calc(50% + 78px);
  display: flex;
  flex-direction: column;
  gap: 14px;
  width: min(340px, 86vw);
  opacity: 0;
  visibility: hidden;
  transform: translate(-50%, 8px);
  transition: opacity .35s ease, transform .35s ease, visibility 0s linear .35s;
}
.auth.is-open .auth-form {
  opacity: 1;
  visibility: visible;
  transform: translate(-50%, 0);
  transition: opacity .35s ease, transform .35s ease;
}
.auth-error {
  margin: 0;
  font: 400 13px/1.5 var(--font-body);
  color: var(--ink-muted);
  text-align: center;
}
.auth .input {
  height: 56px;
  font-size: 16px;
  text-align: center;
}
.auth .btn-primary {
  height: 56px;
  font-size: 16px;
}
.auth-toggle {
  font: 400 12px/1.4 var(--font-body);
  color: var(--ink-dim);
  margin-top: 16px;
  justify-content: center;
}
.auth-toggle .switch-track { border-color: rgba(240,236,232,.3); }

/* ==========================================================================
   Session-expired banner (F1 review fix) — see the block comment above
   App.fetchJSON/App.showSessionExpiredBanner in app.js. Fixed to the top of
   the viewport (a shared z-index scale doesn't exist in this file — 200 is
   deliberately well above the highest sheet z-index in use, 56, so it's
   never hidden behind an open sheet or the More drawer). Content is built
   in JS from the same `.quiet-error`/`.meta`/`.btn-ghost` classes every
   other inline error already uses, so only the fixed positioning/backdrop
   is new here.
   ========================================================================== */
.session-expired-banner {
  position: fixed;
  top: 0; left: 0; right: 0;
  z-index: 200;
  display: flex;
  justify-content: center;
  padding: 10px 16px;
  background: var(--color-surface);
  border-bottom: 1px solid var(--rule-strong);
}
.session-expired-banner[hidden] { display: none; }
.session-expired-banner .quiet-error { display: flex; align-items: center; gap: 6px; }

/* ==========================================================================
   Desktop screen (Task 7) — RDP canvas onto the configured host. The four
   states are driven by a class on .desk-screen, so the client never
   hand-manages layout visibility element by element. The connect form's
   password field composes the shared `.field` / `.input` classes (see
   ~line 174) rather than duplicating their declarations under new selector
   names — same pattern as `.tor-start-input`/`.nts-editor`/`.rss2-url-input`
   elsewhere in this file. `.desk-input` carries no rules of its own today
   (a bare hook, like `.tor-start-input`); add genuine desktop-specific
   overrides here if Task 8 needs any, rather than re-duplicating `.input`.

   `.desk-toolbar`/`.desk-status`/`.desk-error` each carry a `[hidden]`
   attribute in the markup, and two of them (`.desk-toolbar`, `.desk-error`)
   also set `display: flex` — an author rule of equal specificity to the
   UA's `[hidden] { display: none }`, so the later author declaration wins
   and the element stays visible despite `hidden`, confirmed in manual
   browser verification. Fixed with the same `.foo[hidden] { display: none }`
   override already used by `.tor-error`/`.nts-error`/`.rss2-add-error`/
   `.abt-status` elsewhere in this file (`.desk-status` gets one too, for
   consistency, though it has no competing `display` declaration).
   ========================================================================== */
.screen[data-screen="desktop"] { height: 100%; } /* same fix as every other screen: a concrete height so .desk-display's flex:1 can fill it once Task 8 drives is-connected */
.desk-screen { display: flex; flex-direction: column; height: 100%; min-height: 0; }
.desk-head { padding: 16px 20px 8px; }
.desk-title-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.desk-toolbar { display: flex; gap: 8px; }
.desk-toolbar[hidden] { display: none; }
/* The sign-in block is centred the way the site's own /login screen is
   (`.auth`, ~line 890): a flex column that takes its centring from `margin:
   auto` inside .desk-screen's column, so it sits in the middle of the screen
   area rather than hard against the top-left. It centres within the screen
   area, not the viewport, so the rail and drawer keep their space. */
.desk-signin { margin: auto; display: flex; flex-direction: column; align-items: center; gap: 4px; width: 100%; }
.desk-connect { display: flex; flex-direction: column; gap: 8px; width: 100%; max-width: 320px; padding: 16px 20px; }
.desk-connect .field { width: 100%; }
.desk-target { margin: 0 0 8px; opacity: .8; }
.desk-status { padding: 0 20px; opacity: .8; }
.desk-status[hidden] { display: none; }
.desk-error { padding: 16px 20px; display: flex; flex-direction: column; gap: 8px; align-items: flex-start; }
.desk-error[hidden] { display: none; }
.desk-display { flex: 1; min-height: 0; display: none; place-items: center; overflow: hidden; background: #000; }
.desk-display canvas { display: block; }
.desk-hint { max-width: 320px; padding: 0 20px 16px; opacity: .7; font-size: 12px; }

.desk-screen.is-connecting .desk-signin,
.desk-screen.is-connected .desk-signin,
.desk-screen.is-error .desk-signin { display: none; }
.desk-screen.is-connected .desk-display { display: grid; }

/* Fullscreen chrome (autohide). Outside fullscreen nothing here applies: the
   title bar sits in normal flow and .desk-reveal is display:none, so the
   windowed screen is unchanged.

   In fullscreen the chrome is taken OUT of flow and overlaid on the canvas.
   That is deliberate: letting it reflow would resize .desk-display, which
   fires the ResizeObserver in desktop.js and sends a sendSize to the remote
   machine — so every peek at the toolbar would change the Windows resolution.
   Overlaying keeps the session's geometry perfectly still.

   The chrome's box still spans the hidden bar's height, which sits over the
   canvas, so it is pointer-events:none with its two interactive children
   opted back in. Without that, moving the mouse anywhere near the top of the
   remote desktop would summon the bar. */
.desk-chrome { position: relative; }
.desk-reveal { display: none; }
.desk-screen.is-fullscreen { position: relative; }
.desk-screen.is-fullscreen .desk-chrome {
  position: absolute; top: 0; left: 0; right: 0; z-index: 5; pointer-events: none;
}
.desk-screen.is-fullscreen .desk-head {
  pointer-events: auto;
  background: var(--color-bg);
  transform: translateY(-100%);
  /* Asymmetric delays, because the two directions want opposite things.
     Revealing waits .35s so a pointer merely crossing the top edge on its way
     somewhere else does not summon the bar — without it the chrome flickers
     whenever you work near the top of the remote desktop. Hiding waits .5s so
     the bar does not slip away while you are reaching for one of its buttons;
     a pinned bar ignores both. */
  transition: transform .18s ease .5s;
}
.desk-screen.is-fullscreen .desk-chrome:hover .desk-head { transition-delay: .35s; }
.desk-screen.is-fullscreen.is-pinned .desk-head { transition-delay: 0s; }
.desk-screen.is-fullscreen .desk-chrome:hover .desk-head,
.desk-screen.is-fullscreen.is-pinned .desk-head { transform: none; }
.desk-screen.is-fullscreen .desk-reveal {
  display: block; pointer-events: auto;
  position: absolute; top: 0; left: 0; right: 0; height: 6px;
  padding: 0; border: 0; cursor: pointer;
  background: color-mix(in srgb, var(--color-text) 35%, transparent);
}
.desk-screen.is-fullscreen .desk-reveal:hover { background: color-mix(in srgb, var(--color-text) 60%, transparent); }
.desk-screen.is-fullscreen.is-pinned .desk-reveal { background: var(--color-accent); }
.desk-screen.is-fullscreen .desk-reveal:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }

/* Shown only in Firefox, and only while it lacks navigator.clipboard.readText
   — see desktop.js. It retires itself if Firefox ever ships the API. */
.desk-ff-note {
  margin: 0; padding: 8px 20px; font-size: 12px; opacity: .8;
  border-left: 3px solid var(--color-accent);
}
.desk-ff-note[hidden] { display: none; }

/* Settings tabs — one panel per feature. The tablist is a real ARIA tablist:
   arrow keys move between tabs, and only the selected tab is in the tab order
   (roving tabindex), so Tab moves *into* the panel rather than along four
   buttons. */
.set-tabs {
  display: flex; gap: 2px; padding: 0 20px; margin-bottom: 4px;
  border-bottom: 1px solid var(--color-divider); overflow-x: auto;
}
.set-tab {
  appearance: none; background: transparent; border: 0;
  border-bottom: 2px solid transparent; margin-bottom: -1px;
  padding: 10px 14px; min-height: 44px; white-space: nowrap;
  font: inherit; font-size: 13px; letter-spacing: .02em;
  color: color-mix(in srgb, var(--color-text) 60%, transparent);
  cursor: pointer;
}
.set-tab:hover { color: var(--color-text); }
.set-tab.is-active { color: var(--color-text); border-bottom-color: var(--color-accent); }
.set-tab:focus-visible { outline: 2px solid var(--color-accent); outline-offset: -2px; }
.set-panel[hidden] { display: none; }

/* A saved value that the running process has not picked up yet. Every key on
   the Terminal and Desktop tabs is read once at boot, so this is the normal
   state after a save, not an error. */
.set-pending {
  font-size: 12px; margin-top: 4px;
  color: color-mix(in srgb, var(--color-accent) 80%, var(--color-text));
}
.set-field-help { font-size: 12px; opacity: .65; margin-top: 4px; }

/* Settings → Navigation order. Up/down buttons rather than drag: identical
   behaviour with mouse, touch and keyboard, with no parallel implementation. */
.set-nav-order { display: flex; flex-direction: column; gap: 4px; margin-top: 8px; }
.set-nav-row {
  display: grid; grid-template-columns: 1fr auto auto auto; align-items: center; gap: 8px;
  padding: 8px 10px; border: 1px solid var(--color-divider);
}
.set-nav-name { font-size: 14px; }
.set-nav-pos { font-size: 12px; opacity: .7; }
.set-nav-row .btn { min-width: 40px; min-height: 36px; padding: 4px 10px; }
.set-nav-row .btn:disabled { opacity: .35; cursor: default; }

/* `.rail-item` and `.drawer-row` both set `display`, an author rule of the same
   specificity as the UA's `[hidden] { display: none }` — so the later author
   declaration wins and a `hidden` entry stays on screen. The attribute selector
   outranks the bare class, which is what actually hides the overflow. (Same
   trap as `.desk-toolbar[hidden]` above; it is worth grepping for `[hidden]`
   whenever a new component sets `display`.) */
.rail-item[hidden], .drawer-row[hidden] { display: none; }

/* Notes on a phone. `.nts-left` is a fixed 288px pane and `.nts-screen` is a
   flex row, with no breakpoint of its own — so on a 390px screen the list took
   289px, the editor header could not shrink below its buttons, and the row grew
   to 560px. Save, Delete and Markdown ended up past the right edge with no way
   to reach them: the buttons were in the DOM, so every functional test passed.
   Stack the two panes instead, and let the editor header wrap. */
@media (max-width: 819.98px) {
  .nts-screen { flex-direction: column; }
  .nts-left {
    width: auto;
    max-height: 38vh;
    border-right: none;
    border-bottom: 1px solid var(--rule-strong);
  }
  .nts-right-head {
    padding: 16px 16px 12px;
    flex-wrap: wrap;
    align-items: center;
    gap: 10px;
  }
  .nts-right-head-info { flex: 1 1 100%; }
  /* 34px of condensed heading eats the width the buttons need beside it. */
  .nts-title { font-size: 24px; }
  .nts-actions, .nts-confirm { flex-wrap: wrap; }
  .nts-error { padding: 0 16px 12px; }
}

/* The torrent limits form, moved here from the deleted settings sheet. Its
   numeric fields pair up naturally; on a phone .set-grid is already one
   column, so nothing extra is needed there. */
#set-torrent-limits-form .set-grid { align-items: start; }
