/* ============================================================
   EON Library — YouTube grammar, EON's content
   Reference: youtube.com desktop, 2026. Every element answers
   "how would YouTube draw this?" — masthead, guide, shelves,
   cards, watch page. Only the library uses this sheet; the
   matrix homepage/login is a separate world and stays one.
   ============================================================ */

/* Material Symbols — playlist icons come from the database by name.

   Subset build, not the full face: 7,252 B against 370,252 B. The full
   font was the single heaviest asset in the product (44.5% of a share-
   link load) for 41 glyphs. /fonts/material-symbols-rounded.woff2 is
   left in place untouched — the admin sheet still loads it.

   font-display STAYS block. These glyphs are ligatures, so the DOM text
   is the literal word "lock_open"; under `swap` the fallback face would
   render those words on screen until the font arrived. block hides them
   instead. At 7 KB the wait it guards is now negligible anyway.

   The glyph set is the admin quick-pick grid (MATRIX_ICONS in
   src/lib/admin/icons.ts) ∪ the icon_name values live in playlists when
   it was built ∪ the five hardcoded in app.js. Names OUTSIDE it no
   longer break: data-live.js fetches them as a per-glyph Google Fonts
   subset under the .ms-dyn family below. Rebuilding this file is now an
   OPTIMIZATION — promote an icon into it (and into the mirrored lists
   in icons.ts + data-live.js) when a playlist wears it permanently, so
   members stop paying the extra request. Rebuild:
     pyftsubset public/fonts/material-symbols-rounded.woff2 \
       --output-file=public/fonts/material-symbols-matrix.woff2 \
       --flavor=woff2 --glyphs="<comma-separated names>" \
       --text="abcdefghijklmnopqrstuvwxyz_" \
       --layout-features+=liga,dlig,calt,rlig --no-layout-closure \
       --no-hinting --desubroutinize
   --no-layout-closure is load-bearing: without it the ligature closure
   walks the whole alphabet and drags all 4,010 glyphs back in (the same
   subset came out at 282 KB with it left on). */
@font-face {
  font-family: "Material Symbols Rounded";
  src: url("/fonts/material-symbols-matrix.woff2") format("woff2");
  font-display: block;
}
.ms {
  font-family: "Material Symbols Rounded";
  font-weight: normal;
  font-style: normal;
  letter-spacing: normal;
  text-transform: none;
  white-space: nowrap;
  direction: ltr;
  -webkit-font-smoothing: antialiased;
  font-feature-settings: "liga";
}
/* Icons OUTSIDE the local subset. The @font-face for this family is
   injected at runtime by data-live.js (ensureIconFont) — a per-glyph
   Google Fonts subset covering exactly the exotic names in use. It must
   be a separate family, not a fallback inside .ms: the local subset owns
   the a–z glyphs its ligatures need, so an unknown name renders there as
   literal letters instead of falling through to a second face. app.js
   adds this class to precisely the spans whose name needs it. The local
   face stays in the stack so a failed Google fetch degrades to the old
   name-as-text behavior rather than inventing a third one. */
.ms-dyn { font-family: "Material Symbols Dynamic", "Material Symbols Rounded"; }

:root {
  /* YouTube dark palette, verbatim — restored 2026-08-11 on request
     (the brand-purple dark re-ink is in git history if wanted back):
     page #0f0f0f, chips/hovers #272727, pressed #3f3f3f, #f1f1f1
     text over #aaa secondary, link blue as the one accent. */
  --bg: #0f0f0f;
  --bg-elev: #121212;          /* search field well */
  --surface: #272727;          /* chips, hovers */
  --surface-2: #3f3f3f;        /* pressed / active-on-hover */
  --line: rgba(255, 255, 255, 0.12);
  --line-strong: rgba(255, 255, 255, 0.2);
  --text: #f1f1f1;             /* 15.5:1 on --bg */
  --text-2: #aaaaaa;           /* 7.0:1 on --bg */
  --text-3: #aaaaaa;
  --accent: #3ea6ff;           /* link/live blue, 6.8:1 on --bg */
  --accent-soft: rgba(62, 166, 255, 0.15);
  --signal: #3ea6ff;
  --focus: #3ea6ff;
  /* The search field's focus beam — Eon Yellow on dark, Eon Orange on
     cream (owner's call, 2026-08-24). Both are bible primaries; the
     soft half is the same hue for the outer glow. */
  --search-beam: #f3c516;
  --search-beam-soft: rgba(243, 197, 22, 0.4);
  --danger: #f03030;
  --radius: 12px;              /* thumbnails — YouTube's card round */
  --radius-lg: 12px;
  --radius-sm: 4px;            /* duration chip */
  --topbar-h: 56px;
  /* Second masthead row, phones only (see the 640px block). Zero here so
     desktop metrics are untouched. Deliberately separate from --topbar-h,
     which also positions the watch modal, the guide and the up-next rail. */
  --search-h: 0px;

  /* Reading tiers, named so the library has one place to argue about
     size instead of thirty. Raised one step each from 16 / 14 / 12:
     the library had 198 text nodes under 16px, 85 of them at 12px,
     against a membership that skews older.

     Deliberately NOT applied to: #search (16px is the iOS auto-zoom
     floor and must not move), button labels, or the login card — that
     page solved legibility with weight and darker dims instead of size
     on the owner's call, and it measures clean. This is the library
     only. The profile wizard keeps its own 18px floor. */
  --fs-title: 17px;   /* card + hero titles — primary reading text */
  --fs-meta: 15px;    /* byline, counts, nav rows, watch meta */
  --fs-chip: 13px;    /* duration chips, relative dates, now-playing */
  --sidebar-w: 240px;
  /* Roboto is YouTube's voice; Arial/Helvetica hold the metrics
     where it isn't installed. One family for everything. */
  --font: "Roboto", "Segoe UI", Helvetica, Arial, sans-serif;
  --font-display: "Roboto", "Segoe UI", Helvetica, Arial, sans-serif;
  --font-mono: "Roboto", "Segoe UI", Helvetica, Arial, sans-serif;
  --ease-move: cubic-bezier(0.32, 0.72, 0, 1);
  --dur-hover: 120ms;
  --dur-panel: 200ms;
  /* Solid masthead — no blur, no float. */
  --topbar-bg: #0f0f0f;
  --sheen: transparent;
  --watch-bg: #0f0f0f;         /* watch is a page, not a glass modal */
  --selection: rgba(62, 166, 255, 0.3);
  --scrollbar: rgba(255, 255, 255, 0.2);
  --scrollbar-hover: rgba(255, 255, 255, 0.32);
  /* Flat, everywhere. YouTube separates with space and greys,
     never shadow, ring or plate. */
  --card-shadow: none;
  --player-shadow: none;
  --panel-shadow: 4px 0 16px rgba(0, 0, 0, 0.5);
  --plate-ring: transparent;
  --plate-tint: transparent;
}

/* ── Light theme ─────────────────────────────────────────────
   Eon brand light: near-black ink, Eon Purple as the --cta accent
   (yellow cannot carry text-scale contrast on cream).

   ── the tonal ramp, and why the ground is no longer pure cream ──
   This theme was written dark-first, where near-black does the work
   for free: bright artwork pops off it, and "flat, no shadows,
   separate with space" reads as cinematic. Ported to cream verbatim
   nothing did that work — masthead, guide and content were one
   continuous sheet of #FFFAE7, so the page had no structure and the
   artwork had no frame. Owner, 2026-08-24: "the white theme looks
   bland."

   The answer is tonal elevation rather than more colour, which is
   also how Material 3 builds a light scheme: the GROUND steps down,
   and everything that should read as lifted stays at cream or above,
   so chrome and plates sit on top of the content plane instead of
   dissolving into it. Artwork is framed by --plate-ring rather than
   by a shadow — one hairline costs nothing to paint across eighty
   tiles, and a shadow on every one of them would.

   Every value here mixes the two colours the brand bible names as
   TERTIARY, i.e. the ones it designates for backgrounds: Cream
   #FFFAE7 and Dark Purple #341D3D. That is deliberate. The bible
   forbids shades (add black) and tones (add gray) of any brand
   colour, so this ramp is background colours mixing with each other
   and never a darkened brand hue — the same rule that retired
   #b8641c.

   Dark is untouched. --plate-ring stays `transparent` there, so the
   shared rules that draw the hairline paint nothing on dark at all. */
