fix: drop video z-index hack + heavy comments + doc 31 layer model

Image-12 incident: I'd set <video> z-index:9999, which covers the OSD
scrubber + buttons (Jellyfin's stock OSD controls live at z-index
1100-2000, above the 1000 of .videoPlayerContainer but BELOW our
9999). Drop the lift entirely. Stock z-index hierarchy already has
controls floating on top. The fix for black-screen was always
transparent ancestor backgrounds (L1+L2), never z-index.

Reorganized inject-middle-theme.py CSS string from one-line dense
concat into a triple-quoted multi-line block with header comments
explaining each section + the layer model + DO-NOT rules. Same
output bytes (verified md5 deterministic). Added long-form comment
header to JS too.

Doc 31 (new): "Theme layer model + edit guide" — comprehensive
checklist for any future CSS edit. Covers:
  - Stacking order layer 0..7 with stock Jellyfin z-indexes
  - The two body classes (.arrflix-themed, .arrflix-video-active)
  - Specificity tiers + cascade order (L1 vs L2)
  - CSS load order (inline < bundle < branding.xml)
  - Recurring bug list (6 incidents now, all same anti-pattern)
  - DO NOT DO foot-gun list
  - 4-step smoke verify procedure
  - CI gates still TODO

Snapshot bumped to md5 2da61583. Prod+dev byte-identical.
This commit is contained in:
s8n 2026-05-09 22:41:51 +01:00
parent 4f13db63f9
commit 1ed55152b7
4 changed files with 786 additions and 78 deletions

View file

