# 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>` → `&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` | `~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>` → `&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 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 `&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.