@media (prefers-color-scheme: light) {
  :root:not([data-theme="dark"]) {
    /* the content plane, one step down from cream */
    --bg: color-mix(in srgb, #fffae7, #341d3d 6%);
    /* pure cream and lighter are now the LIFTED values */
    --bg-elev: #fffdf4;
    --surface: color-mix(in srgb, #fffae7, #341d3d 3%);
    --surface-2: color-mix(in srgb, #fffae7, #341d3d 12%);
    --line: rgba(52, 29, 61, 0.14);
    --line-strong: rgba(52, 29, 61, 0.26);
    --text: #17101c;
    --text-2: #5c5260;
    --text-3: #5c5260;
    /* Eon Orange, not Eon Purple. Owner's call, 2026-08-24: the site
       carries one accent and it is the brand orange, so nothing on a
       page reads as a second, unrelated highlight colour. The purple
       measured better on cream (7.4:1 against 2.81:1) — see the deck's
       accent block for the full record of that trade. */
    --accent: #db8037;
    --accent-soft: rgba(219, 128, 55, 0.16);
    --signal: #db8037;
    --focus: #db8037;
    --search-beam: #db8037;
    --search-beam-soft: rgba(219, 128, 55, 0.4);
    --danger: #b3261e;
    --topbar-bg: #fffae7;
    --sheen: transparent;
    --watch-bg: #fffae7;
    --selection: rgba(243, 197, 22, 0.45);
    --scrollbar: rgba(52, 29, 61, 0.3);
    --scrollbar-hover: rgba(52, 29, 61, 0.45);
    --card-shadow: none;
    --player-shadow: none;
    --panel-shadow: 4px 0 16px rgba(52, 29, 61, 0.15);
    --plate-ring: rgba(52, 29, 61, 0.11);
    --plate-tint: transparent;
  }
}
:root[data-theme="light"] {
  /* the content plane, one step down from cream */
  --bg: color-mix(in srgb, #fffae7, #341d3d 6%);
  /* pure cream and lighter are now the LIFTED values */
  --bg-elev: #fffdf4;
  --surface: color-mix(in srgb, #fffae7, #341d3d 3%);
  --surface-2: color-mix(in srgb, #fffae7, #341d3d 12%);
  --line: rgba(52, 29, 61, 0.14);
  --line-strong: rgba(52, 29, 61, 0.26);
  --text: #17101c;
  --text-2: #5c5260;
  --text-3: #5c5260;
  /* Eon Orange, not Eon Purple. Owner's call, 2026-08-24: the site
     carries one accent and it is the brand orange, so nothing on a
     page reads as a second, unrelated highlight colour. The purple
     measured better on cream (7.4:1 against 2.81:1) — see the deck's
     accent block for the full record of that trade. */
  --accent: #db8037;
  --accent-soft: rgba(219, 128, 55, 0.16);
  --signal: #db8037;
  --focus: #db8037;
  --search-beam: #db8037;
  --search-beam-soft: rgba(219, 128, 55, 0.4);
  --danger: #b3261e;
  --topbar-bg: #fffae7;
  --sheen: transparent;
  --watch-bg: #fffae7;
  --selection: rgba(243, 197, 22, 0.45);
  --scrollbar: rgba(52, 29, 61, 0.3);
  --scrollbar-hover: rgba(52, 29, 61, 0.45);
  --card-shadow: none;
  --player-shadow: none;
  --panel-shadow: 4px 0 16px rgba(52, 29, 61, 0.15);
  --plate-ring: rgba(52, 29, 61, 0.11);
  --plate-tint: transparent;
}

/* ── Guide icons (24px, single-colour masks) ────────────────
   YouTube's guide leads every row with an icon; the categories
   get one each, keyed on the data-cat attribute app.js already
   writes. Masks take currentColor, so they follow text state. */
:root {
  --ic-home: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M4 11.5 12 4l8 7.5V20a1 1 0 0 1-1 1h-5v-6h-4v6H5a1 1 0 0 1-1-1z'/%3E%3C/svg%3E");
  --ic-live: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zm0 2a7 7 0 1 1 0 14 7 7 0 0 1 0-14zm0 3.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 0 1 0-7z'/%3E%3C/svg%3E");
  --ic-cam: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M3 6.5A1.5 1.5 0 0 1 4.5 5h10A1.5 1.5 0 0 1 16 6.5V10l5-3.5v11L16 14v3.5a1.5 1.5 0 0 1-1.5 1.5h-10A1.5 1.5 0 0 1 3 17.5z'/%3E%3C/svg%3E");
  --ic-clips: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill-rule='evenodd' d='M3 5.5A1.5 1.5 0 0 1 4.5 4h15A1.5 1.5 0 0 1 21 5.5v13a1.5 1.5 0 0 1-1.5 1.5h-15A1.5 1.5 0 0 1 3 18.5zM10 9v6l5.5-3z'/%3E%3C/svg%3E");
  --ic-person: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 4a4 4 0 1 1 0 8 4 4 0 0 1 0-8zm0 10c4.4 0 8 2 8 5v2H4v-2c0-3 3.6-5 8-5z'/%3E%3C/svg%3E");
  --ic-chat: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H9l-6 5z'/%3E%3C/svg%3E");
  --ic-couch: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M2 12a3 3 0 0 1 2-2.8V8a3 3 0 0 1 3-3h10a3 3 0 0 1 3 3v1.2A3 3 0 0 1 22 12v5a2 2 0 0 1-2 2h-1v2h-2v-2H7v2H5v-2H4a2 2 0 0 1-2-2z'/%3E%3C/svg%3E");
  --ic-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M2 9c0-1.1 1-2 3-2h14c2 0 3 .9 3 2 0 1.5-1 6-5.5 6-2 0-3-1.2-4.5-1.2S9.5 15 7.5 15C3 15 2 10.5 2 9z'/%3E%3C/svg%3E");
  --ic-moon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M20.5 14.5A9 9 0 1 1 9.5 3.5a7.5 7.5 0 0 0 11 11z'/%3E%3C/svg%3E");
  --ic-book: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M4 5a1 1 0 0 1 1-1h5a2 2 0 0 1 2 2 2 2 0 0 1 2-2h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-5.5c-.8 0-1.5.4-1.5 1 0-.6-.7-1-1.5-1H5a1 1 0 0 1-1-1z'/%3E%3C/svg%3E");
  --ic-globe: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zm0 2a7 7 0 1 1 0 14 7 7 0 0 1 0-14zM5 11h14v2H5z'/%3E%3Cpath d='M12 5c2.2 0 4 3.1 4 7s-1.8 7-4 7-4-3.1-4-7 1.8-7 4-7zm0 2c-1.1 0-2 2.2-2 5s.9 5 2 5 2-2.2 2-5-.9-5-2-5z'/%3E%3C/svg%3E");
  --ic-flag: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M6 3h2v18H6zm4 2h9.5l-2.5 4 2.5 4H10z'/%3E%3C/svg%3E");
  --ic-mic: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 3a3 3 0 0 1 3 3v6a3 3 0 0 1-6 0V6a3 3 0 0 1 3-3zm-7 9h2a5 5 0 0 0 10 0h2a7 7 0 0 1-6 6.9V21h-2v-2.1A7 7 0 0 1 5 12z'/%3E%3C/svg%3E");
  --ic-info: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zm0 2a7 7 0 1 1 0 14 7 7 0 0 1 0-14zm-1 5h2v7h-2zm0-3h2v2h-2z'/%3E%3C/svg%3E");
  --ic-bookmark: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M7 3h10a1 1 0 0 1 1 1v17l-6-4-6 4V4a1 1 0 0 1 1-1z'/%3E%3C/svg%3E");
  --ic-search: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M10.5 3a7.5 7.5 0 1 1 0 15 7.5 7.5 0 0 1 0-15zm0 2a5.5 5.5 0 1 0 0 11 5.5 5.5 0 0 0 0-11zm7.8 11.9 3.2 3.2-1.4 1.4-3.2-3.2z'/%3E%3C/svg%3E");
  /* clock — recent-search rows in the suggestion sheet */
  --ic-history: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 3a9 9 0 1 1 0 18 9 9 0 0 1 0-18zm0 2a7 7 0 1 0 0 14 7 7 0 0 0 0-14zm1 2v5.1l3.9 2.3-1 1.7-4.9-2.9V7z'/%3E%3C/svg%3E");
  /* check — the Watched pill */
  --ic-check: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M9.55 15.15 19.6 5.1l1.4 1.4-11.45 11.5L4.5 13l1.4-1.4z'/%3E%3C/svg%3E");
  --ic-pin: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill-rule='evenodd' d='M12 3a6.5 6.5 0 0 1 6.5 6.5C18.5 14 12 21 12 21S5.5 14 5.5 9.5A6.5 6.5 0 0 1 12 3zm0 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5z'/%3E%3C/svg%3E");
  --ic-shield: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 2l8 3.5V11c0 5-3.2 8.7-8 11-4.8-2.3-8-6-8-11V5.5z'/%3E%3C/svg%3E");
  --ic-sun: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 7a5 5 0 1 1 0 10 5 5 0 0 1 0-10zM11 2h2v3h-2zm0 17h2v3h-2zM2 11h3v2H2zm17 0h3v2h-3zM4.2 5.6l1.4-1.4 2.1 2.1-1.4 1.4zm12.1 12.1 1.4-1.4 2.1 2.1-1.4 1.4zm1.4-13.5 1.4 1.4-2.1 2.1-1.4-1.4zM5.6 19.8l-1.4-1.4 2.1-2.1 1.4 1.4z'/%3E%3C/svg%3E");
}

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

html { color-scheme: dark; background: #0f0f0f; }
@media (prefers-color-scheme: light) {
  html:not([data-theme="dark"]) { color-scheme: light; background: var(--bg); }
}
html[data-theme="light"] { color-scheme: light; background: var(--bg); }

/* NO cross-document view transitions — deliberately. They were tried
   for the login→library seam and produced white flashes twice. The
   seam is carried by paint-holding, the wall handoff in js/wall.js,
   and the black first-frame grounds. Do not reintroduce
   @view-transition here without retesting the flash. */

body {
  font-family: var(--font);
  background: var(--bg);
  color: var(--text);
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
}

img { display: block; }
button { font-family: inherit; }
a { color: inherit; }

::selection { background: var(--selection); }

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

.visually-hidden {
  position: absolute;
  width: 1px; height: 1px;
  padding: 0; margin: -1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}

.skip-link {
  position: fixed;
  top: 10px; left: 10px;
  z-index: 200;
  padding: 12px 18px;
  min-height: 44px;
  display: flex;
  align-items: center;
  font-size: 14px;
  font-weight: 500;
  color: var(--text);
  background: var(--surface);
  border: 1px solid var(--line-strong);
  border-radius: 8px;
  text-decoration: none;
  transform: translateY(-200%);
}
.skip-link:focus-visible { transform: none; }

/* Scrollbars */
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
  background: var(--scrollbar);
  border-radius: 5px;
  border: 2px solid var(--bg);
}
::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-hover); }

/* ============================================================
   Masthead — 56px, solid, three zones: burger+logo / search / end
   ============================================================ */

.topbar {
  position: fixed;
  top: 0; left: 0; right: 0;
  height: calc(var(--topbar-h) + var(--search-h));
  z-index: 50;
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 0 16px;
  background: var(--topbar-bg);
  /* Separates the chrome from the content plane. The masthead and the
     page below it used to be the same cream with nothing between. */
  box-shadow: 0 1px 0 var(--plate-ring);
}

.topbar::after { content: none; }

.brand {
  display: flex;
  align-items: center;
  gap: 8px;
  cursor: pointer;
  user-select: none;
  flex-shrink: 0;
  padding: 0 4px;
}

.brand img { width: 28px; height: 28px; }

.logo-on-light { display: none; }
@media (prefers-color-scheme: light) {
  :root:not([data-theme="dark"]) .logo-on-dark { display: none; }
  :root:not([data-theme="dark"]) .logo-on-light { display: block; }
}
:root[data-theme="light"] .logo-on-dark { display: none; }
:root[data-theme="light"] .logo-on-light { display: block; }
:root[data-theme="dark"] .logo-on-dark { display: block; }
:root[data-theme="dark"] .logo-on-light { display: none; }

/* Wordmark next to the mark, like the YouTube logotype. Sized so the
   caps stand exactly as tall as the 24px EON block beside them
   (Roboto cap height ≈ 0.71em → 34px ≈ 24px of capital). */
.brand span {
  font-size: 27px;
  font-weight: 800;
  letter-spacing: -0.01em;
  line-height: 1;
}

/* Icon-only round burger, always present — YouTube's first pixel. */
.menu-btn {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 44px;
  min-width: 44px;
  min-height: 44px;
  padding: 0;
  background: none;
  border: none;
  color: var(--text);
  cursor: pointer;
  border-radius: 50%;
  flex-shrink: 0;
  transition: background var(--dur-hover) ease;
}
.menu-btn:hover { background: var(--surface); }
.menu-btn span { display: none; }

/* Search: pill field + attached magnifier button, centred. */
/* Three real columns, not an absolutely-positioned bar floating over
   two of them. It used to be pinned to 50% with width:min(560px, 100vw
   - 470px) — a hand-measured reserve for whatever happened to be in the
   top-right at the time. Adding the "Log in / Sign up" button made the
   right side wider than the reserve and the search sat on top of it.

   Left and right both flex:1 1 0, so they take equal width and the
   middle is centred by construction rather than by arithmetic. Nothing
   can overlap because nothing is out of flow, and it holds at any
   window size: the search gives up its width first, down to nothing,
   before the buttons are touched. */
.topbar-left {
  display: flex;
  align-items: center;
  gap: 8px;
  flex: 1 1 0;
  min-width: 0;
}
.search-wrap {
  /* Anchors the suggestion sheet AND .search-clear. The clear button was
     always right:66px against "the wrap" — without this it resolved
     against the fixed .topbar on desktop and floated over the sign-out
     corner whenever it showed. */
  position: relative;
  flex: 0 1 560px;
  min-width: 0;
  margin: 0 12px;
  display: flex;
  align-items: center;
}

/* The in-field icon retires; the attached button carries it. */
.search-wrap > svg { display: none; }

#search {
  flex: 1;
  min-width: 0;
  min-height: 44px;
  padding: 10px 44px 10px 18px;
  font-size: 16px;
  color: var(--text);
  background: var(--bg-elev);
  border: 1px solid var(--line-strong);
  border-radius: 22px 0 0 22px;
  transition: border-color var(--dur-hover) ease;
}

#search::placeholder { color: var(--text-3); }

#search::-webkit-search-cancel-button,
#search::-webkit-search-decoration {
  -webkit-appearance: none;
  appearance: none;
  display: none;
}

/* The beam. The WHOLE pill lights — field and magnifier as one control,
   not a lit half and a grey half: both borders take the brand hue, and
   the ring + outer glow draw on the wrap so one unbroken outline traces
   the full pill silhouette (a per-half shadow would seam at the join).
   Yellow out of the dark, orange on cream. Focus stays visible without
   the global focus ring — the glow IS the indicator, and stronger than
   the 2px default. */
#search:focus { outline: none; }
.search-wrap:focus-within {
  border-radius: 22px;
  box-shadow: 0 0 0 1px var(--search-beam), 0 0 16px var(--search-beam-soft);
}
.search-wrap:focus-within #search,
.search-wrap:focus-within .search-go {
  border-color: var(--search-beam);
}
/* Only the OUTER edge beams. The field's right border is the divider
   between field and magnifier — lit, it reads as a bar down the middle
   of the pill. */
.search-wrap:focus-within #search { border-right-color: transparent; }

.search-clear {
  position: absolute;
  right: 66px; top: 50%;
  transform: translateY(-50%);
  display: none;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 44px;
  background: none;
  border: none;
  color: var(--text-2);
  cursor: pointer;
  border-radius: 50%;
  line-height: 0;
}
.search-clear:hover { color: var(--text); background: var(--surface); }
.search-clear.show { display: flex; }

/* The attached button: 64px wide, shares the field's outline. */
.search-go {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 64px;
  min-height: 44px;
  flex-shrink: 0;
  color: var(--text);
  background: var(--surface);
  border: 1px solid var(--line-strong);
  border-left: none;
  border-radius: 0 22px 22px 0;
  cursor: pointer;
}
.search-go:hover { background: var(--surface-2); }
/* While the field is lit, the magnifier takes the beam's colour too —
   the pill reads as one lit control, not a lit half and a grey half. */
.search-wrap:focus-within .search-go { color: var(--search-beam); }
.search-go::before {
  content: "";
  width: 22px; height: 22px;
  background: currentColor;
  -webkit-mask: var(--ic-search) center / contain no-repeat;
  mask: var(--ic-search) center / contain no-repeat;
}

/* ── The suggestion sheet — YouTube's grammar exactly ──────────
   Hung under the field, spanning field + button. Recent searches
   first (clock, with Remove), then matching titles (magnifier).
   In a suggestion the TYPED part is regular and the completion is
   bold — YouTube's way round, not autocomplete's. Rows are option
   divs driven from the input (combobox pattern), so the Remove
   inside a history row can be a real button. */
.search-suggest {
  position: absolute;
  top: calc(100% + 6px);
  left: 0;
  right: 0;
  padding: 10px 0;
  background: var(--bg-elev);
  border: 1px solid var(--line);
  border-radius: 12px;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
  overflow: hidden;
  z-index: 20;
}
.suggest-row {
  display: flex;
  align-items: center;
  gap: 14px;
  min-height: 44px;
  padding: 0 6px 0 18px;
  color: var(--text);
  font-size: 16px;
  cursor: pointer;
}
.suggest-row:hover,
.suggest-row.active { background: var(--surface); }
.suggest-row .si {
  flex: none;
  width: 20px; height: 20px;
  background: currentColor;
  opacity: 0.75;
  -webkit-mask: var(--ic-search) center / contain no-repeat;
  mask: var(--ic-search) center / contain no-repeat;
}
.suggest-row.history .si {
  -webkit-mask-image: var(--ic-history);
  mask-image: var(--ic-history);
}
.suggest-row .st {
  flex: 1;
  min-width: 0;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.suggest-row .st b { font-weight: 700; }
.suggest-remove {
  flex: none;
  min-height: 44px;
  padding: 0 12px;
  background: none;
  border: none;
  color: var(--accent);
  font-size: 14px;
  cursor: pointer;
  visibility: hidden;
}
.suggest-row:hover .suggest-remove,
.suggest-row.active .suggest-remove,
.suggest-remove:focus-visible { visibility: visible; }
/* No hover on touch — the target has to simply be there. */
@media (hover: none) {
  .suggest-remove { visibility: visible; }
}

.topbar-right {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 8px;
  flex: 1 1 0;
  min-width: 0;
}

/* Theme switch, the last row of the menu: a labelled settings row
   with a plain track and a sliding knob wearing the current mode's
   glyph — sun in daylight, moon in the dark. The button is a 44px
   target; the track inside stays slim and quiet. */
.theme-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-top: 12px;
  padding: 8px 12px 0;
  border-top: 1px solid var(--line);
}
.theme-row-label {
  font-size: 14px;
  color: var(--text);
}
.theme-toggle {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 56px;
  min-height: 44px;
  padding: 0;
  background: none;
  border: none;
  cursor: pointer;
}
.theme-toggle::before {
  content: "";
  width: 52px;
  height: 28px;
  border-radius: 14px;
  background: var(--surface);
  border: 1px solid var(--line-strong);
  transition: background var(--dur-hover) ease;
}
.theme-toggle:hover::before { background: var(--surface-2); }
.tt-knob {
  position: absolute;
  left: 5px;
  top: 50%;
  translate: 0 -50%;
  width: 22px;
  height: 22px;
  border-radius: 50%;
  background: var(--text);
  transition: transform var(--dur-hover) var(--ease-move);
}
.theme-toggle[aria-checked="true"] .tt-knob { transform: translateX(24px); }
.tt-knob::before {
  content: "";
  position: absolute;
  inset: 4px;
  background: var(--bg);
  -webkit-mask: var(--ic-sun) center / contain no-repeat;
  mask: var(--ic-sun) center / contain no-repeat;
}
.theme-toggle[aria-checked="true"] .tt-knob::before {
  -webkit-mask-image: var(--ic-moon);
  mask-image: var(--ic-moon);
}

/* End-of-bar pill, YouTube's outlined-button shape. */
.logout-btn {
  display: flex;
  align-items: center;
  gap: 8px;
  min-height: 40px;
  padding: 8px 15px;
  font-size: 14px;
  font-weight: 500;
  color: var(--text);
  background: transparent;
  border: 1px solid var(--line-strong);
  border-radius: 20px;
  cursor: pointer;
  transition: background var(--dur-hover) ease;
}
.logout-btn:hover { background: var(--surface); }

/* ============================================================
   Guide — 240px, borderless, icon-led rows; burger collapses it
   ============================================================ */

.sidebar {
  position: fixed;
  top: var(--topbar-h);
  left: 0; bottom: 0;
  width: var(--sidebar-w);
  z-index: 40;
  overflow-y: auto;
  overflow-x: hidden;
  padding: 12px;
  background: var(--bg);
  scrollbar-width: thin;
}

/* A plain line the team drops between playlists to break up the nav. */
.nav-divider {
  height: 1px;
  background: var(--line);
  margin: 10px 12px;
}

.nav-item {
  display: flex;
  align-items: center;
  gap: 20px;
  width: 100%;
  min-height: 44px;
  padding: 8px 12px;
  font-size: var(--fs-meta);
  color: var(--text);
  background: none;
  border: none;
  border-radius: 10px;
  cursor: pointer;
  text-align: left;
  transition: background var(--dur-hover) ease;
}

.nav-item:hover { background: var(--surface); }

/* Row icon: a Material Symbols ligature span (.nav-ic) that app.js
   fills from the playlist's icon_name column — admin-created playlists
   bring their own icon by just naming one. */
.nav-item .nav-ic {
  width: 24px;
  flex-shrink: 0;
  font-size: 24px;
  line-height: 1;
  text-align: center;
}

/* Active row: grey fill + weight. No bars, no colour — YouTube
   states it with ink alone. */
.nav-item.active {
  background: var(--surface);
  color: var(--text);
  font-weight: 500;
}
.nav-item.active:hover { background: var(--surface-2); }
/* The active row's icon takes a brand hue, so the selected place is
   readable at a glance even before the fill registers — Eon Yellow on
   dark, Eon Orange on cream (owner's call; not the theme accent). */
.nav-item.active .nav-ic { color: #f3c516; }
@media (prefers-color-scheme: light) {
  :root:not([data-theme="dark"]) .nav-item.active .nav-ic { color: #db8037; }
}
:root[data-theme="light"] .nav-item.active .nav-ic { color: #db8037; }

.nav-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

.nav-badge {
  font-size: 12px;
  font-weight: 500;
  letter-spacing: 0.02em;
  color: var(--text-2);
  background: var(--surface);
  padding: 2px 6px;
  border-radius: var(--radius-sm);
}
.nav-item.active .nav-badge,
.nav-item:hover .nav-badge { background: var(--surface-2); }

.sidebar-backdrop {
  display: none;
  position: fixed;
  inset: 0;
  z-index: 39;
  background: rgba(0, 0, 0, 0.5);
}

/* Desktop burger = collapse to the 72px icon rail (YouTube's
   mini-guide). app.js just toggles .open; above 900px we read
   "open" as "minimised" so no script changes are needed. */
@media (min-width: 901px) {
  /* The rail slides between full and mini instead of snapping, and
     the content shelf glides with it. Labels/badges/section titles
     fade and collapse in step (max-width + opacity, never display) so
     nothing pops mid-slide; visibility rides the transition so they
     stay off the accessibility tree once the rail settles. */
  .sidebar { transition: width var(--dur-panel) var(--ease-move), padding var(--dur-panel) var(--ease-move); }
  .main { transition: margin-left var(--dur-panel) var(--ease-move); }
  .nav-item { transition: background var(--dur-hover) ease, gap var(--dur-panel) var(--ease-move), padding var(--dur-panel) var(--ease-move); }
  .nav-label,
  .nav-badge,
  .theme-row-label {
    max-width: 200px;
    overflow: hidden;
    white-space: nowrap;
    transition: opacity var(--dur-panel) var(--ease-move), max-width var(--dur-panel) var(--ease-move), padding var(--dur-panel) var(--ease-move), visibility 0s linear var(--dur-panel);
  }
  .sidebar-pt { transition: opacity var(--dur-panel) var(--ease-move); }
  .sidebar:not(.open) .nav-label,
  .sidebar:not(.open) .nav-badge,
  .sidebar:not(.open) .theme-row-label { visibility: visible; transition-delay: 0s; }

  .sidebar.open { width: 72px; padding: 12px 4px; }
  .sidebar.open .nav-label,
  .sidebar.open .nav-badge,
  .sidebar.open .theme-row-label { visibility: hidden; opacity: 0; max-width: 0; padding-left: 0; padding-right: 0; }
  .sidebar.open .nav-item { justify-content: center; padding: 8px 0; gap: 0; }
  .sidebar.open .theme-row { justify-content: center; padding: 12px 0 0; }
  /* The mini-rail is plain — no glass pattern (faded, not yanked). */
  .sidebar.open .sidebar-pt { opacity: 0; }
  .sidebar.open ~ .main { margin-left: 72px; }
  .sidebar-backdrop.show { display: none; }
}

/* ============================================================
   Content
   ============================================================ */

.main {
  margin-left: var(--sidebar-w);
  padding: calc(var(--topbar-h) + var(--search-h) + 24px) 24px 80px;
  min-height: 100vh;
}

/* Page header: a compact round chip wearing the PAGE's own icon
   (home / the playlist's sidebar glyph / the magnifier — app.js
   passes it), name, meta line. The big EON avatar retired
   2026-08-13: the brand already owns the masthead. */
.page-head {
  display: grid;
  grid-template-columns: auto 1fr;
  grid-template-rows: auto auto;
  column-gap: 14px;
  align-content: center;
  margin-bottom: 24px;
}

.page-ic {
  grid-row: 1 / 3;
  grid-column: 1;
  align-self: center;
  display: grid;
  place-items: center;
  width: 44px; height: 44px;
  border-radius: 50%;
  background: var(--surface);
  /* Same brand hue as the guide's active icon: Eon Yellow on dark,
     Eon Orange on cream. */
  color: #f3c516;
  font-size: 24px;
}
@media (prefers-color-scheme: light) {
  :root:not([data-theme="dark"]) .page-ic { color: #db8037; }
}
:root[data-theme="light"] .page-ic { color: #db8037; }

/* Learning paths wear their course photo in the chip — same circle,
   image instead of glyph, drawn larger so the photography reads. */
.page-ic-art {
  overflow: hidden;
  width: 104px;
  height: 104px;
}
.page-ic-art img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

/* YouTube pages don't breadcrumb; the guide highlight answers
   "where am I". */
.page-eyebrow { display: none; }

.page-title {
  font-size: 24px;
  font-weight: 700;
  letter-spacing: 0;
  line-height: 1.3;
  align-self: end;
}

.page-meta {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 2px 8px;
  align-self: start;
  margin-top: 4px;
}

.page-count {
  font-size: var(--fs-meta);
  font-weight: 400;
  color: var(--text-2);
  white-space: nowrap;
}

.page-desc {
  font-size: var(--fs-meta);
  line-height: 1.4;
  color: var(--text-2);
  max-width: 70ch;
}

/* ============================================================
   Latest-uploads deck — the five newest videos as a pile, dealt
   one at a time, with the title in a fixed place beside it.

   Replaced the three-up "latest banner" on 2026-08-24, from the
   owner's mockup. Two things in here argue with the rest of this
   stylesheet. Both are deliberate and both are scoped to the deck,
   so nothing else on the page inherits either:

     · a real drop shadow, where --card-shadow is `none` and the
       rule is "flat, everywhere". A pile is the one object on this
       page whose entire meaning is depth — cards in front of other
       cards — and space and greys cannot say that.
     · chips written over the artwork, which the old banner had
       just been stripped of. They are the SAME chips the shelf
       tiles carry (.dur, .new-tag, .lock-chip, .pv-chip), so the
       deck agrees with the cards below it even though it reverses
       a decision made about the banner it replaces.

   ── the accent ──────────────────────────────────────────────
   NOT --cta. That token goes Eon Purple on cream, which is right
   for a sign-in button and wrong here: this is the library's
   warmest surface and it stays in the yellow/orange family in both
   themes (owner's call, 2026-08-24).

   Eon Yellow #f3c516 measures ~1.2:1 on cream and cannot carry
   anything there, so light mode takes Eon Orange — and takes the
   brand bible's actual #db8037, not a darkened stand-in (owner's
   call, 2026-08-24, overriding an earlier #b8641c / #a85c17 pair).

   The measurements that produced those darker values still stand and
   are kept here so nobody reads the brand colour as a bug and
   "fixes" it: #db8037 is 2.81:1 on cream against #b8641c's 4.12:1.
   That is under the 3:1 floor for a UI component and well under the
   4.5:1 for text, so the eyebrow and the active dot sit quieter
   against cream than they did. The owner's position is that the
   brand colour is the brand colour on this surface. If it is ever
   revisited, revisit it for the small TEXT only — the fills below
   are not the problem.

   ── the filled controls: Eon Orange, white ink ────────────
   Owner's call, 2026-08-24: the Watch button and the play glyph are
   WHITE in light mode, on the brand orange.

   Recorded honestly, because the number is not good and should not
   be rediscovered as a bug: white on #db8037 measures 2.93:1. That
   is under 4.5:1 for text and under even the 3:1 large-text floor.
   Dark ink on the same orange is 6.35:1, and a Dark Purple fill with
   white would have been 15.1:1 — both were built and both were
   rejected on the owner's call. The orange stays because it is the
   brand's, and lifting the ratio would mean darkening it, which the
   bible forbids as a shade.

   So this pairing is a deliberate brand decision taken against the
   measurement, not an oversight. If it is ever revisited, the two
   readable options above are one token each.

   Both filled controls use the same pairing on purpose — the fab
   sits on artwork and the button on the page, but they mean the same
   thing ("play this") and should not look like two ideas.
   ============================================================ */
:root {
  --deck-accent: #f3c516;
  --deck-fill: #f3c516;
  --deck-fill-ink: #17101c;
  --deck-halo: rgba(243, 197, 22, 0.14);
  --deck-line: rgba(255, 255, 255, 0.2);
  --deck-shadow-0: 0 26px 60px rgba(0, 0, 0, 0.62), 0 1px 0 rgba(255, 255, 255, 0.09) inset;
  --deck-shadow-1: 0 18px 44px rgba(0, 0, 0, 0.5);
  --deck-shadow-2: 0 12px 32px rgba(0, 0, 0, 0.45);
  --deck-shadow-drag: 0 40px 90px rgba(0, 0, 0, 0.72);
  --deck-dim-1: brightness(0.46) saturate(0.8);
  --deck-dim-2: brightness(0.28) saturate(0.65);
  --deck-dim-3: brightness(0.22);
  --deck-veil: linear-gradient(180deg, rgba(6, 6, 10, 0.4), rgba(6, 6, 10, 0.62));
}

/* Three blocks, not two — same reason tokens.css gives: an explicit
   choice stamps data-theme, and the default "system" setting stamps
   nothing at all, so system-default light is only reachable through
   the media query. */
@media (prefers-color-scheme: light) {
  :root:not([data-theme="dark"]) {
    --deck-accent: #db8037;
    --deck-fill: #db8037;
    --deck-fill-ink: #ffffff;
    --deck-halo: rgba(219, 128, 55, 0.22);
    --deck-line: rgba(52, 29, 61, 0.18);
    --deck-shadow-0: 0 22px 48px rgba(52, 29, 61, 0.22), 0 1px 0 rgba(255, 255, 255, 0.7) inset;
    --deck-shadow-1: 0 14px 32px rgba(52, 29, 61, 0.16);
    --deck-shadow-2: 0 10px 24px rgba(52, 29, 61, 0.12);
    --deck-shadow-drag: 0 32px 70px rgba(52, 29, 61, 0.3);
    /* brightness() is how a card recedes on black. On cream it just
       makes the picture look dirty, so the light theme holds the
       geometry and the veil — which are what actually say "behind" —
       and lifts the filter almost all the way off. */
    --deck-dim-1: brightness(0.95) saturate(0.9);
    --deck-dim-2: brightness(0.89) saturate(0.82);
    --deck-dim-3: brightness(0.86);
    --deck-veil: linear-gradient(180deg, rgba(52, 29, 61, 0.14), rgba(52, 29, 61, 0.26));
  }
}
:root[data-theme="light"] {
  --deck-accent: #db8037;
  --deck-fill: #db8037;
  --deck-fill-ink: #ffffff;
  --deck-halo: rgba(219, 128, 55, 0.22);
  --deck-line: rgba(52, 29, 61, 0.18);
  --deck-shadow-0: 0 22px 48px rgba(52, 29, 61, 0.22), 0 1px 0 rgba(255, 255, 255, 0.7) inset;
  --deck-shadow-1: 0 14px 32px rgba(52, 29, 61, 0.16);
  --deck-shadow-2: 0 10px 24px rgba(52, 29, 61, 0.12);
  --deck-shadow-drag: 0 32px 70px rgba(52, 29, 61, 0.3);
  --deck-dim-1: brightness(0.95) saturate(0.9);
  --deck-dim-2: brightness(0.89) saturate(0.82);
  --deck-dim-3: brightness(0.86);
  --deck-veil: linear-gradient(180deg, rgba(52, 29, 61, 0.14), rgba(52, 29, 61, 0.26));
}

.deckbar {
  display: grid;
  /* The pile takes the larger share and the card fills it, so the video
     is ~45% of the viewport at 1440 and keeps growing with the window —
     owner's call, 2026-08-24: "as big as possible on desktop". minmax(0,
     …) rather than a bare fr on both tracks, because an fr track's
     minimum is auto and one long unbroken word in a title would
     otherwise push the deck narrower than its share. */
  grid-template-columns: minmax(0, 0.76fr) minmax(0, 1.24fr);
  /* The prev/next controls used to live at the bottom of the TEXT
     column, which put the two buttons that move the pile as far from the
     pile as the layout allowed. They now sit directly under it, in the
     pile's own column, where "these move the thing above them" needs no
     explaining. The lead spans both rows so it still centres against the
     full height. */
  grid-template-areas:
    "lead stage"
    "lead nav";
  column-gap: 38px;
  row-gap: 16px;
  align-items: center;
  padding: 8px 0 26px;
  margin-bottom: 8px;
  /* A dealt card travels 150px to the right of a stage that already ends
     at the edge of <main>, so without this every deal grew the document's
     scroll width and the whole page gained a horizontal scrollbar for the
     half-second the card was in the air.

     `clip`, not `hidden`: hidden on one axis forces the other to `auto`,
     which would have turned the pile's upward lift into a vertical
     scrollbar inside the banner. clip takes the horizontal axis only and
     leaves the vertical genuinely visible, which is what lets the cards
     behind the front one stand above the box. */
  overflow-x: clip;
  overflow-y: visible;
}

/* ── the headline, outside the deck and in one fixed place ──────
   It swaps as cards are dealt; it never moves. */
.deckbar-lead { grid-area: lead; align-self: center; min-width: 0; }

.deck-eyebrow {
  display: flex;
  align-items: center;
  gap: 9px;
  font-family: var(--font);
  font-size: clamp(11px, 0.7vw, 14px);
  font-weight: 600;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--deck-accent);
}
.deck-pulse {
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--deck-accent);
  flex-shrink: 0;
}
@media (prefers-reduced-motion: no-preference) {
  .deck-pulse { animation: deck-pulse 1.9s ease-in-out infinite; }
}
@keyframes deck-pulse {
  0%, 100% { opacity: 1; transform: scale(1); }
  50% { opacity: 0.35; transform: scale(0.7); }
}

/* min-height holds two lines' worth of space whatever the title is,
   so a one-line title followed by a three-line one does not make the
   whole banner jump. */
/* Two numbers doing two jobs.

   The SIZE tracks the viewport all the way up. 38px was tuned against a
   14-inch screen and was originally the ceiling — which meant that on a
   27-inch monitor the card roughly doubled while the headline beside it
   did not move, and the text ended up as a small block adrift in a very
   wide column. 2.4vw keeps the ratio that was tuned (it resolves to 38px
   at ~1600px, exactly as before) and carries it up to 56px.

   The MEASURE is capped in em, so it is a constant number of characters
   at every size rather than a constant number of pixels. That is what
   keeps the line count stable as the column grows — without it a wider
   column fits the same title on fewer lines and the min-height below
   turns into a hole under the headline. Whatever is left over becomes
   gutter between the text and the pile, which is the right place for it.

   min-height holds THREE lines rather than the mockup's two, because at
   this measure that is what a long title takes. Nothing is clamped or
   ellipsised: a title needing a fourth line gets it and the banner grows
   by one line rather than cutting a word in half. */
.deck-title {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: clamp(26px, 2.4vw, 56px);
  line-height: 1.04;
  letter-spacing: -0.012em;
  color: var(--text);
  margin: 16px 0 0;
  max-width: 13em;
  min-height: 3.15em;
  overflow-wrap: break-word;
}

.deck-meta {
  font-family: var(--font);
  font-size: clamp(13px, 0.85vw, 17px);
  letter-spacing: 0.06em;
  color: var(--text-2);
  margin-top: 14px;
}

.deck-watch {
  display: inline-flex;
  align-items: center;
  gap: 9px;
  margin-top: 24px;
  /* Scales with the headline for the same reason it does — a 15px button
     under a 56px title beside a 1400px card reads as an afterthought. */
  padding: clamp(14px, 0.85vw, 17px) clamp(22px, 1.35vw, 28px);
  border: 0;
  border-radius: 10px;
  background: var(--deck-fill);
  color: var(--deck-fill-ink);
  font-family: var(--font);
  font-size: clamp(15px, 0.95vw, 18px);
  font-weight: 700;
  cursor: pointer;
}
.deck-watch svg { width: 1.33em; height: 1.33em; display: block; }
.deck-watch:hover { filter: brightness(1.06); }

/* The three blocks that swap. Staggered so the change reads as one
   move rather than three. */
.deck-live .an { animation: deck-in 0.5s cubic-bezier(0.2, 0.85, 0.25, 1) both; }
.deck-live .an:nth-child(2) { animation-delay: 0.05s; }
.deck-live .an:nth-child(3) { animation-delay: 0.1s; }
.deckbar-lead.swap .an { animation: deck-out 0.19s ease forwards; }
@keyframes deck-in {
  from { opacity: 0; transform: translateY(15px); }
  to { opacity: 1; transform: none; }
}
@keyframes deck-out {
  from { opacity: 1; transform: none; }
  to { opacity: 0; transform: translateY(-9px); }
}

.deck-nav {
  grid-area: nav;
  display: flex;
  gap: 10px;
  align-items: center;
  /* Same width and centring as the stage, so the dots line up with the
     left edge of the pile and the arrows with its right edge rather than
     floating somewhere inside a wider track. */
  width: 100%;
  max-width: calc(62vh * 16 / 9);
  justify-self: end;
}
.deck-dots { display: flex; gap: 6px; margin-right: auto; }
.deck-dots i {
  width: 20px;
  height: 4px;
  border-radius: 99px;
  background: var(--line-strong);
  transition: background 0.3s ease;
}
.deck-dots i.on { background: var(--deck-accent); }

.deck-arrow {
  width: 46px;
  height: 46px;
  border-radius: 50%;
  border: 1.5px solid var(--line-strong);
  background: var(--surface);
  color: var(--text);
  cursor: pointer;
  display: grid;
  place-items: center;
  transition: background var(--dur-hover) ease, color var(--dur-hover) ease,
    border-color var(--dur-hover) ease;
}
.deck-arrow:hover {
  background: var(--deck-fill);
  color: var(--deck-fill-ink);
  border-color: var(--deck-fill);
}


/* ── the pile ───────────────────────────────────────────────────
   The perspective origin sits ABOVE the stack on purpose: that is
   what makes it read as a pile on a table seen from slightly above,
   rather than as a carousel. Do not "fix" it to 50% 50%. */
/* No fixed height: the box IS the front card, 16:9, filling the track.
   It deliberately reserves NO headroom for the two cards behind it. They
   lift about 12% of the card's height above the top edge and are simply
   allowed to overflow — the banner sits under a page head whose content
   is all in the left column, so the space beside "Home" is empty, and a
   pile that reaches up into it is using room the page already had rather
   than asking the layout for more (owner's call, 2026-08-24). Reserving
   the headroom instead cost 39px of dead space BELOW the card as well,
   because the deck centres in its box. */
.deck-stage {
  grid-area: stage;
  position: relative;
  aspect-ratio: 16 / 9;
  /* "As big as possible" has to stop somewhere, and the limit is the
     fold, not the column: on a very wide monitor the track alone would
     make the banner 900px tall and push every shelf off the screen. The
     cap is on the stage's WIDTH because the card is width: 100% of it and
     takes its height from 16:9 — capping the height here would leave the
     card overflowing a shorter box. 62vh rather than the full height
     because the pile stands roughly 12% of a card above its own top edge.
     Centred in the track, so what it gives up is shared evenly rather
     than pulling the pile off-axis.

     `width: 100%` is load-bearing next to justify-self. Without
     it, a centred (or auto-margined) grid item is sized to fit-content —
     and every card in here is absolutely positioned, so fit-content is
     zero and the stage collapses to nothing. */
  width: 100%;
  max-width: calc(62vh * 16 / 9);
  /* end, not center. On a short wide window the 62vh cap makes the stage
     narrower than its track, and centring it would float the pile away
     from the right edge every shelf below lines up on. */
  justify-self: end;
  perspective: 1200px;
  perspective-origin: 50% -18%;
}
.deck-stage:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 6px;
  border-radius: var(--radius);
}
.deck {
  position: absolute;
  inset: 0;
  transform-style: preserve-3d;
  display: grid;
  place-items: center;
}

.deck-card {
  position: absolute;
  width: 100%;
  padding: 0;
  border-radius: 16px;
  overflow: hidden;
  background: var(--surface);
  border: 1px solid var(--deck-line);
  box-shadow: var(--deck-shadow-0);
  color: inherit;
  font: inherit;
  text-align: left;
  will-change: transform, opacity;
  transform-origin: 50% 50%;
  touch-action: pan-y;
  transition: transform 0.58s cubic-bezier(0.22, 1.02, 0.36, 1), opacity 0.4s ease,
    filter 0.55s ease, box-shadow 0.55s ease;
}
.deck-card .thumb {
  border-radius: 0;
  background: var(--surface-2);
  /* The card carries its own border; a ring inside it would draw a
     second line one pixel away from the first. */
  box-shadow: none;
}
.deck-card:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 3px;
}

/* Resting positions. The alternating rotations and the uneven gaps are
   the point: evenly spaced cards read as a diagram, uneven ones read as
   objects that were put down by a hand.

   The lifts are percentages, not the mockup's pixels. A translateY
   percentage resolves against the element's OWN height, so -8.9% is the
   mockup's -26px on the 292px card it was tuned against and stays that
   proportion on a card three times the size. Pixels would have collapsed
   the pile into a stack of near-coincident edges on a wide window. */
.deck-card[data-pos="0"] {
  transform: translate3d(0, 0, 0) rotate(-0.8deg) scale(1);
  z-index: 3;
  opacity: 1;
  filter: none;
  cursor: pointer;
}
.deck-card[data-pos="1"] {
  transform: translate3d(0, -8.9%, 0) rotate(2.6deg) scale(0.935);
  z-index: 2;
  filter: var(--deck-dim-1);
  box-shadow: var(--deck-shadow-1);
}
.deck-card[data-pos="2"] {
  transform: translate3d(0, -15.4%, 0) rotate(-3.4deg) scale(0.878);
  z-index: 1;
  filter: var(--deck-dim-2);
  box-shadow: var(--deck-shadow-2);
}
.deck-card[data-pos="0"]:hover {
  transform: translate3d(0, 4px, 0) rotate(-0.8deg) scale(1.012);
}

/* Only three cards are ever visible. The rest wait behind the pile with
   no artwork requested at all — a deeper deck that renders every card
   pays for paint and pixels nobody can see. */
.deck-card.tuck,
.deck-card:not([data-pos="0"]):not([data-pos="1"]):not([data-pos="2"]) {
  transform: translate3d(0, -21.2%, 0) rotate(-5deg) scale(0.8);
  opacity: 0;
  z-index: 0;
  filter: var(--deck-dim-3);
  pointer-events: none;
}
.deck-card.no-transition { transition: none !important; }
/* The card being dealt away. Invisible, but still at data-pos="0" and
   pushed toward the viewer in 3D, so without this it lies across the
   card rising underneath it and takes the taps meant for it. */
.deck-card.dealing { pointer-events: none; }
.deck-card.dragging { cursor: grabbing; box-shadow: var(--deck-shadow-drag); }

.deck-depth {
  position: absolute;
  inset: 0;
  pointer-events: none;
  z-index: 4;
  background: var(--deck-veil);
  opacity: 0;
  transition: opacity 0.5s ease;
}
.deck-card[data-pos="1"] .deck-depth,
.deck-card[data-pos="2"] .deck-depth { opacity: 1; }

/* Only the front card offers to play. */
.deck-fab {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  width: 66px;
  height: 66px;
  z-index: 5;
  display: grid;
  place-items: center;
  border-radius: 50%;
  background: var(--deck-fill);
  color: var(--deck-fill-ink);
  /* The halo was hardcoded Eon Yellow, which put a yellow ring around an
     orange button the moment light mode took the brand orange. It follows
     the fill now. */
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5), 0 0 0 6px var(--deck-halo);
  opacity: 0;
  transition: opacity 0.4s ease 0.1s;
}
.deck-fab svg { width: 26px; height: 26px; display: block; }
.deck-card[data-pos="0"] .deck-fab { opacity: 1; }

.deck-new {
  position: absolute;
  left: 12px;
  top: 12px;
  z-index: 5;
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 4px 8px;
  border-radius: 5px;
  background: var(--deck-fill);
  color: var(--deck-fill-ink);
  opacity: 0;
  transition: opacity 0.3s ease;
}
.deck-card[data-pos="0"] .deck-new { opacity: 1; }

/* A locked card already carries the big centred lock (join.css). A play
   button in the same spot would be two answers to one press, so the fab
   stands down and the lock keeps the middle of the frame. The preview
   chip is top-left and needs no help — a level-1 video is never flagged
   new, so it can never collide with the New tag. */
.deck-card .thumb.locked ~ .deck-fab { display: none; }
.deck-card .dur { right: 10px; bottom: 10px; }

/* ── reduced motion ─────────────────────────────────────────────
   No arc, no throw, no auto-advance (deck.js refuses to start the
   timer). Cards cross-fade between their resting positions, and the
   headline swaps on opacity alone. */
@media (prefers-reduced-motion: reduce) {
  .deck-card {
    transition: opacity 0.25s ease;
  }
  .deck-card[data-pos="0"]:hover { transform: translate3d(0, 0, 0) rotate(-0.8deg) scale(1); }
  .deck-live .an { animation: deck-fade 0.3s ease both; }
  .deckbar-lead.swap .an { animation: deck-fade-out 0.15s ease forwards; }
  @keyframes deck-fade { from { opacity: 0; } to { opacity: 1; } }
  @keyframes deck-fade-out { from { opacity: 1; } to { opacity: 0; } }
}

/* ── narrow ─────────────────────────────────────────────────────
   One column, deck FIRST and the text under it: on a phone the
   picture is the thing that earns the scroll. */
@media (max-width: 900px) {
  .deckbar {
    grid-template-columns: 1fr;
    /* Pile, then its title, then its controls. The nav goes last rather
       than following the stage: the title is what the picture is OF, and
       putting two buttons between them separates a caption from the
       thing it captions. */
    grid-template-areas: "stage" "lead" "nav";
    column-gap: 0;
    row-gap: 2px;
    padding: 4px 0 18px;
  }
  .deck-stage {
    /* Was a flat 242px. The card is wider here now, so a fixed height
       clipped the top of the pile — the stage has to be derived from the
       card at every width, exactly as it is on desktop.

       But it KEEPS the headroom desktop gives up: the page head runs the
       full width on a phone, so a pile standing above its own box would
       reach into "Home / 161 videos" rather than into empty space. */
    aspect-ratio: 16 / 11;
    perspective: 900px;
    perspective-origin: 50% -14%;
  }
  .deck-nav { max-width: none; }
  /* Was 300px. 94% rather than the full width: the cards behind the front
     one are rotated up to 4.5deg, and their corners need somewhere to go
     that is not under .deckbar's horizontal clip. */
  .deck-card { max-width: 94%; }
  .deck-card[data-pos="1"] { transform: translate3d(0, -10.6%, 0) rotate(2.4deg) scale(0.93); }
  .deck-card[data-pos="2"] { transform: translate3d(0, -18.9%, 0) rotate(-3deg) scale(0.872); }
  .deck-card.tuck,
  .deck-card:not([data-pos="0"]):not([data-pos="1"]):not([data-pos="2"]) {
    transform: translate3d(0, -26%, 0) rotate(-4.5deg) scale(0.8);
  }
  .deck-fab { width: 56px; height: 56px; }
  .deck-fab svg { width: 22px; height: 22px; }
  .deck-title { font-size: 22px; margin-top: 12px; min-height: 2.2em; }
  .deck-meta { margin-top: 10px; }
  /* No Watch button on a phone (owner's call, 2026-08-24). The card IS
     the button — it is a real <button> carrying the video's name, and it
     wears a 56px play glyph in the middle of the artwork — so a second
     control underneath was a duplicate that cost a screen-reader user an
     extra stop and everyone else 60px of the fold. Removed with
     `display: none` rather than by not rendering it: deck.js keeps one
     DOM shape at every width, and the swap animation's nth-child stagger
     counts the block it lives in either way. */
  .deck-watch { display: none; }
  .deck-nav { margin-top: 14px; }
}

/* ============================================================
   Shelves — bold title row, then a horizontal slider of cards
   ============================================================ */

.section {
  margin-bottom: 24px;
  padding-top: 16px;
  border-top: 1px solid var(--line);
}

.section-head {
  display: flex;
  align-items: center;
  gap: 12px;
  margin-bottom: 12px;
  padding-bottom: 0;
  border-bottom: none;
}

.section-title {
  font-family: var(--font);
  font-size: 20px;
  font-weight: 700;
  letter-spacing: 0;
}

/* Shelf titles don't carry counts on YouTube. */
.section-count { display: none; }

.view-all {
  margin-left: auto;
  display: flex;
  align-items: center;
  min-height: 44px;
  background: none;
  border: 1px solid var(--line-strong);
  color: var(--text);
  font-size: 14px;
  font-weight: 500;
  cursor: pointer;
  padding: 6px 15px;
  border-radius: 18px;
  transition: background var(--dur-hover) ease;
}
.view-all:hover { background: var(--surface); }

/* Category/search grids: the Videos-tab layout. */
.grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(310px, 1fr));
  gap: 40px 16px;
  align-items: start;
}

