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.
13 KiB
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, orbranding.xml. Five black-screen-over-video incidents in 24 hours (doc 26 INC1–INC5, doc 28 INC7, doc 30 v6-stable, plus this latest one) all came from the same anti-pattern: an opaquebackground-colorrule 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
- Does my rule paint
background-color,background, orbackground-imageon 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.
- Yes → scope it with
- Does my rule set a
z-indexon<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 (1100–2000). Lifting the player above that obscures the controls — see image #12 incident, this very doc. - No → safe to z-index whatever.
- Yes → STOP. Don't. OSD controls (scrubber, buttons, settings panel) sit above
- 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>→<video>. 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>).
- If the rule lives in
- 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 |
~1100–1500 (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].AccessTokenexists, 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.hashcontains/video#videoOsdPage:not(.hide)exists in DOMvideo.htmlvideoplayer:not(.hide)exists and isdisplay: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)
web-overrides/index.html<style>(top, lines 1–63) — critical CSS, painted before bundle. Includes the originalhtml, body, .preload, ... { background-color: #000 !important }rule.<style>ARRFLIX-MIDDLE-THEME-BEGIN/END</style>— our middle-theme rules. Inserted just before</head>. After the critical CSS.- 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. branding.xmlCustomCss— 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> → <video> |
| 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
- Read the layer table above. If your selector lands on layer 0–4, scope with
body.arrflix-themed:not(.arrflix-video-active). - Put the rule in
bin/inject-middle-theme.py, in the section that matches its purpose (header layout, search input, etc.). - Run
python3 bin/inject-middle-theme.pyto re-emitweb-overrides/index.html. - scp to dev only (don't touch prod yet).
- Hard-refresh dev login → confirm no visual regression.
- Hard-refresh dev → click any video → play 5+ seconds → confirm video pixels visible. If black: revert.
- 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
- Confirm the selector lands on a real ancestor of
<video>(open DevTools, navigate up the DOM tree from the video element). - Add it to BOTH L1 (opaque) and L2 (transparent) selector lists. Always paired so the off-video state stays black.
- 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 < >. |
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:
# 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.