legacy-arrflix/docs/31-theme-layer-model-and-edit-guide.md
s8n 1ed55152b7 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.
2026-05-09 22:41:51 +01:00

13 KiB
Raw Permalink Blame History

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:

# 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.