/* Home shelves ride sideways at every width — one row, snap
   scrolling, scrollbar hidden; the half-visible next card is
   the affordance. */
.section > .grid {
  grid-template-columns: none;
  grid-auto-flow: column;
  grid-auto-columns: 320px;
  gap: 8px;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scroll-behavior: smooth;
  padding-bottom: 8px;
  scrollbar-width: none;
}
.section > .grid::-webkit-scrollbar { display: none; }
.section > .grid .card { scroll-snap-align: start; }

.empty {
  padding: 72px 20px;
  text-align: center;
  color: var(--text-2);
  font-size: 15px;
}

/* The second line of an empty state carries the explanation; the first
   carries the fact. Dimmer, and it wraps to a readable measure. */
.empty-sub {
  max-width: 42ch;
  margin: 10px auto 0;
  font-size: 15px;
  line-height: 1.5;
  color: var(--text-3);
}

.empty-action {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: 40px;
  margin-top: 18px;
  padding: 9px 18px;
  font-size: 14px;
  font-weight: 500;
  color: var(--text);
  background: var(--surface);
  border: none;
  border-radius: 20px;
  cursor: pointer;
  transition: background var(--dur-hover) ease;
}
.empty-action:hover { background: var(--surface-2); }

/* ── boot skeleton ───────────────────────────────────────────
   Stands in for the page head, the deck and the first two shelves while
   data-live.js fetches the catalog. Every box copies the exact
   dimensions of the element that replaces it — .page-head, .deckbar's
   .92fr/1.08fr split, the 320px shelf cards — so real content paints ON
   the skeleton, not past it. Shapes only — it must never imply
   content that might not arrive, so no text and no counts.
   Cleared by the first `main.innerHTML = ""` in any render path. */
