# THEMING — how to edit the ARRFLIX theme without breaking it

> Short, actionable companion to `docs/31-theme-layer-model-and-edit-guide.md`.
> Read 31 once for the why; come back here for the checklist every edit.

## TL;DR — checklist before EVERY theme edit

1. Read `docs/31-theme-layer-model-and-edit-guide.md` (canonical layer model).
2. Decide: **am I painting an ancestor of `<video>`?** (see layer table below).
3. If yes → scope with `body.arrflix-themed:not(.arrflix-video-active)` AND add the matching transparent rule under `body.arrflix-themed.arrflix-video-active`.
4. Use the specificity table to predict what wins. `!important` does NOT promote specificity.
5. Edit `bin/inject-middle-theme.py` — NEVER hot-patch the deployed overlay.
6. `python3 bin/inject-middle-theme.py` → `scp web-overrides/index.html dev:/opt/docker/jellyfin-dev/web-overrides/` → `docker restart jellyfin-dev`.
7. Hard-refresh browser (`Ctrl+Shift+R`) — bind-mount serves stale otherwise.
8. Run `testing/SMOKE-TEST.md` (login, home, video, OSD).
9. Green? Promote per `testing/DEPLOY.md`. Red? `testing/ROLLBACK.md`.

## The layer model (condensed)

| Layer | Element | bg ownership |
|------:|---------|--------------|
| 0 | `<html>` | `#000` (JS inline-style pinned via `setProperty(...,'important')`) |
| 1 | `<body>` | L1 `#000` off-video / L2 `transparent` on-video |
| 2 | `.backgroundContainer` / `.skinBody` / `#reactRoot` | follows L1/L2 |
| 3 | `.mainAnimatedPages` / `.pageContainer` | follows L1/L2 |
| 4 | `.skinHeader` | `#000` off-video, `display:none` on-video |
| 5 | `.videoPlayerContainer` (z:1000) → `<video.htmlvideoplayer>` | transparent on-video |
| 6 | `.osdControls` / `.videoOsdBottom` (z:~1100–1500) | DO NOT touch — Jellyfin owns |
| 7 | `.dialogContainer` / `.actionSheet` (z:~2000+) | DO NOT touch — Jellyfin owns |

## Specificity quick reference

| Selector | (a,b,c) | When to use |
|----------|---------|-------------|
| `body` | (0,0,1) | almost never |
| `body.arrflix-themed` | (0,1,1) | base theme rule, off/on video both |
| `body.arrflix-themed:not(.arrflix-video-active)` | (0,2,1) | **L1**: off-video bg paint |
| `body.arrflix-themed.arrflix-video-active` | (0,2,1) | **L2**: on-video transparent |
| `body.arrflix-themed.arrflix-video-active #videoOsdPage` | (1,2,1) | beats Cineplex `#videoOsdPage .pageContainer` (1,1,0) |
| `body.arrflix-video-active:not(:has(#loginPage:not(.hide))) .skinHeader` | (0,4,2) | beats Cineplex `display:flex` on header |

L1 and L2 tie on (0,2,1). **Source order decides** — L2 must come AFTER L1 in `inject-middle-theme.py`. Reordering reopens the black-screen bug.

## DO NOT DO

- Set `z-index` on `<video>` or `.videoPlayerContainer` above 1000 → covers OSD scrubber/buttons (image-12 incident).
- Add `background-color` rules without `:not(.arrflix-video-active)` gate → black-screen-over-video.
- Hot-patch `/opt/docker/jellyfin/web-overrides/index.html` in place → repo↔prod drift, INC1 root cause.
- `cp` overlay then skip `docker restart jellyfin` → bind-mount inode swap, container serves stale.
- Use `:has(.htmlVideoPlayer)` (camelCase) — class is `.htmlvideoplayer` lowercase. The selector silently never matches.
- Drop a `<video>` literal into `branding.xml` `<CustomCss>` (even in a comment) without escaping → XML parse fails silently, theme disappears site-wide. Use `&lt;video&gt;`.
- Add `!important` hoping it beats a higher-specificity rule. Among `!important` rules, specificity still wins.

## When to add to L1/L2 paired rules

If your rule paints `background`, `background-color`, or `background-image` on **any** of:

```
body, html, .backgroundContainer, .skinBody, .mainAnimatedPage, .mainAnimatedPages,
.pageContainer, #reactRoot, .videoPlayerContainer, #videoOsdPage, .libraryPage,
video.htmlvideoplayer, .emby-scroller, .backdropContainer
```