@ -33,49 +33,218 @@ wordmark_url = (ASSETS / "arrflix-wordmark.b64-url").read_text(encoding="utf-8")
START = "/* ARRFLIX-MIDDLE-THEME-BEGIN */"
END = "/* ARRFLIX-MIDDLE-THEME-END */"
CSS = (
"body.arrflix-themed .skinHeader .headerTop{display:flex!important;align-items:center;position:relative;min-height:48px}\n"
"body.arrflix-themed .skinHeader .headerLeft,body.arrflix-themed .skinHeader .headerRight{flex:1 1 0;display:flex;align-items:center}\n"
"body.arrflix-themed .skinHeader .headerLeft{justify-content:flex-start;gap:.4em}\n"
"body.arrflix-themed .skinHeader .headerRight{justify-content:flex-end}\n"
"body.arrflix-themed .skinHeader .headerHomeButton,body.arrflix-themed .skinHeader .pageTitleWithLogo,body.arrflix-themed .skinHeader .headerBackButton{display:none!important}\n"
"body.arrflix-themed .skinHeader .headerLeft > h3.pageTitle:not(.pageTitleWithLogo){display:none!important}\n"
"body.arrflix-themed .skinHeader .headerCastButton,body.arrflix-themed .skinHeader .headerSyncButton{display:none!important}\n"
"body.arrflix-themed .headerTabs.sectionTabs{display:none!important}\n"
"/* Hide the 'My Media' library row on home page (first vertical section, .section0). Continue Watching=section1, Continue Listening=section2, Continue Reading=section3, Next Up=section5, Recently Added=section6 — leave those untouched. */\n"
"body.arrflix-themed .homePage .homeSectionsContainer .verticalSection.section0{display:none!important}\n"
"/* Hide entire header during video playback */\n"
"body.arrflix-video-active:not(:has(#loginPage:not(.hide))) .skinHeader,body.arrflix-video-active .arrflix-headerLogo,body.arrflix-video-active .arrflix-nav{display:none!important}\n"
".arrflix-headerLogo{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:120px;height:38px;"
"background:center/contain no-repeat url('" + wordmark_url + "');"
"z-index:1;display:block;text-indent:-9999px;overflow:hidden}\n"
".arrflix-headerLogo:hover{filter:brightness(1.15)}\n"
".arrflix-nav{text-transform:uppercase;letter-spacing:.08em;font-weight:600;padding:0 .9em;color:#fff!important;text-decoration:none;display:inline-flex;align-items:center;height:100%;font-size:.85em;transition:color .18s ease,text-shadow .18s ease,font-weight .18s ease}\n"
".arrflix-nav:hover{color:#E50914!important}\n"
".arrflix-nav.active{color:#E50914!important;font-weight:700;text-shadow:0 0 12px rgba(229,9,20,0.55),0 0 24px rgba(229,9,20,0.25)}\n"
"/* Pure-black background — L1: gated by :not(.arrflix-video-active). Self-disables when JS sets body.arrflix-video-active. Doc 30 lesson — recurring black-screen-over-video bug class. */\n"
"html,body.arrflix-themed:not(.arrflix-video-active),body.arrflix-themed:not(.arrflix-video-active) .backgroundContainer,body.arrflix-themed:not(.arrflix-video-active) .skinBody,body.arrflix-themed:not(.arrflix-video-active) .mainAnimatedPage,body.arrflix-themed:not(.arrflix-video-active) .mainAnimatedPages,body.arrflix-themed:not(.arrflix-video-active) .pageContainer,body.arrflix-themed:not(.arrflix-video-active) #reactRoot{background-color:#000!important}\n"
"body.arrflix-themed:not(.arrflix-video-active) .backgroundContainer.withBackdrop{background-color:rgba(0,0,0,.86)!important}\n"
"/* Video isolation L2: when arrflix-video-active is set, ancestors become transparent so <video> renders unobscured. html stays #000 from the rule above so letterbox shows black. videoPlayerContainer (the actual Jellyfin wrapper, NOT .htmlVideoPlayer) + child video.htmlvideoplayer covered. */\n"
"body.arrflix-themed.arrflix-video-active,body.arrflix-themed.arrflix-video-active .backgroundContainer,body.arrflix-themed.arrflix-video-active .skinBody,body.arrflix-themed.arrflix-video-active .mainAnimatedPage,body.arrflix-themed.arrflix-video-active .mainAnimatedPages,body.arrflix-themed.arrflix-video-active .pageContainer,body.arrflix-themed.arrflix-video-active #reactRoot,body.arrflix-themed.arrflix-video-active .videoPlayerContainer,body.arrflix-themed.arrflix-video-active .videoPlayerContainer-onTop,body.arrflix-themed.arrflix-video-active #videoOsdPage,body.arrflix-themed.arrflix-video-active #videoOsdPage .pageContainer,body.arrflix-themed.arrflix-video-active #videoOsdPage .mainAnimatedPage,body.arrflix-themed.arrflix-video-active #videoOsdPage .layout-desktop,body.arrflix-themed.arrflix-video-active .libraryPage,body.arrflix-themed.arrflix-video-active video.htmlvideoplayer{background-color:transparent!important;background:transparent!important;background-image:none!important}\n"
"/* Video element on top of all stacking contexts during playback. */\n"
"body.arrflix-themed.arrflix-video-active video.htmlvideoplayer,body.arrflix-themed.arrflix-video-active .videoPlayerContainer{z-index:9999!important;isolation:isolate}\n"
"/* Search input: replace stock cyan focus ring (jellyfin-web/themes/dark/theme.css:262-272) with a borderless slab + red underline on focus, true to the Cineplex/Netflix accent */\n"
"body.arrflix-themed .searchFields .emby-input,body.arrflix-themed input.searchfields-txtSearch,body.arrflix-themed #searchTextInput{background:#141414!important;border:0!important;border-bottom:2px solid transparent!important;border-radius:2px!important;color:#fff!important;transition:border-color .18s ease,box-shadow .18s ease,background-color .18s ease;padding:.55em .8em!important}\n"
"body.arrflix-themed .searchFields .emby-input::placeholder,body.arrflix-themed input.searchfields-txtSearch::placeholder,body.arrflix-themed #searchTextInput::placeholder{color:rgba(255,255,255,.4);letter-spacing:.02em}\n"
"body.arrflix-themed .searchFields .emby-input:hover,body.arrflix-themed input.searchfields-txtSearch:hover,body.arrflix-themed #searchTextInput:hover{background:#1a1a1a!important}\n"
"body.arrflix-themed .searchFields .emby-input:focus,body.arrflix-themed input.searchfields-txtSearch:focus,body.arrflix-themed #searchTextInput:focus{background:#1a1a1a!important;border:0!important;border-bottom:2px solid #E50914!important;box-shadow:0 1px 0 0 rgba(229,9,20,.35),0 0 14px -2px rgba(229,9,20,.35)!important;outline:none!important}\n"
)
CSS = r"""
/* ===========================================================================
* ARRFLIX MIDDLE-THEME v6 CSS layer model
* ===========================================================================
*
* STACKING ORDER (low high) DO NOT VIOLATE:
*
* layer 0 <html> bg #000 (set via JS inline style; see start())
* black letterbox bars on video page come from here
* layer 1 <body> bg #000 off-video (L1), transparent on-video (L2)
* layer 2 .backgroundContainer Jellyfin backdrop (poster blur), bg propagated from L1/L2
* .skinBody main app shell
* #reactRoot
* layer 3 .mainAnimatedPages page swap container
* .pageContainer current page
* layer 4 .skinHeader top nav (HIDDEN during video see :not(:has(#loginPage)))
* layer 5 .videoPlayerContainer Jellyfin player wrapper (z:1000 by Jellyfin, fixed inset:0)
* video.htmlvideoplayer the <video> element (z:auto, inherits container stack)
* layer 6 .osdControls Jellyfin OSD bar (scrubber, play/pause, settings)
* .videoOsdBottom bottom controls strip
* .upNextDialog episode-up-next overlay
* ALL Jellyfin OSD UI must stay above <video>. Jellyfin sets these
* with z-index > 1000 in stock CSS DO NOT add a higher z-index
* to <video> or .videoPlayerContainer or you cover the controls.
* layer 7 .dialogContainer modal dialogs (settings menu, subtitle picker)
*
* RULE: never z-index <video> or .videoPlayerContainer above 1000.
* Stock Jellyfin OSD controls float on top because their CSS sets
* z-index in the 11002000 range (depending on dialog vs bar).
*
* BLACK-SCREEN-OVER-VIDEO BUG CLASS recurring (5+ times in 24h, doc 26/28/30):
* ANY rule that paints opaque bg on layer 04 ancestors of <video> while
* the player is mounted obscures the decoded frames. Two-layer defence:
*
* L1 (off-video): paint #000 on body+ancestors only when
* body lacks .arrflix-video-active class.
* L2 (on-video): paint transparent on every known ancestor when
* body has .arrflix-video-active. JS toggles this
* class via isVideoPage() which checks hash + DOM.
*
* SPECIFICITY NOTE: L1 (`body.arrflix-themed:not(.arrflix-video-active)`)
* and L2 (`body.arrflix-themed.arrflix-video-active`) both score (0,2,1)
* on body. Equal specificity source order decides. L2 listed AFTER L1
* in this file L2 wins when video-active. Good.
*
* BEFORE ADDING ANY NEW BG-COLOR RULE: ask "does this paint an ancestor of
* <video>?" If yes, scope it with `:not(.arrflix-video-active)`. Otherwise
* you reopen the black-screen bug. See doc 31 LAYER-MODEL.
* ===========================================================================
*/
/* --- HEADER LAYOUT ------------------------------------------------------ */
/* Three-column flex: nav-left | logo-center (absolute) | search-right */
body.arrflix-themed .skinHeader .headerTop{display:flex!important;align-items:center;position:relative;min-height:48px}
body.arrflix-themed .skinHeader .headerLeft,
body.arrflix-themed .skinHeader .headerRight{flex:1 1 0;display:flex;align-items:center}
body.arrflix-themed .skinHeader .headerLeft{justify-content:flex-start;gap:.4em}
body.arrflix-themed .skinHeader .headerRight{justify-content:flex-end}
/* Hide stock Jellyfin header chrome we don't want */
body.arrflix-themed .skinHeader .headerHomeButton,
body.arrflix-themed .skinHeader .pageTitleWithLogo,
body.arrflix-themed .skinHeader .headerBackButton{display:none!important}
body.arrflix-themed .skinHeader .headerLeft > h3.pageTitle:not(.pageTitleWithLogo){display:none!important}
body.arrflix-themed .skinHeader .headerCastButton,
body.arrflix-themed .skinHeader .headerSyncButton{display:none!important}
body.arrflix-themed .headerTabs.sectionTabs{display:none!important}
/* Hide 'My Media' row (.section0) Continue Watching=section1, Next Up=section5, Recently Added=section6 unaffected */
body.arrflix-themed .homePage .homeSectionsContainer .verticalSection.section0{display:none!important}
/* Header itself disappears during video :not(:has(#loginPage)) keeps login pre-arrflix-themed render unaffected */
body.arrflix-video-active:not(:has(#loginPage:not(.hide))) .skinHeader,
body.arrflix-video-active .arrflix-headerLogo,
body.arrflix-video-active .arrflix-nav{display:none!important}
/* Center wordmark logo absolute pos, dead-center of headerTop */
.arrflix-headerLogo{
position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);
width:120px;height:38px;
background:center/contain no-repeat url('__WORDMARK_URL__');
z-index:1;display:block;text-indent:-9999px;overflow:hidden;
}
.arrflix-headerLogo:hover{filter:brightness(1.15)}
/* Movies / Series nav links */
.arrflix-nav{
text-transform:uppercase;letter-spacing:.08em;font-weight:600;
padding:0 .9em;color:#fff!important;text-decoration:none;
display:inline-flex;align-items:center;height:100%;font-size:.85em;
transition:color .18s ease,text-shadow .18s ease,font-weight .18s ease;
}
.arrflix-nav:hover{color:#E50914!important}
/* Variant E: cinematic glow on active route toggled by JS on hashchange */
.arrflix-nav.active{
color:#E50914!important;font-weight:700;
text-shadow:0 0 12px rgba(229,9,20,0.55),0 0 24px rgba(229,9,20,0.25);
}
/* --- L1: PURE-BLACK BG (off-video only) -------------------------------- */
/* Fires when body does NOT have .arrflix-video-active.
* Specificity (0,2,1) on body / (0,3,1) on descendants.
* <html> selector has no :not() so html stays #000 always. */
html,
body.arrflix-themed:not(.arrflix-video-active),
body.arrflix-themed:not(.arrflix-video-active) .backgroundContainer,
body.arrflix-themed:not(.arrflix-video-active) .skinBody,
body.arrflix-themed:not(.arrflix-video-active) .mainAnimatedPage,
body.arrflix-themed:not(.arrflix-video-active) .mainAnimatedPages,
body.arrflix-themed:not(.arrflix-video-active) .pageContainer,
body.arrflix-themed:not(.arrflix-video-active) #reactRoot{background-color:#000!important}
body.arrflix-themed:not(.arrflix-video-active) .backgroundContainer.withBackdrop{background-color:rgba(0,0,0,.86)!important}
/* --- L2: TRANSPARENT ANCESTORS (during video playback) ----------------- */
/* Fires when JS sets body.arrflix-video-active. Specificity matched to L1
* (0,2,1 on body, 0,3,1 on descendants). L2 wins on source order listed
* AFTER L1 in this file. Without this, opaque ancestor bg paints over <video>.
*
* NOTE: <html> bg is pinned via JS inline style (start()) so letterbox bars
* stay BLACK even though html selector is in L1's gated rule above.
*
* Targets every known ancestor of <video.htmlvideoplayer>:
* body, .backgroundContainer, .skinBody, .mainAnimatedPage(s), .pageContainer,
* #reactRoot, .videoPlayerContainer (Jellyfin wrapper, z:1000),
* .videoPlayerContainer-onTop, #videoOsdPage + descendants, .libraryPage,
* <video> itself.
*
* DO NOT add z-index to anything in this block. OSD controls (layer 6) sit
* above <video> via Jellyfin's stock z-index 1100+. Lifting <video> z-index
* obscures controls see image #12 incident. */
body.arrflix-themed.arrflix-video-active,
body.arrflix-themed.arrflix-video-active .backgroundContainer,
body.arrflix-themed.arrflix-video-active .skinBody,
body.arrflix-themed.arrflix-video-active .mainAnimatedPage,
body.arrflix-themed.arrflix-video-active .mainAnimatedPages,
body.arrflix-themed.arrflix-video-active .pageContainer,
body.arrflix-themed.arrflix-video-active #reactRoot,
body.arrflix-themed.arrflix-video-active .videoPlayerContainer,
body.arrflix-themed.arrflix-video-active .videoPlayerContainer-onTop,
body.arrflix-themed.arrflix-video-active #videoOsdPage,
body.arrflix-themed.arrflix-video-active #videoOsdPage .pageContainer,
body.arrflix-themed.arrflix-video-active #videoOsdPage .mainAnimatedPage,
body.arrflix-themed.arrflix-video-active #videoOsdPage .layout-desktop,
body.arrflix-themed.arrflix-video-active .libraryPage,
body.arrflix-themed.arrflix-video-active video.htmlvideoplayer{
background-color:transparent!important;
background:transparent!important;
background-image:none!important;
}
/* --- SEARCH INPUT (cyan ring red underline) ------------------------- */
/* Stock Jellyfin theme.css:262-272 sets blue focus ring (#00a4dc).
* Replace with borderless slab + red bottom border + soft red glow.
* Cineplex/Netflix-faithful. */
body.arrflix-themed .searchFields .emby-input,
body.arrflix-themed input.searchfields-txtSearch,
body.arrflix-themed #searchTextInput{
background:#141414!important;border:0!important;
border-bottom:2px solid transparent!important;border-radius:2px!important;
color:#fff!important;padding:.55em .8em!important;
transition:border-color .18s ease,box-shadow .18s ease,background-color .18s ease;
}
body.arrflix-themed .searchFields .emby-input::placeholder,
body.arrflix-themed input.searchfields-txtSearch::placeholder,
body.arrflix-themed #searchTextInput::placeholder{color:rgba(255,255,255,.4);letter-spacing:.02em}
body.arrflix-themed .searchFields .emby-input:hover,
body.arrflix-themed input.searchfields-txtSearch:hover,
body.arrflix-themed #searchTextInput:hover{background:#1a1a1a!important}
body.arrflix-themed .searchFields .emby-input:focus,
body.arrflix-themed input.searchfields-txtSearch:focus,
body.arrflix-themed #searchTextInput:focus{
background:#1a1a1a!important;border:0!important;
border-bottom:2px solid #E50914!important;
box-shadow:0 1px 0 0 rgba(229,9,20,.35),0 0 14px -2px rgba(229,9,20,.35)!important;
outline:none!important;
}
""".replace("__WORDMARK_URL__", wordmark_url)
JS = """
/* ARRFLIX middle-theme JS shim runtime DOM mutations + body-class toggles.
*
* BODY CLASSES we manage:
* .arrflix-themed set when isAuthed() = true. Gates the entire theme.
* Removed on logout/login route; CSS rules disable.
* .arrflix-video-active set when isVideoPage() = true. Gates L2 transparency
* and hides .skinHeader. Toggled live on hashchange
* + every 1.5s tick + on every body-mutation.
*
* INLINE STYLE on <html>:
* We force background-color:#000 via setProperty(...,'important') because
* getComputedStyle(html).backgroundColor inexplicably returned rgba(0,0,0,0)
* on details/video pages despite 5 stylesheet rules saying #000 !important.
* Inline style is the highest specificity short of !important user-agent.
* This guarantees the canvas behind any transparent body stays BLACK
* (so video-page letterbox bars are black, not browser-default white).
*/
(function(){
function isVideoPage(){
/* Returns true if the user is currently on a video-playback page.
* Three signals (any one is enough):
* 1. URL hash contains '/video' (Jellyfin's video route)
* 2. #videoOsdPage element is visible (the OSD page id Jellyfin mounts)
* 3. video.htmlvideoplayer (lowercase!) element is visible
* NOTE: '.htmlVideoPlayer' (camelCase) does NOT exist in Jellyfin 10.10.
* The real class is lowercase 'htmlvideoplayer'.
*/
try{
var h=(location.hash||'').toLowerCase();
if (h.indexOf('/video') !== -1) return true;
var osd = document.querySelector('#videoOsdPage:not(.hide)');
if (osd) return true;
var v = document.querySelector('.htmlVideoPlayer:not(.hide), video.htmlvideoplayer:not(.hide)');
var v = document.querySelector('video.htmlvideoplayer:not(.hide)');
if (v && getComputedStyle(v).display !== 'none') return true;
}catch(e){}
return false;

View file

@ -0,0 +1,195 @@
# 31 — ARRFLIX theme layer model + edit guide (2026-05-09)
> **Read this before editing any CSS in `bin/inject-middle-theme.py`, `web-overrides/index.html`, or `branding.xml`.** Five black-screen-over-video incidents in 24 hours (doc 26 INC1INC5, doc 28 INC7, doc 30 v6-stable, plus this latest one) all came from the same anti-pattern: an opaque `background-color` rule painted on an ancestor of `<video>` while the player is mounted. This doc maps the layer hierarchy and gives a checklist that catches the bug before it ships.
---
## TL;DR — checklist before adding a CSS rule
1. Does my rule paint `background-color`, `background`, or `background-image` on **any** of: `html`, `body`, `.backgroundContainer`, `.skinBody`, `.mainAnimatedPage(s)`, `.pageContainer`, `#reactRoot`, `.videoPlayerContainer`, `#videoOsdPage`, `.libraryPage`, `video.htmlvideoplayer`?
- **Yes** → scope it with `body.arrflix-themed:not(.arrflix-video-active)`. Test on a video page after deploy.
- **No** → safe to paint any color.
2. Does my rule set a `z-index` on `<video>`, `.videoPlayerContainer`, or any element claiming to be "the player"?
- **Yes** → STOP. Don't. OSD controls (scrubber, buttons, settings panel) sit above `<video>` via Jellyfin's stock z-indexes (11002000). Lifting the player above that obscures the controls — see image #12 incident, this very doc.
- **No** → safe to z-index whatever.
3. Did I add a `<video>` literal in a CSS comment? (e.g. `/* video element... */`).
- If the rule lives in `branding.xml` `<CustomCss>`: ESCAPE it. `<video>``&lt;video&gt;`. Otherwise XML parser chokes, branding silently fails to load, theme disappears site-wide. See doc 30.
- If the rule lives in `web-overrides/index.html` `<style>`: safe (HTML doesn't parse content of `<style>`).
4. After deploying, hard-refresh and play any video. If you see a black/white frame instead of decoded pixels: revert and re-read this doc.
---
## The layer model
Stacking order, low → high. Ancestors of `<video>` listed first.
| Layer | Element | Stock z-index | ARRFLIX bg | Notes |
|------:|---------|---------------|------------|-------|
| 0 | `<html>` | n/a (root) | `#000` (JS inline-style pinned) | Shows behind transparent body during video — black letterbox bars come from here. |
| 1 | `<body>` | n/a | `#000` off-video (L1) / `transparent` on-video (L2) | Toggled by JS body class `.arrflix-video-active`. |
| 2 | `.backgroundContainer` | `-1` (Jellyfin) | follows L1/L2 | Holds the poster blur backdrop on detail pages. |
| | `.skinBody` | `auto` | follows L1/L2 | Main app shell. |
| | `#reactRoot` | `auto` | follows L1/L2 | React mount root. |
| 3 | `.mainAnimatedPages` | `auto` | follows L1/L2 | Page swap container (animates between pages). |
| | `.pageContainer` | `auto` | follows L1/L2 | Current page. |
| 4 | `.skinHeader` | `1` | `#000` off-video, **HIDDEN** on-video | Top nav. Hidden when `body.arrflix-video-active` (and not on login). |
| 5 | `.videoPlayerContainer` (`.videoPlayerContainer-onTop`) | `1000` (Jellyfin) | `transparent` on-video | The player wrapper. **NEVER override this z-index.** |
| | └─ `<video class="htmlvideoplayer">` | `auto` (inherits) | `transparent` | Class is **lowercase** `htmlvideoplayer`. There is no `.htmlVideoPlayer` (camelCase). Don't confuse them. |
| 6 | `.osdControls`, `.videoOsdBottom`, `.upNextDialog` | `~11001500` (Jellyfin) | varies per element | Scrubber, play/pause, fullscreen, captions, settings. **MUST stay above `<video>`.** |
| 7 | `.dialogContainer`, `.dialog` | `~2000+` (Jellyfin) | varies | Modals (settings menu, audio/subtitle picker, info dialog). |
**Hard rule**: any z-index between 1000 and 2000 is owned by Jellyfin. Don't touch it.
---
## The two body classes
JS toggles two body classes on every `relayoutHeader()` tick (every page mutation + 1.5s interval + hashchange + DOMContentLoaded).
### `body.arrflix-themed`
- **Set when** `isAuthed()` returns true. Conditions: `ApiClient.isLoggedIn()`, `localStorage.jellyfin_credentials.Servers[0].AccessToken` exists, no visible `#loginPage`, hash not on `/login | /wizard | /forgotpassword | /selectserver`.
- **Removed on** logout, login route, server picker.
- **Effect**: gates the entire theme. Without this class, the page renders stock-Jellyfin (so login looks like Jellyfin's default sign-in form, not the rearranged Cineplex layout).
### `body.arrflix-video-active`
- **Set when** `isVideoPage()` returns true. Conditions (any one):
- `location.hash` contains `/video`
- `#videoOsdPage:not(.hide)` exists in DOM
- `video.htmlvideoplayer:not(.hide)` exists and is `display:flex/block`
- **Removed when** none of those signals match.
- **Effect**: switches CSS from L1 (opaque #000 ancestors) to L2 (transparent ancestors), hides `.skinHeader`, hides `.arrflix-headerLogo`, hides `.arrflix-nav`.
---
## Cascade rules to know
### Specificity tiers used
| Selector form | Specificity (a,b,c) |
|---------------|---------------------|
| `body` | (0,0,1) |
| `body.arrflix-themed` | (0,1,1) |
| `body.arrflix-themed:not(.arrflix-video-active)` | (0,2,1) |
| `body.arrflix-themed.arrflix-video-active` | (0,2,1) |
| `body.arrflix-themed.arrflix-video-active .pageContainer` | (0,3,1) |
| `body.arrflix-themed.arrflix-video-active #videoOsdPage` | (0,2,1) + ID = (1,2,1) |
| `#videoOsdPage .pageContainer` (Cineplex/INC7) | (1,1,0) |
L1 (off-video) and L2 (on-video) both score (0,2,1) on body. Equal specificity → **source order decides**. L2 is listed AFTER L1 in `inject-middle-theme.py`, so during video L2 wins. If you reorder these blocks you reopen the bug.
### `!important` doesn't override specificity
Both L1 and L2 use `!important`. Among `!important` rules, specificity still decides. Adding `!important` to a low-specificity rule won't beat a high-specificity `!important` rule.
### Inline style beats stylesheets
`<html style="background-color:#000">` (set via `element.style.setProperty('background-color','#000','important')`) beats every stylesheet rule. We use this on `<html>` because `getComputedStyle(html).backgroundColor` inexplicably returned `rgba(0,0,0,0)` on details/video pages despite 5 stylesheet rules saying `#000 !important`. Likely a Chromium root-canvas-propagation quirk.
---
## CSS load order (top → bottom = first → last applied)
1. **`web-overrides/index.html` `<style>` (top, lines 163)** — critical CSS, painted before bundle. Includes the original `html, body, .preload, ... { background-color: #000 !important }` rule.
2. **`<style>ARRFLIX-MIDDLE-THEME-BEGIN/END</style>`** — our middle-theme rules. Inserted just before `</head>`. After the critical CSS.
3. **Jellyfin web bundle CSS** (`main.jellyfin.<hash>.css`, `themes/dark/theme.css`, lazy-loaded chunks). Loaded via `<link>`. Comes after our `<style>` block in the DOM but typically lower specificity, so we still win.
4. **`branding.xml` `CustomCss`** — fetched at SPA boot, injected as a `<style>` element AFTER everything else. Includes `@import url('/web/cineplex.css')` which pulls in the Cineplex theme. Wins over inline `<style>` on equal specificity.
If you see a CSS rule from `branding.xml` overriding ours, increase specificity, not load order.
---
## Recurring bug list (the things this doc exists to prevent)
| Date | Bug | Cause | Fix |
|------|-----|-------|-----|
| 2026-05-09 INC1 | Backdrop band black | `BLACK-PASS` paints `.backdropContainer` opaque | scope `:has(.itemDetailPage)` transparent |
| 2026-05-09 INC4 | "More from Season N" carousel hidden | `.emby-scroller{bg:#000}` unscoped | add `.emby-scroller` to transparent-scope |
| 2026-05-09 INC7 | Video black-screen during playback | `.libraryPage{bg:#000}` paints over `<video>` | `#videoOsdPage{bg:transparent}` |
| 2026-05-09 v6-stable | All Cineplex CSS missing site-wide | `<video>` literal in branding.xml comment broke XML parse | escape `<video>``&lt;video&gt;` |
| 2026-05-09 a6cf925 | Body opaque during playback | `:has(.htmlVideoPlayer)` (camelCase) never matched | use `:not(.arrflix-video-active)` instead |
| 2026-05-09 image-12 | Video covers OSD scrubber + buttons | We forced `<video> z-index: 9999` | revert; rely on Jellyfin's stock z-index hierarchy |
The pattern is the same every time: **a wrapper got an opaque background, and the negation didn't catch every wrapper Jellyfin uses on the video page.** This doc is the negation list.
---
## How to add a new theme rule safely
### Adding a NEW bg-color rule
1. Read the layer table above. If your selector lands on layer 04, scope with `body.arrflix-themed:not(.arrflix-video-active)`.
2. Put the rule in `bin/inject-middle-theme.py`, in the section that matches its purpose (header layout, search input, etc.).
3. Run `python3 bin/inject-middle-theme.py` to re-emit `web-overrides/index.html`.
4. scp to dev only (don't touch prod yet).
5. Hard-refresh dev login → confirm no visual regression.
6. Hard-refresh dev → click any video → play 5+ seconds → confirm video pixels visible. If black: revert.
7. Only then push to prod (`docker run --userns=host -v /opt/docker/jellyfin/web-overrides:/d:rw alpine cp /tmp/idx.html /d/index.html && chown root:root && docker restart jellyfin`).
### Adding a NEW z-index
Don't. Period. If you think you need to z-index `<video>` higher to "be on top": you don't. Stock Jellyfin already gives you the right stacking. The bug was always opaque ancestor backgrounds, never z-index.
If you absolutely need z-index for a non-player element: stay below 1000 (everything below the player wrapper) or above 2000 (above all dialogs). Anything in between is a Jellyfin OSD/dialog territory.
### Adding a NEW selector to L2's transparent list
1. Confirm the selector lands on a real ancestor of `<video>` (open DevTools, navigate up the DOM tree from the video element).
2. Add it to BOTH L1 (opaque) and L2 (transparent) selector lists. Always paired so the off-video state stays black.
3. Same deploy flow as above.
---
## DO NOT DO list (the foot-guns)
| Don't | Reason |
|-------|--------|
| `body.arrflix-themed { background:#000 }` (unscoped) | Reopens black-screen bug. Always use `:not(.arrflix-video-active)`. |
| `<video> { z-index: 9999 }` | Covers OSD scrubber and buttons. Image #12. |
| `:has(.htmlVideoPlayer)` (camelCase) | Class doesn't exist. Use `.htmlvideoplayer` lowercase or just `:not(.arrflix-video-active)`. |
| `<video>...</video>` literal in `branding.xml` `CustomCss` comment | XML parser chokes, branding silently fails. Escape with `&lt;` `&gt;`. |
| Reorder L1 / L2 blocks in `inject-middle-theme.py` | Equal specificity → source order decides. L2 must come after L1. |
| Add `!important` to "fix" a cascade conflict without understanding specificity | `!important` doesn't change specificity ordering among `!important` rules. Increase specificity instead. |
| Hot-patch the deployed overlay (write directly to `/opt/docker/jellyfin/web-overrides/index.html`) | Drift between repo and prod. INC1 root cause per doc 26. Always edit `bin/inject-middle-theme.py`, regen, scp. |
| `cp` to swap the prod overlay without `docker restart jellyfin` | bind-mount inode swap doesn't refresh container view. The new file lives at a different inode; container still serves the old one. Always restart prod after `cp`. |
---
## CI gates that would have caught past bugs (still TODO)
| Gate | Catches | Status |
|------|---------|--------|
| `xmllint --noout branding.xml` on every push | The v6-stable XML-parse-silent-failure (doc 30 lesson) | NOT IMPLEMENTED |
| `darkPct` assertion in `bin/headless-test-v2.py` | Every black-screen-over-video incident (5 of them) | NOT IMPLEMENTED (per doc 30 + agent 4 history) |
| Forgejo CI runner triggering headless test on push to `main` | Both above, automatically | NOT IMPLEMENTED |
| Headless test asserts `.osdControls` is visible during playback | The image-12 z-index-too-high regression | NOT IMPLEMENTED |
If you implement any of these, mark it here.
---
## Quick verify after any theme change
Run this 4-step manual smoke on dev before pushing to prod:
```bash
# 1. Login still works (theme disabled pre-auth, no Cineplex breakage)
curl -s https://dev.arrflix.s8n.ru/web/index.html | grep -c ARRFLIX-MIDDLE-THEME-BEGIN # = 1
docker exec jellyfin-dev curl -s http://127.0.0.1:8096/Branding/Css.css | wc -c # ~36000
# 2. Home page renders pure black (no #101010 stripe at bottom)
# 3. Play any video → frames visible (no black/white overlay) → OSD scrubber + buttons clickable
# 4. Hard-refresh and repeat 3 to catch first-paint regressions
# Headless equivalent (until darkPct lands in v2):
docker run --rm --userns=host --network container:jellyfin-dev \
mcr.microsoft.com/playwright/python:v1.49.0-noble \
bash -c 'pip install --quiet playwright==1.49.0 && python -c "..."'
```
---
## Summary
The theme is fragile in one specific way: any new opaque background rule on a wrapper class can hide the video. The two-class system (`arrflix-themed` + `arrflix-video-active`) plus the L1/L2 paired rules is the structural defence. The CI gates above would be the automated safety net. Until those exist, this checklist + the layer model is the best you have.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long