.skel b,
.skel i,
.skel .skel-ic,
.skel .skel-thumb {
  display: block;
  background: var(--surface);
  border-radius: var(--radius-sm);
}

/* .page-head: 44px round icon, 24px title, meta line. */
.skel-head {
  display: flex;
  align-items: center;
  gap: 14px;
  margin-bottom: 24px;
}
.skel .skel-ic {
  width: 44px;
  height: 44px;
  border-radius: 50%;
  flex-shrink: 0;
}
.skel-headlines {
  display: flex;
  flex-direction: column;
  gap: 9px;
  flex: 1;
  min-width: 0;
}
.skel-headlines b:first-child { width: min(34%, 150px); height: 22px; }
.skel-headlines b:last-child { width: 84px; height: 13px; }

/* .deckbar, shape for shape: headline column left, the pile right, the
   pile's controls under it. Same .76fr/1.24fr split, the same 38px
   column gap, the same 16:9 stage flush to the content edge, and title
   bars driven by the same clamp the real headline uses — so the deck
   paints ON this instead of past it at every window width.

   Only ONE card is drawn. The two behind it are dim, tilted and stand
   above the box, and a skeleton implying a pile would be promising a
   shape before the data that decides how many cards there are has
   arrived. --sk-lines mirrors .deck-title's min-height so the block
   reserves exactly the height the real headline will claim. */