→ Add the selector to **BOTH** lists in `bin/inject-middle-theme.py`:

- **L1 list** — under `/* --- L1: PURE-BLACK BG (off-video only) ------ */`, prefixed with `body.arrflix-themed:not(.arrflix-video-active)`.
- **L2 list** — under the L2 transparent block, prefixed with `body.arrflix-themed.arrflix-video-active`, value `background:transparent !important`.

Always paired. Off-video must stay opaque black; on-video must be transparent so `<video>` pixels show through.

## Safe-edit recipe — "make the search input focus ring red"

```bash
# 1. Edit the injector (NOT the deployed overlay)
$EDITOR /tmp/arrflix-recon/bin/inject-middle-theme.py
# Add inside the CSS string, search-input section:
#   body.arrflix-themed .searchFields input:focus {
#     border-color: #E50914 !important;
#     box-shadow: 0 0 0 2px rgba(229,9,20,.35) !important;
#   }
# Specificity (0,2,1) — does NOT touch a <video> ancestor → no L1/L2 pairing needed.

# 2. Regenerate the overlay
cd /tmp/arrflix-recon && python3 bin/inject-middle-theme.py

# 3. Sanity: exactly one marker block
grep -c ARRFLIX-MIDDLE-THEME-BEGIN web-overrides/index.html   # = 1

# 4. Push to dev only
scp web-overrides/index.html nullstone:/opt/docker/jellyfin-dev/web-overrides/index.html
ssh nullstone 'docker restart jellyfin-dev'

# 5. Verify served
curl -s https://dev.arrflix.s8n.ru/web/index.html | grep -c ARRFLIX-MIDDLE-THEME-BEGIN  # = 1

# 6. Hard-refresh browser, run testing/SMOKE-TEST.md, then promote per testing/DEPLOY.md.
```

## How to add a new skin variant

Skin variants are alternative CSS blocks that swap a single visual concern (selector highlight, header logo treatment, etc.) without forking the whole theme.

- Location: `web-overrides/skins/`.
- Naming: `<concern>-variant-<NN>-<short-slug>.css` (e.g. `selector-variant-02-red-underline.css`).
- Format: file-level comment header explaining what concern it replaces, which variant is currently active in `inject-middle-theme.py`, and a "drop into the CSS string" instruction. Body is plain CSS scoped under `body.arrflix-themed …`.
- Activation: copy the rules into the matching section of `bin/inject-middle-theme.py`, regen overlay, deploy. Skins are NOT auto-loaded — the file is a parking spot.

## Common pitfalls

- **camelCase vs lowercase classes** — `.htmlVideoPlayer` does NOT exist; the real class is `.htmlvideoplayer`. Same trap on `.videoOsdBottom` (correct) vs `.videoosdbottom` (wrong).
- **Cineplex CSS load order** — `branding.xml` → `@import url('/web/cineplex.css')` is injected as a `<style>` AFTER our inline block. On equal specificity Cineplex wins. Bump specificity, do NOT reorder.
- **`branding.xml` XML parse** — `<CustomCss>` content must be XML-safe. Escape `<` `>` in any CSS comment that mentions HTML tags. Silent failure = whole branding skipped.
- **iframes / shadow DOM** — Jellyfin web does not currently use either. N/A; skip.
- **`backdrop-filter: blur()`** — only renders if there's content scrolling/painted behind the fixed element. On a pure-black bg the blur is invisible (no pixel diff). Test on a page with a poster backdrop.
- **`getComputedStyle(html).backgroundColor` returns `rgba(0,0,0,0)`** despite stylesheet rules — Chromium root-canvas quirk. We pin `<html>` via JS `style.setProperty('background-color','#000','important')`. Don't fight it from CSS.

## See also

- `docs/31-theme-layer-model-and-edit-guide.md` — canonical layer model and history of past incidents.
- `testing/ERROR-PATTERNS.md` — catalog of past mistakes (INC1–INC7, v6-stable, image-12).
- `testing/SMOKE-TEST.md` — 4-step manual verify after any theme change.
- `testing/HEADLESS-PROBE.md` — Playwright recipes for DOM / `darkPct` / OSD-visible assertions.
- `testing/DEPLOY.md` / `testing/ROLLBACK.md` — promote-to-prod and revert procedures.
