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.
195 lines
13 KiB
Markdown
195 lines
13 KiB
Markdown
# 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 INC1–INC5, 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 (1100–2000). 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>` → `<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>`).
|
||
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` | `~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].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 1–63)** — 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>` → `<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
|
||
|
||
1. Read the layer table above. If your selector lands on layer 0–4, 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 `<` `>`. |
|
||
| 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.
|