.skel-deck {
  --sk-title: clamp(26px, 2.4vw, 56px);
  --sk-lines: 3.15;
  --sk-btn-fs: clamp(15px, 0.95vw, 18px);
  --sk-btn-pad: clamp(14px, 0.85vw, 17px);
  display: grid;
  grid-template-columns: minmax(0, 0.76fr) minmax(0, 1.24fr);
  grid-template-areas:
    "lead stage"
    "lead nav";
  column-gap: 38px;
  row-gap: 16px;
  align-items: center;
  padding: 8px 0 26px;
  margin-bottom: 8px;
}
.skel-deck-lead {
  grid-area: lead;
  align-self: center;
  display: flex;
  flex-direction: column;
  min-width: 0;
}
.skel-eyebrow { width: 118px; height: 11px; }
.skel-h1 {
  width: 92%;
  max-width: calc(var(--sk-title) * 12);
  height: calc(var(--sk-title) * 0.74);
  margin-top: 16px;
}
.skel-h2 {
  width: 60%;
  max-width: calc(var(--sk-title) * 8);
  height: calc(var(--sk-title) * 0.74);
  margin-top: calc(var(--sk-title) * 0.3);
  /* what is left of the headline's reserved block after two bars */
  margin-bottom: calc(var(--sk-title) * (var(--sk-lines) - 1.78));
}
.skel-metaline { width: 190px; height: 12px; margin-top: 14px; }
/* Written .skel-prefixed on purpose. The shared `.skel b, .skel i, …`
   rule above is (0,1,1) and sets display AND border-radius, so a bare
   .skel-btn (0,1,0) loses to it — which is how these ended up square,
   and how `display: none` on a phone did nothing at all. */
.skel .skel-btn {
  width: calc(var(--sk-btn-fs) * 9.5);
  height: calc(var(--sk-btn-fs) * 1.35 + var(--sk-btn-pad) * 2);
  border-radius: 10px;
  margin-top: 24px;
}
.skel-deck-stage {
  grid-area: stage;
  width: 100%;
  max-width: calc(62vh * 16 / 9);
  justify-self: end;
}
.skel-deck-nav {
  grid-area: nav;
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  max-width: calc(62vh * 16 / 9);
  justify-self: end;
}
.skel .skel-dots { width: 124px; height: 4px; border-radius: 99px; margin-right: auto; }
.skel .skel-arrow { width: 46px; height: 46px; border-radius: 50%; }
/* Specificity, not order: the shared .skel .skel-thumb rule below sets
   the card radius and would otherwise win on source order. */
.skel .skel-deck-stage .skel-thumb { border-radius: 16px; }

/* One card shape everywhere: 16:9 thumb, two title lines, meta line —
   the same three-part stack .card renders. */
.skel .skel-thumb {
  width: 100%;
  aspect-ratio: 16 / 9;
  border-radius: var(--radius);
}
.skel-card b { height: 13px; margin-top: 9px; }
.skel-card b:nth-of-type(1) { width: 92%; margin-top: 12px; }
.skel-card b:nth-of-type(2) { width: 62%; }
.skel-card i { width: 42%; height: 11px; margin-top: 8px; }

/* .section: hairline top, 20px title, then the sideways shelf of
   320px cards. overflow hidden — a skeleton never scrolls. */
.skel-sec {
  margin-bottom: 24px;
  padding-top: 16px;
  border-top: 1px solid var(--line);
}
.skel-shelf {
  width: min(38%, 180px);
  height: 19px;
  margin-bottom: 15px;
}
.skel-row {
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: 320px;
  gap: 8px;
  overflow: hidden;
  padding-bottom: 8px;
}

/* Arrived on a sidebar tab (html.skel-cat, read off location.hash in
   the head): that page is a head plus a vertical .grid — no deck, no
   shelves — so the skeleton wears the grid shape instead. */
.skel-grid { display: none; }
.skel-cat .skel-deck,
.skel-cat .skel-sec { display: none; }
.skel-cat .skel-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(310px, 1fr));
  gap: 40px 16px;
  align-items: start;
}

/* The shimmer is the only thing that says "still working" rather than
   "broken". It rides opacity, so it never triggers layout. */
@media (prefers-reduced-motion: no-preference) {
  .skel b,
  .skel i,
  .skel .skel-ic,
  .skel .skel-thumb {
    animation: skel-pulse 1.6s var(--ease-move) infinite;
  }
}
@keyframes skel-pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.55; }
}

/* Same thresholds as the real layout. ≤900: the deck goes to one
   column with the pile on top, shelf cards shrink with the viewport,
   and the head compacts like .page-ic. */
@media (max-width: 900px) {
  /* Pile, title, controls — the real banner's order, and its numbers:
     22px headline reserving 2.2em, a card at 94% of the stage, and no
     Watch button at all. */
  .skel-deck {
    --sk-title: 22px;
    --sk-lines: 2.2;
    grid-template-columns: 1fr;
    grid-template-areas: "stage" "lead" "nav";
    column-gap: 0;
    row-gap: 2px;
    padding: 4px 0 18px;
  }
  .skel-deck-stage { max-width: none; justify-self: stretch; }
  .skel .skel-deck-stage .skel-thumb { max-width: 94%; margin-inline: auto; }
  .skel-deck-nav { max-width: none; margin-top: 14px; }
  .skel-metaline { margin-top: 10px; }
  .skel .skel-btn { display: none; }

  .skel-ic { width: 36px; height: 36px; }
  .skel-headlines b:first-child { height: 18px; }
  .skel-row {
    grid-auto-columns: min(300px, 74vw);
    margin: 0 -16px;
    padding: 4px 16px 10px;
  }
  .skel-cat .skel-grid {
    grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
    gap: 32px 16px;
  }
}
/* ≤520: the category grid bleeds edge-to-edge with square artwork and
   16px text insets — the phone feed shape. Shelf tiles stay rounded,
   and so does the deck: a tilted card cannot run to the edge. */
@media (max-width: 520px) {

  .skel-cat .skel-grid {
    grid-template-columns: 1fr;
    gap: 24px;
    margin: 0 -16px;
  }
  .skel-cat .skel-grid .skel-thumb { border-radius: 0; }
  .skel-cat .skel-grid .skel-card b,
  .skel-cat .skel-grid .skel-card i { margin-left: 16px; }
}

/* ── learning-path locks (members) ──
   A path playlist outside the member's grant: a small lock rides the
   nav row's right edge, and the page itself is a dead-end panel — no
   join overlay, no unlock affordance at all. */
.nav-item .nav-lock {
  margin-left: auto;
  font-size: 21px;
  color: var(--text-2);
}
.nav-item.nav-path-locked .nav-label { color: var(--text-2); }

.path-locked {
  padding: 72px 20px;
  text-align: center;
  color: var(--text-2);
}
.path-locked .ms {
  font-size: 44px;
  display: block;
  margin-bottom: 14px;
}
.path-locked-title {
  margin: 0 0 6px;
  font-size: 17px;
  font-weight: 600;
  color: var(--text);
}
.path-locked-sub { margin: 0; font-size: 14px; }
.path-locked-cta {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: 44px;
  margin-top: 22px;
  padding: 10px 26px;
  font-family: inherit;
  font-size: 14px;
  font-weight: 700;
  color: #17101c;
  background: #f3c516;
  border: none;
  border-radius: 22px;
  cursor: pointer;
  transition: filter var(--dur-hover) ease;
}
.path-locked-cta:hover { filter: brightness(1.06); }

/* ============================================================
   Video card — flat thumb, two-line title, grey meta. Nothing
   painted on the artwork but the duration chip.
   ============================================================ */

.card {
  position: relative;
  display: block;
  width: 100%;
  padding: 0;
  background: none;
  border: none;
  color: inherit;
  font: inherit;
  text-align: left;
  cursor: pointer;
  border-radius: var(--radius);
}

.thumb {
  position: relative;
  aspect-ratio: 16 / 9;
  border-radius: var(--radius);
  overflow: hidden;
  background: var(--surface);
  /* A hairline, not a shadow: on the light ground a bright thumbnail
     dissolves into the page and the grid loses its edges. No-op on
     dark, where --plate-ring is transparent. */
  box-shadow: 0 0 0 1px var(--plate-ring);
}

.thumb img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  opacity: 1;
}

/* No plates, no rings, no zoom, no hover play glyph. */
.thumb::after,
.upnext-thumb::after { content: none; }
.card:hover .thumb img, .card:focus-visible .thumb img { transform: none; }
.play-hint { display: none; }

.dur {
  position: absolute;
  right: 8px; bottom: 8px;
  z-index: 2;
  font-family: var(--font);
  font-size: var(--fs-chip);
  font-weight: 600;
  color: #fff;
  background: rgba(0, 0, 0, 0.8);
  padding: 3px 5px;
  border-radius: var(--radius-sm);
}

/* Watched — the finished-video pill, duration's opposite corner. Same
   98% line that clears Continue watching. Glass rather than the solid
   duration chip: a check + blur-backed pill with a hairline reads as a
   status laid OVER the artwork, where the duration reads as part of it.
   The blur needs no fallback beyond its own background — at 0.55 black
   the text is readable on any frame even where backdrop-filter isn't. */
.watched-tag {
  position: absolute;
  left: 8px; bottom: 8px;
  z-index: 2;
  display: inline-flex;
  align-items: center;
  gap: 5px;
  font-family: var(--font);
  font-size: var(--fs-chip);
  font-weight: 600;
  letter-spacing: 0.2px;
  color: #fff;
  background: rgba(0, 0, 0, 0.55);
  -webkit-backdrop-filter: blur(8px) saturate(1.4);
  backdrop-filter: blur(8px) saturate(1.4);
  border: 1px solid rgba(255, 255, 255, 0.18);
  padding: 3px 9px 3px 7px;
  border-radius: 999px;
}
.watched-tag::before {
  content: "";
  width: 12px; height: 12px;
  background: currentColor;
  -webkit-mask: var(--ic-check) center / contain no-repeat;
  mask: var(--ic-check) center / contain no-repeat;
}
.upnext-thumb .watched-tag { left: 4px; bottom: 4px; padding: 2px 7px 2px 5px; gap: 4px; }

/* Platform logos come off the artwork too. */
.src-badge { display: none; }

.card-meta { padding: 12px 4px 0; }

.card-title {
  font-size: var(--fs-title);
  font-weight: 500;
  line-height: 1.4;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

/* Meta line reads "Category · date", YouTube's byline shape. */
.card-sub {
  margin-top: 4px;
  font-size: var(--fs-meta);
  color: var(--text-2);
  display: flex;
  align-items: center;
  gap: 4px;
}

.card-sub .cat-link,
.card-sub .cat-link + span { display: inline; }

/* ============================================================
   Watch — a page, not a glass dialog: solid ground, flat player,
   bold title, chip row, compact up-next rail.
   ============================================================ */

/* Starts BELOW the topbar rather than over it, so the bar — brand,
   search, account — stays reachable while a video is open. It used to
   cover the whole viewport, which left a lone Close button as the only
   way back and hid the fact you were still inside the library.

   It still spans the full width, so the sidebar underneath is covered:
   no menu, no icon rail, nothing competing with the video. */
.watch {
  position: fixed;
  top: var(--topbar-h);
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 100;
  display: none;
  background: var(--watch-bg);
  overflow-y: auto;
}
.watch.open { display: block; }

/* While a video is open the rail becomes a drawer OVER it, at every
   width. Two reasons it cannot just stay where it is: the overlay sits
   at z-index 100 and the rail at 40, so it opened behind the video and
   looked like a dead button; and on desktop the burger means "collapse
   to a 72px icon rail", which needs content beside it to make any
   sense — there is none while watching.

   So: hidden by default (the video gets the screen), and when the burger
   is pressed it slides in above everything with a scrim over the player.
   app.js drops .open before opening a video, so none of the desktop
   minimise rules apply in here. */
/* While a video is open the player overlay (z 100) outranks the topbar
   (z 50). The bar itself never overlaps it — the overlay starts below —
   but the search SUGGESTION SHEET hangs down into overlay territory,
   and from inside the topbar's stacking context it rendered under the
   player: type at /watch and no suggestions appeared. Raised only while
   watching, and only to 120, so the drawer (130) and its scrim (125)
   still cover the bar. */
body.eon-watching .topbar { z-index: 120; }

body.eon-watching .sidebar {
  display: block;
  top: var(--topbar-h);
  bottom: 0;
  width: var(--sidebar-w);
  padding: 12px;
  z-index: 130;
  transform: translateX(-100%);
  transition: transform var(--dur-panel) var(--ease-move);
  box-shadow: var(--panel-shadow);
  background: var(--bg);
}
body.eon-watching.eon-menu .sidebar { transform: none; }

/* The scrim is the point: without it the menu floats over a playing
   video with nothing saying the video is no longer what you are
   pointing at, and a press meant for the menu lands on the player. */
body.eon-watching .sidebar-backdrop.show {
  display: block;
  z-index: 125;
  top: var(--topbar-h);
}

/* The overlay is a viewport, not a page.
   It used to scroll: the player sat in a 16/9 box at column width and
   the up-next list ran on underneath it, so on a laptop you scrolled the
   video off the top to see what was next. Now the overlay owns the
   screen height, the player takes as much of it as it can, and only the
   rail beside it scrolls. The video never leaves the screen. */
.watch { overflow: hidden; }

.watch-inner {
  /* One source of truth for the rail's width: the rail is laid out from
     it and the player's width budget is computed from it. A fixed 400px
     was costing the video ~100px of width on a laptop, and with a side
     rail the width is what limits the picture long before the height
     does. */
  --rail-px: clamp(280px, 22vw, 400px);
  max-width: 1800px;
  height: calc(100dvh - var(--topbar-h));
  margin: 0 auto;
  padding: 46px 24px 20px;
  display: flex;
  gap: 24px;
  align-items: stretch;
  min-height: 0;
}

/* Below the topbar now, not over it — the bar owns that strip again, and
   Close sitting on top of the account menu was the first thing to break
   when the overlay stopped covering it. */
.watch-close {
  position: fixed;
  top: calc(var(--topbar-h) + 8px);
  right: 16px;
  z-index: 110;
  display: flex;
  align-items: center;
  gap: 8px;
  min-height: 44px;
  padding: 8px 15px;
  font-size: 14px;
  font-weight: 500;
  color: var(--text);
  background: var(--surface);
  border: none;
  border-radius: 20px;
  cursor: pointer;
  transition: background var(--dur-hover) ease;
}
.watch-close:hover { background: var(--surface-2); }

/* Both dimensions are computed here, from one height budget, and handed
   to the player and the caption block so they stay the same width.

   Doing it with aspect-ratio + width:auto did not survive contact with
   flexbox: this is a column, so align-items defaults to stretch, the
   player was forced to the full column width, and an element with an
   explicit height AND a stretched width ignores aspect-ratio entirely.
   The box came out 1290x859 with the video letterboxed in a band through
   the middle and the pause bands sized off the empty box. align-items is
   centre now and the width is arithmetic rather than a hint.

     --watch-chrome  title + meta block + the overlay's own padding
     --rail-w        rail + the gap beside it
     --pad           the overlay's horizontal padding
   min(100vw, 1800px) because .watch-inner is capped at 1800. */
.watch-main {
  --watch-chrome: 136px;
  --rail-w: calc(var(--rail-px, 400px) + 24px);   /* rail + the gap */
  --pad: 48px;
  /* The topbar is no longer covered, so its height comes off the budget
     as well as the chrome below the video. */
  --vid-h: min(
    calc(100dvh - var(--topbar-h) - var(--watch-chrome)),
    calc((min(100vw, 1800px) - var(--rail-w) - var(--pad)) * 9 / 16)
  );
  --vid-w: calc(var(--vid-h) * 16 / 9);
  flex: 1;
  min-width: 0;
  min-height: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
}

/* As large as the screen allows, without fullscreen.
   Driven by HEIGHT, not by the column width left over after the rail.
   Sizing it by width meant a 1512px laptop gave the player ~1040px and
   therefore 585px of height, leaving 200px of screen unused underneath —
   the video was as big as its column allowed, not as big as the screen
   allowed, which is not the same thing and was the complaint.

   So: take the full height available, unless the width needed for 16/9
   at that height would not fit beside the rail, in which case the width
   decides instead. Whichever runs out first wins, and any width left
   over becomes margin either side rather than a shorter video.
     --watch-chrome  title + meta block + the overlay's own padding
     --rail-space    rail + gap + page padding */
.watch-main .player {
  height: var(--vid-h);
  width: var(--vid-w);
  max-width: 100%;
  flex: 0 0 auto;
  border-radius: var(--radius);
  overflow: hidden;
  background: #000;
  border: none;
  box-shadow: none;
}

.player iframe { width: 100%; height: 100%; border: 0; display: block; }

/* Same width as the picture, so the title lines up with its left edge
   instead of with the column the player is centred in. */
.watch-info {
  padding: 12px 0 0;
  flex: 0 0 auto;
  width: var(--vid-w);
  max-width: 100%;
}

.watch-title {
  font-family: var(--font);
  font-size: 20px;
  font-weight: 700;
  letter-spacing: 0;
  line-height: 1.4;
}

.watch-meta {
  margin-top: 8px;
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 8px;
  color: var(--text-2);
  font-size: var(--fs-meta);
}

/* Category chips: the grey rounded chip row under the title. */
.watch-meta .pill {
  display: inline-flex;
  align-items: center;
  min-height: 32px;
  padding: 5px 12px;
  background: var(--surface);
  border: none;
  border-radius: 8px;
  font-size: 14px;
  font-weight: 500;
  color: var(--text);
  cursor: pointer;
  transition: background var(--dur-hover) ease;
}
.watch-meta .pill:hover { background: var(--surface-2); }

/* External link: YouTube link blue, no underline. */
.watch-meta .ext-link {
  display: inline-flex;
  align-items: center;
  min-height: 32px;
  color: var(--accent);
  font-size: 14px;
  font-weight: 500;
  text-decoration: none;
}
.watch-meta .ext-link:hover { text-decoration: underline; text-underline-offset: 3px; }

/* Up next rail */
/* The rail is the only thing that scrolls. */
.upnext {
  width: var(--rail-px, 400px);
  flex-shrink: 0;
  display: flex;
  flex-direction: column;
  min-height: 0;
}
.upnext-list {
  overflow-y: auto;
  min-height: 0;
  flex: 1;
  scrollbar-width: thin;
  padding-right: 4px;
}

.upnext h3 {
  font-family: var(--font);
  font-size: 16px;
  font-weight: 600;
  letter-spacing: 0;
  text-transform: none;
  color: var(--text);
  margin-bottom: 8px;
  padding-bottom: 0;
  border-bottom: none;
}

.upnext-item {
  display: flex;
  gap: 8px;
  width: calc(100% + 8px);
  min-height: 44px;
  padding: 4px;
  margin: 0 -4px;
  background: none;
  border: none;
  color: inherit;
  font: inherit;
  text-align: left;
  border-radius: 8px;
  cursor: pointer;
  transition: background var(--dur-hover) ease;
}
.upnext-item:hover { background: var(--surface); }
.upnext-item.now { background: var(--surface); cursor: default; }

.upnext-thumb {
  position: relative;
  width: 168px;
  flex-shrink: 0;
  aspect-ratio: 16 / 9;
  border-radius: 8px;
  overflow: hidden;
  background: var(--surface);
  box-shadow: 0 0 0 1px var(--plate-ring);
}
.upnext-thumb img { width: 100%; height: 100%; object-fit: cover; }
.upnext-thumb .dur { right: 4px; bottom: 4px; font-size: var(--fs-chip); padding: 2px 4px; }

.upnext-meta { min-width: 0; padding-top: 0; }

.upnext-title {
  font-size: var(--fs-meta);
  font-weight: 500;
  line-height: 1.4;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.upnext-sub { margin-top: 3px; font-size: var(--fs-chip); color: var(--text-2); }

.now-tag {
  font-size: var(--fs-chip);
  font-weight: 500;
  letter-spacing: 0.02em;
  color: var(--text-2);
}

/* ============================================================
   Responsive
   ============================================================ */

/* Below the two-column threshold the rail goes UNDER the video and turns
   sideways. Stacking it vertically here was the bug: on a phone the list
   pushed the player off the top the moment you looked at it. A single
   horizontal row keeps the video fixed in place and still shows what is
   next — the same trade the home shelves already make. */
@media (max-width: 1160px) {
  /* With the rail underneath rather than beside, the column can add up
     to more than the viewport — a long title, a tall rail, a short
     phone. Locking the overlay to exactly one screen made that content
     unreachable: the page simply would not scroll.

     min-height, not height, and scrolling allowed. The player is still
     sized to fit a screen on its own, so nothing scrolls in the ordinary
     case and the video does not wander off the top; it is only there
     when the content genuinely exceeds the room. */
  .watch { overflow-y: auto; }
  .watch-inner {
    flex-direction: column;
    height: auto;
    min-height: calc(100dvh - var(--topbar-h));
    padding: 46px 16px 12px;
    gap: 12px;
  }
  /* Rail and info block sit below now, not beside, so nothing competes
     for width — only the height budget changes. */
  .watch-main {
    flex: 0 0 auto;
    --watch-chrome: 274px;
    --rail-w: 0px;
    --pad: 32px;
  }
  .upnext {
    width: 100%;
    flex: 1 1 auto;
    min-height: 0;
  }
  .upnext h3 { margin-bottom: 6px; }

  .upnext-list {
    display: flex;
    flex-direction: row;
    gap: 12px;
    overflow-x: auto;
    overflow-y: hidden;
    scroll-snap-type: x proximity;
    -webkit-overflow-scrolling: touch;
    padding-bottom: 6px;
  }
  /* Each row becomes a card: picture on top, title under it. */
  .upnext-item {
    flex: 0 0 190px;
    width: 190px;
    flex-direction: column;
    gap: 6px;
    margin: 0;
    padding: 0;
    scroll-snap-align: start;
  }
  .upnext-thumb { width: 100%; height: auto; aspect-ratio: 16 / 9; }
  .upnext-title { white-space: normal; }
}

@media (max-width: 900px) {
  .section > .grid {
    grid-auto-columns: min(300px, 74vw);
    margin: 0 -16px;
    padding: 4px 16px 10px;
    scroll-padding-left: 16px;
  }

  .sidebar {
    transform: translateX(-100%);
    transition: transform var(--dur-panel) var(--ease-move);
    z-index: 60;
    top: 0;
    padding-top: 12px;
    width: 260px;
    box-shadow: var(--panel-shadow);
  }
  .sidebar.open { transform: none; }
  .sidebar-backdrop.show { display: block; z-index: 55; }
  .main { margin-left: 0; padding-left: 16px; padding-right: 16px; }
  .brand span { display: none; }
  .search-wrap { flex-basis: 420px; margin: 0 8px; }
  .watch-inner { padding: 70px 16px 40px; }
  .grid { grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 32px 16px; }
  .page-ic { width: 36px; height: 36px; font-size: 20px; }
  .page-ic-art { width: 76px; height: 76px; }
  .page-title { font-size: 20px; }
}

/* ── Phone masthead — two rows ──────────────────────────────
   Row 1: burger, the EON mark, the right-hand cluster.
   Row 2: the search field, full width.

   Search was `display: none` here (owner's call 2026-08-13, "browsing
   is the shelves and the guide"). Measured at 360, 420 and 560px it
   rendered 0x0 and only appeared at 700px — so against 153 videos, a
   phone-first audience had no way to reach a half-remembered episode.
   It returns as its own row rather than as a collapsed magnifier: no
   gesture, no icon to decode, and a full-width 44px target.

   The extra height rides --search-h, NOT --topbar-h. --topbar-h also
   positions the watch modal, the guide and the up-next rail; growing
   it would have pushed the player down by 52px. */
@media (max-width: 640px) {
  :root { --search-h: 52px; }

  .topbar {
    flex-wrap: wrap;
    align-content: flex-start;
    padding: 0 8px;
    gap: 4px;
  }
  .topbar-left,
  .topbar-right { height: var(--topbar-h); }
  .topbar-right { gap: 2px; }
  /* 2px was cut for a corner of icon-shaped controls, where a stray tap
     lands on a neighbour that does roughly the same thing. Signed out it
     separates two 44px LABELLED buttons that do opposite things — one
     opens the login, the other opens the paywall — and 2px between them
     is a mis-tap waiting to happen at exactly the moment we are asking
     somebody for money. 8px is the platform minimum between adjacent
     targets. Affordable here because the signed-out corner lost the
     blank profile seat: 44px back, 6px spent. */
  body.eon-signedout .topbar-right { gap: 8px; }

  .search-wrap {
    display: flex;
    position: relative;   /* .search-clear is absolute against this */
    order: 3;             /* forced onto row 2 */
    flex: 1 0 100%;
    margin: 0 0 8px;
  }

  /* The mark holds the centre of the FIRST row. The wordmark retires
     again here (it already does between 641 and 900px): at 360px the
     full lockup measured 176px against a 164px safe centre and ran 6px
     under the join button. A legible "Join" label is worth more than
     the word MATRIX, and the mark still carries the brand. */
  .brand {
    position: absolute;
    left: 50%;
    top: calc(var(--topbar-h) / 2);
    transform: translate(-50%, -50%);
    gap: 6px;
    padding: 0;
    max-width: calc(100vw - 200px);
  }
  /* Signed out, the corner is Sign in + Join — two labelled pills where
     a member has one pill and a 44px seat, so it is wider and the
     centred mark has to claim less. A guard, not the fix: the mark's
     natural width is well under this, and what actually prevents the
     collision at the narrowest widths is the rule further down that
     stops centring it at all. */
  body.eon-signedout .brand { max-width: calc(100vw - 220px); }
  .brand span { display: none; }

  .logout-btn { width: 44px; min-height: 44px; padding: 0; justify-content: center; border: none; border-radius: 50%; }
  .logout-btn span { display: none; }
  .logout-btn:hover { background: var(--surface); }
}

/* At the narrowest widths a centred mark and two labelled pills cannot
   both fit, and no max-width solves it: absolute centring pins the mark
   to the middle of the viewport regardless of what is beside it, so at
   360px it ran under the Sign in button. Measured, not guessed — a 66px
   mark centred at 180 reaches x=213, while the pair starts at x=188.
   Centring is what gives way, not the button a returning member is
   hunting for: the mark drops back into the flow next to the burger,
   which is where every layout above 640px already puts it, and flexbox
   then makes an overlap structurally impossible rather than merely
   unlikely. Scoped to signed-out, so the ordinary two-control row keeps
   the centred mark it was designed around. */
@media (max-width: 430px) {
  body.eon-signedout .brand {
    position: static;
    transform: none;
    max-width: none;
  }
}

@media (max-width: 520px) {
  .grid { grid-template-columns: 1fr; gap: 24px; }

  /* The phone feed is edge-to-edge with square artwork — YouTube's
     mobile card. Shelf sliders keep their rounded tiles; it's the
     vertical feeds (category/search grids) that bleed. */
  .main > .grid { margin: 0 -16px; }
  .main > .grid .thumb { border-radius: 0; }
  .main > .grid .card-meta { padding: 12px 16px 0; }

  /* The deck does NOT bleed. A rotated, scaled pile capped at 300px is
     not an edge-to-edge object, and running it to the edges would put
     the corner of a tilted card off-screen. It keeps its own padding
     and the bleed rule stops at the banner. */

  /* The rail is a horizontal flex row on phones, not a grid — a
     single-column grid here would have stacked it vertically again and
     pushed the player off the screen, which is what this change exists
     to stop. Narrower cards instead. */
  .upnext-item { flex-basis: 160px; width: 160px; }
}

@media (prefers-reduced-motion: reduce) {
  * { animation: none !important; transition: none !important; }
  .section > .grid { scroll-behavior: auto; }
}

/* ── Matrix word glitch (wordmark) ───────────────────────────
   Verbatim subset of src/styles/glitch.css (word variant + the loud
   gl-nav flavor) — the matrix pages live outside the Astro bundle, so
   the rules ride in the library sheet. Keep the two files in step.
   Markup: <span class="gl gl-nav" data-text="MATRIX">MATRIX</span> */
.gl {
  position: relative;
  display: inline-block;
  animation: gl-base var(--gl-period, 4.6s) infinite steps(1, end);
  animation-delay: var(--gl-delay, 0s);
}
.gl::before,
.gl::after {
  content: attr(data-text);
  position: absolute;
  inset: 0;
  opacity: 0;
  pointer-events: none;
  user-select: none;
  animation: var(--gl-period, 4.6s) infinite steps(1, end);
  animation-delay: var(--gl-delay, 0s);
}
@supports (content: "x" / "y") {
  .gl::before,
  .gl::after {
    content: attr(data-text) / "";
  }
}
.gl::before { animation-name: gl-a; }
.gl::after  { animation-name: gl-b; }
@keyframes gl-a {
  0%       { opacity: 0.85; color: #c43b89; transform: translate(-0.06em, -0.03em); clip-path: inset(12% 0 58% 0); }
  2%       { color: #c43b89; transform: translate(0.06em, 0.03em);  clip-path: inset(64% 0 8% 0); }
  4%       { color: #f3c516; transform: translate(-0.04em, 0);      clip-path: inset(38% 0 40% 0); }
  6%       { color: #c43b89; transform: translate(0.05em, 0);       clip-path: inset(80% 0 4% 0); }
  8%, 100% { opacity: 0; transform: none; clip-path: inset(50% 0 50% 0); }
}
@keyframes gl-b {
  0%       { opacity: 0.85; color: #793795; transform: translate(0.06em, 0.03em);   clip-path: inset(70% 0 12% 0); }
  2%       { color: #793795; transform: translate(-0.06em, -0.03em); clip-path: inset(10% 0 72% 0); }
  4%       { color: #db8037; transform: translate(0.04em, 0);        clip-path: inset(52% 0 28% 0); }
  6%       { color: #793795; transform: translate(-0.05em, 0);       clip-path: inset(4% 0 82% 0); }
  8%, 100% { opacity: 0; transform: none; clip-path: inset(50% 0 50% 0); }
}
@keyframes gl-base {
  0%       { transform: translateX(0.02em); }
  2%       { transform: translateX(-0.02em); }
  4%       { transform: translateX(0.01em); }
  6%, 100% { transform: none; }
}
.gl-nav::before { animation-name: gl-nav-a; }
.gl-nav::after  { animation-name: gl-nav-b; }
.gl-nav         { animation-name: gl-nav-base; }
/* The wordmark is on screen on every page, so its glitch is the one
   effect that repeats forever. It used to run 0-20% of a 1.9s cycle:
   the product's own name rendered as three offset coloured copies for
   roughly a fifth of the time a member is looking at it. Same five
   flicker steps, remapped onto 0-8% — the duty cycle the login
   headline already uses — and the period below is left at the 4.6s
   default rather than 1.9s, so it reads as an occasional glitch
   instead of a stutter. prefers-reduced-motion still kills it. */
@keyframes gl-nav-a {
  0%        { opacity: 1; color: #c43b89; transform: translate(-0.22em, -0.1em) skewX(-10deg); clip-path: inset(0 0 52% 0); }
  1.6%      { color: #c43b89; transform: translate(0.24em, 0.08em) skewX(8deg);   clip-path: inset(44% 0 0 0); }
  3.2%      { color: #f3c516; transform: translate(-0.16em, 0.05em);              clip-path: inset(18% 0 30% 0); }
  4.8%      { color: #c43b89; transform: translate(0.2em, -0.06em) skewX(-6deg);  clip-path: inset(58% 0 0 0); }
  6.4%      { color: #f3c516; transform: translate(-0.12em, 0);                   clip-path: inset(0 0 66% 0); }
  8%, 100%  { opacity: 0; transform: none; clip-path: inset(50% 0 50% 0); }
}
@keyframes gl-nav-b {
  0%        { opacity: 1; color: #793795; transform: translate(0.22em, 0.1em) skewX(10deg);    clip-path: inset(48% 0 0 0); }
  1.6%      { color: #793795; transform: translate(-0.24em, -0.08em) skewX(-8deg); clip-path: inset(0 0 56% 0); }
  3.2%      { color: #db8037; transform: translate(0.16em, -0.05em);               clip-path: inset(32% 0 20% 0); }
  4.8%      { color: #793795; transform: translate(-0.2em, 0.06em) skewX(6deg);    clip-path: inset(0 0 62% 0); }
  6.4%      { color: #db8037; transform: translate(0.12em, 0);                     clip-path: inset(70% 0 0 0); }
  8%, 100%  { opacity: 0; transform: none; clip-path: inset(50% 0 50% 0); }
}
@keyframes gl-nav-base {
  0%        { transform: translateX(0.08em); }
  1.6%      { transform: translateX(-0.08em); }
  3.2%      { transform: translateX(0.05em) skewX(4deg); }
  4.8%      { transform: translateX(-0.06em); }
  6.4%      { transform: translateX(0.03em); }
  8%, 100%  { transform: none; }
}
@media (prefers-reduced-motion: reduce) {
  .gl,
  .gl::before,
  .gl::after {
    animation: none;
  }
  .gl::before,
  .gl::after {
    display: none;
  }
}

/* ── how far through an episode you are ───────────────────────────────
   A hairline across the foot of the thumbnail, the way every video
   service marks resume position. It sits ON the artwork rather than
   under it, so it costs no layout height and cannot push a grid around.

   --progress is its own token: --cta turns Eon Purple in light mode, and
   a purple bar on cream reads as "selected" rather than "watched". Eon
   Yellow on dark, Eon Orange on light. */
.thumb-progress {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 3px;
  /* The unwatched remainder needs to be visible on both a dark frame and
     a bright one, hence a translucent black rather than a fixed grey. */
  background: rgba(0, 0, 0, 0.55);
  border-bottom-left-radius: inherit;
  border-bottom-right-radius: inherit;
  overflow: hidden;
  pointer-events: none;
}
.thumb-progress-fill {
  height: 100%;
  background: var(--progress);
}
/* Thin bars vanish at a distance; on a phone the cards are smaller and
   this is the only cue that an episode is half-finished. */
@media (max-width: 640px) {
  .thumb-progress { height: 4px; }
}

/* Light theme only, a little thicker (owner's call, 2026-08-24). Eon
   Orange has to carry this bar where Eon Yellow carries it on dark, and
   #db8037 is a darker, lower-contrast mark than #f3c516 against both the
   translucent black track behind it and the bright frames a light-theme
   viewer tends to be looking at. Height is the cheapest way to give it
   back what the hue gives up — and it costs no layout, because the bar
   is absolutely positioned ON the artwork.

   Three blocks, not two, and specificity does the work rather than order:
   these are (0,2,0) against the plain (0,1,0) rules above, so the 640px
   rule cannot take the light value back down. */
@media (prefers-color-scheme: light) {
  :root:not([data-theme="dark"]) .thumb-progress { height: 5px; }
}
:root[data-theme="light"] .thumb-progress { height: 5px; }
@media (max-width: 640px) and (prefers-color-scheme: light) {
  :root:not([data-theme="dark"]) .thumb-progress { height: 6px; }
}
@media (max-width: 640px) {
  :root[data-theme="light"] .thumb-progress { height: 6px; }
}

/* ── viewport fullscreen ───────────────────────────────────────────────
   The fallback for iPhone Safari, which has no working fullscreen API
   for a <div>. player.css carries the full reasoning; the short version
   is that the player cannot escape this overlay with position:fixed —
   WebKit blanks the cross-origin iframe when it tries — so the overlay
   opens up around the player instead.

   Everything that is not the picture stands down for the duration. The
   Close button deliberately stays: in this state it is the only chrome
   on screen, and the player's own bar auto-hides on a phone, so it is
   the guaranteed way out. */
html.eonp-vfull-open .topbar { display: none; }

html.eonp-vfull-open .watch {
  top: 0;
  overflow: hidden;
}

html.eonp-vfull-open .watch-info,
html.eonp-vfull-open .upnext { display: none; }

/* svh, not vh and not dvh. Safari's toolbars slide in and out, and vh
   measures the tallest state, so the picture would be cropped by exactly
   the strip of toolbar on screen. dvh is the right idea but a readout
   from the handset had 100dvh at 735 against an innerHeight of 695 — it
   reported a viewport the toolbars were not leaving. svh is the SMALLEST
   viewport, toolbars visible, which is what the page actually has; it
   cannot overflow, and scroll is locked here so they cannot retract and
   leave a gap. Pure CSS — nothing measures this in JavaScript. */
html.eonp-vfull-open .watch-inner {
  height: 100vh;      /* floor for anything without the new units */
  height: 100svh;
  max-width: none;
  padding: 0;
  display: block;
}

html.eonp-vfull-open .watch-main {
  height: 100vh;      /* floor for anything without the new units */
  height: 100svh;
  display: block;
}

/* The player's height is normally computed from the screen minus the
   title, the meta row and the overlay's padding (--vid-h). None of those
   are on screen now. */
html.eonp-vfull-open .watch-main .player {
  height: 100vh;      /* floor for anything without the new units */
  height: 100svh;
  width: 100%;
  max-width: none;
  border-radius: 0;
}

/* ============================================================
   Unseen surfacing — the shimmering tab and the border beam

   Two marks for one fact: a playlist row that holds something this
   member has never opened, and the card of the thing itself. Both are
   PURELY additive — nothing here occupies layout, so the moment a video
   starts playing and app.js takes them off, not a pixel of the page
   moves. That is the constraint everything below is shaped by.

   Honest note on "compositor only": neither of these can be. A gradient
   swept across clipped TEXT and a conic gradient rotated by an animated
   <angle> are both paint-bound by construction — there is no transform
   or opacity spelling of either. That is precisely why the beams are
   capped at four (app.js BEAM_MAX): the cost is real, so it is bounded
   rather than pretended away. No script runs per frame for either.
   ============================================================ */

/* The hue, once. Eon Yellow on the dark ground; on cream it becomes Eon
   Orange — the brand bible's #db8037, in step with --progress and the
   deck's accent (owner's call, 2026-08-24, replacing the darkened
   #b8641c). Deliberately NOT --cta, which flips to Eon Purple on cream:
   purple would read as a selection state, which is the one thing this
   must not say.

   --new-on flipped to dark with it. That token is the ink on a FILLED
   chip of this colour, and white on #db8037 is 2.93:1 where #17101c is
   6.35:1 — the same pairing rule the dark theme uses for yellow.

   Three blocks, not two, for the reason tokens.css spells out: an
   explicit choice stamps data-theme, and system-default light stamps
   nothing at all and is only reachable through the media query. */
:root {
  --new-ink: #f3c516;    /* the label, and the head of the beam */
  --new-on: #17101c;     /* ink ON a filled --new-ink chip */
  --new-tip: #fff6d0;    /* the white-hot point of the head */
}
@media (prefers-color-scheme: light) {
  :root:not([data-theme="dark"]) {
    --new-ink: #db8037;
    --new-on: #17101c;
    /* A "white-hot" tip is invisible on cream. On a light ground the
       hottest point of a beam is the most saturated one, not the
       lightest, so the tip becomes Eon Magenta and the ring still reads
       as a travelling head rather than as a smudge. */
    --new-tip: #c43b89;
  }
}
:root[data-theme="light"] {
  --new-ink: #db8037;
  --new-on: #17101c;
  --new-tip: #c43b89;
}

/* ── the word ────────────────────────────────────────────────────────
   Same face as the rest of the library — var(--font), NOT the mono
   stack. Uppercased in CSS rather than in the DOM so a screen reader
   reads the word "new" instead of spelling an initialism. */
.nav-new,
.new-tag {
  font-family: var(--font);
  font-size: 10px;
  font-weight: 600;
  letter-spacing: 0.12em;
  line-height: 1;
  text-transform: uppercase;
  white-space: nowrap;
}

/* ── the sidebar row ─────────────────────────────────────────────────
   The row grew a third child, and a single flex `gap` cannot both give
   the icon its 20px of air and keep a trailing chip tight against a name
   that is being truncated to make room for it. So the gap becomes
   explicit margins: identical to today's rendering on every row without
   a chip, and 12px cheaper on the ones with. */
.nav-item { position: relative; gap: 0; }
.nav-item .nav-ic { margin-right: 20px; }
.nav-badge { margin-left: 20px; }

/* Long playlist names are the normal case here, not the exception, and
   the chip has to win the argument: the name can be recognised from its
   first few words, and "NEW" cannot be recognised from "NE". So the
   label keeps flex:1 and truncates with an ellipsis, and the chip never
   shrinks. (.nav-label already carries overflow/text-overflow, which is
   also what gives it an automatic minimum size of zero.) */
.nav-item .nav-label { flex: 1 1 auto; min-width: 0; }
.nav-new {
  flex: 0 0 auto;
  margin-left: 8px;
  color: var(--new-ink);
}

/* The shimmer.

   The gradient's dark stops are var(--text) ITSELF — the row's ordinary
   colour, at full strength. So the legibility floor is not a number that
   was tuned until it looked all right; it is structural: at no frame of
   the sweep is any glyph dimmer than the same word on the row above it.
   The animation can only ever make the text BRIGHTER (dark mode) or more
   saturated (light), never fainter, which is what makes it safe on a
   phone in daylight. Reading the requirement as "≥55% of the normal
   label colour": this sits at 100%.

   THE TRAVEL NUMBERS ARE NOT THE REFERENCE'S, and they cannot be.
   Percentage background-position places an image by
   `(area − image) × percentage`, so with a 300%-wide image the box is
   only covered while the percentage is between −50% and 100%. The
   reference sweeps 180% → −80%: BOTH ends sit entirely off the box, and
   with `no-repeat` that paints no background at all — which under
   `color: transparent` is a nav row with an invisible name on it. Caught
   on screen, not by reading: three playlists lost their labels outright.

   So the gradient TILES (background-repeat left at its default) and the
   sweep travels exactly one tile width, 150% → 0%. The box is covered at
   every frame; the loop is seamless because 150% displaces the image by
   precisely its own period; and the tile's outer 40% either side is
   var(--text), so the seam is text-colour meeting text-colour and never
   reads as a join. */
.nav-item.has-new .nav-label,
.nav-item.has-new .nav-new {
  background-image: linear-gradient(100deg,
    var(--text) 40%, var(--new-ink) 50%, var(--text) 60%);
  background-size: 300% 100%;
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  animation: eon-sweep 3.4s linear infinite;
}
@keyframes eon-sweep {
  from { background-position: 150% 0; }
  to   { background-position: 0 0; }
}

/* The floor under the floor. `color: transparent` is only survivable
   because something else is painting the glyphs; anywhere the clip is
   not honoured, that assumption turns a playlist name into a blank row.
   Cheap insurance for a failure whose symptom is invisible text. */
@supports not ((-webkit-background-clip: text) or (background-clip: text)) {
  .nav-item.has-new .nav-label,
  .nav-item.has-new .nav-new {
    background-image: none;
    color: var(--new-ink);
  }
}

/* A locked path must never advertise what is behind the lock, and its
   label is deliberately dimmed. Belt and braces alongside app.js's own
   pathLocked() guard in paintNav(). */
.nav-item.nav-path-locked.has-new .nav-label {
  background-image: none;
  color: var(--text-2);
  animation: none;
}
.nav-item.nav-path-locked .nav-new { display: none; }

/* ── the collapsed rail (desktop mini-guide) ─────────────────────────
   72px of icons and no labels. The word cannot fit and must not force
   the row wider, so it retires the same way the labels do — max-width
   and opacity, riding the same transition, never display — and a 6px
   dot on the icon carries the meaning until the rail is opened again. */
@media (min-width: 901px) {
  .nav-new {
    overflow: hidden;
    transition: opacity var(--dur-panel) var(--ease-move),
                max-width var(--dur-panel) var(--ease-move),
                margin var(--dur-panel) var(--ease-move),
                visibility 0s linear var(--dur-panel);
  }
  .sidebar:not(.open) .nav-new { visibility: visible; transition-delay: 0s; }
  .sidebar.open .nav-item .nav-ic { margin-right: 0; }
  .sidebar.open .nav-new {
    visibility: hidden;
    opacity: 0;
    max-width: 0;
    margin-left: 0;
  }
  .sidebar.open .nav-item.has-new::after {
    content: "";
    position: absolute;
    top: 7px;
    right: 16px;
    width: 6px;
    height: 6px;
    border-radius: 50%;
    background: var(--new-ink);
  }
}

/* ── the card ────────────────────────────────────────────────────────
   A stacking context of its own so the ring's z-index is scoped to this
   card rather than fighting every other card on the shelf. Deliberately
   isolation rather than overflow:hidden — the ring is inset:0 and masked
   to its own padding box, so it has nothing to overflow, and clipping
   here would put the shelf's hover chips at risk for no gain. */
.card { isolation: isolate; }

/* The travelling head. The angle is a real registered <angle> custom
   property, which is the whole reason this can be animated at all —
   an unregistered --a is a string, and strings do not interpolate. */
@property --eon-beam {
  syntax: "<angle>";
  inherits: false;
  initial-value: 0deg;
}

.card.is-new .ring {
  position: absolute;
  inset: 0;
  z-index: 3;              /* over the artwork; the card itself is the target */
  border-radius: inherit;  /* the whole card, not the thumbnail */
  padding: 1.5px;
  pointer-events: none;
  /* NOTHING until the beam starts. This used to be a solid yellow→magenta
     hairline — the state a card wore while it waited its turn under the
     old four-at-once cap. The cap is gone (owner's call), so the only
     moments this is on screen are the frame or two between a card being
     built and the observer reporting it, and any card sitting off screen.
     A loud two-colour border flashing in for one frame on every tile you
     scroll past is worse than no border at all, and it is exactly the
     thing that read as a second, competing indicator. Reduced motion
     re-instates a still ring below, because there it is all there is. */
  background: none;
  /* Two boxes, minus each other: the outer fills the border box, the
     inner is knocked out at the content box, and what survives is a
     1.5px band exactly following border-radius. */
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
  mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  mask-composite: exclude;
}

/* On screen: the head laps the perimeter. Transparent for most of the
   circle so it reads as one travelling point of light rather than as a
   rotating gradient — 72% of nothing, then the glow ramps, the head, the
   hot tip, and out. */
.card.is-new.beam-on .ring {
  background: conic-gradient(from var(--eon-beam),
    transparent 0 72%,
    color-mix(in srgb, var(--new-ink), transparent 65%) 82%,
    var(--new-ink) 90%,
    var(--new-tip) 92%,
    transparent 96%);
  animation: eon-beam 4s linear infinite;
}
@keyframes eon-beam { to { --eon-beam: 360deg; } }

/* The label. Over the artwork, mirroring the duration chip at the other
   corner, because a chip in the flow would make the card taller and the
   whole shelf would jump the instant a video was marked seen. */
.card.is-new .new-tag {
  position: absolute;
  left: 8px;
  top: 8px;
  z-index: 2;
  padding: 4px 6px;
  border-radius: var(--radius-sm);
  color: var(--new-on);
  background: var(--new-ink);
}

/* Focus is a 2px outline at +2px offset — outside the border box, where
   the ring (inset:0) cannot reach it, and unclipped because .card takes
   no overflow. Stated as a rule rather than left to luck: the beam must
   never be what a keyboard user loses their place to. */
.card.is-new:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }

/* ── phones ──────────────────────────────────────────────────────────
   Under 520px the feed goes edge to edge and .thumb loses its radius, so
   a chip at 8px would sit on the screen edge. The ring follows the card
   in — there is no border box to draw on out there — and the label pulls
   in to the same 16px gutter the titles use. */
@media (max-width: 520px) {
  .main > .grid .card.is-new .new-tag { left: 16px; }
  .main > .grid .card.is-new .ring { border-radius: 0; }
}

/* ── reduced motion ──────────────────────────────────────────────────
   This sheet already kills every animation globally with
   `* { animation: none !important }`, which stops both of these dead —
   but stopping is not the same as being correct once stopped. A shimmer
   frozen mid-sweep is a word in two colours; a beam frozen at 0deg is a
   bright smear in one corner, and the ring's resting state is now
   `background: none`, which would be no indicator at all. So both fall
   back to a deliberate still state that still distinguishes new from
   seen:

     the tab   a solid Eon Yellow (Eon Orange on cream) label
     the card  a complete hairline all the way round, in ONE hue —
               the head's own yellow fading to a third of itself

   Single-hue, not the yellow→magenta it used to be. Two strong colours
   on a still border is a heavier mark than the moving beam it stands in
   for, and someone who has asked for less motion has not asked for more
   decoration. Written against `.is-new`, not `.beam-on`, so it holds
   whether or not the observer ever got to that card. */
@media (prefers-reduced-motion: reduce) {
  .nav-item.has-new .nav-label,
  .nav-item.has-new .nav-new {
    background-image: none;
    -webkit-background-clip: border-box;
    background-clip: border-box;
    color: var(--new-ink);
  }
  .card.is-new .ring {
    background: linear-gradient(120deg,
      var(--new-ink),
      color-mix(in srgb, var(--new-ink), transparent 62%));
  }
}

/* Touch runway (see player.css, .eonp-vfull-touch): the svh sizing above
   was right while the root was locked — it was the only height that
   could not overflow. With the runway the root scrolls, the toolbars
   retract, and the target state is the LARGE viewport; sizing the chain
   to svh there would leave a toolbar-sized band of ground showing under
   the picture once the chrome is gone. The player's own bar is fixed to
   the visible viewport in this mode, so nothing is ever out of reach
   while the toolbars are still up. */
html.eonp-vfull-open.eonp-vfull-touch .watch-inner,
html.eonp-vfull-open.eonp-vfull-touch .watch-main,
html.eonp-vfull-open.eonp-vfull-touch .watch-main .player {
  height: 100vh;
  height: 100lvh;
}
