testing/ folder: theme-edit guides + error catalog + headless recipes

7 docs in /testing/ — institutional memory after 6+ regressions in
24-48h on the v6 theme. Read before any edit.

  README.md           — index + quickstart
  THEMING.md          — safe-edit checklist + layer/specificity tables
  ERROR-PATTERNS.md   — 12 cataloged patterns (Symptom/Cause/Diag/Fix/Prev)
  HEADLESS-PROBE.md   — 11 playwright recipes (md5 chain, darkPct,
                        ancestor bg sample, dropdown listItem probe)
  ROLLBACK.md         — 8 emergency revert recipes (overlay, branding,
                        encoding, full-from-repo, dev-clone-prod,
                        git-revert, pw-reset, bind-mount inode-swap)
  SMOKE-TEST.md       — manual + headless verify checklist
  DEPLOY.md           — dev → prod promotion workflow with backup +
                        chown root + restart inode-swap

Empty subdirs: snipUSER-Es/, recipes/, incidents/ (post-mortems land here).

Goal: stop reinventing the same fixes. Catalog every error class,
codify the recovery, build a skills folder for future ARRFLIX work.
This commit is contained in:
s8n 2026-05-10 00:47:20 +01:00
parent 755088e7fc
commit d9d6bdba64
10 changed files with 881 additions and 0 deletions

74
testing/DEPLOY.md Normal file
View file

@ -0,0 +1,74 @@
# DEPLOY — dev → prod promotion workflow
> Strict order. Skip a step → break prod.
## Pre-flight
- [ ] testing/SMOKE-TEST.md passed on dev
- [ ] You can name the change in one sentence
- [ ] You have a rollback target (the current prod md5 — capture before deploy)
## Step 1 — capture current prod md5 (rollback anchor)
```bash
PROD_BEFORE=$(ssh user@nullstone 'md5sum /opt/docker/jellyfin/web-overrides/index.html' | awk '{print $1}')
echo "rollback anchor: $PROD_BEFORE"
```
If anything goes wrong: `cp /opt/docker/jellyfin/web-overrides/index.html.bak.<latest> /opt/docker/jellyfin/web-overrides/index.html` + restart.
## Step 2 — backup prod overlay + branding
```bash
ssh user@nullstone 'set -e
TS=$(date +%s)
docker run --rm --userns=host -v /opt/docker/jellyfin/web-overrides:/d:rw alpine cp /d/index.html /d/index.html.bak.deploy.$TS
docker run --rm --userns=host -v /home/docker/jellyfin/config/config:/d:rw alpine cp /d/branding.xml /d/branding.xml.bak.deploy.$TS
'
```
## Step 3 — copy dev overlay to prod via docker shim (root-owned dest)
```bash
ssh user@nullstone 'docker run --rm --userns=host -v /opt/docker/jellyfin-dev/web-overrides:/dev:ro -v /opt/docker/jellyfin/web-overrides:/prod:rw alpine sh -c "cp /dev/index-dev.html /prod/index.html && chown root:root /prod/index.html && md5sum /prod/index.html"'
```
## Step 4 — bind-mount inode swap requires restart
```bash
ssh user@nullstone 'docker restart jellyfin && sleep 14'
```
## Step 5 — verify
```bash
ssh user@nullstone 'docker exec jellyfin md5sum /jellyfin/jellyfin-web/index.html' # should match dev's md5
ssh user@nullstone 'docker exec jellyfin curl -s http://127.0.0.1:8096/web/index.html | grep -c ARRFLIX-MIDDLE-THEME-BEGIN' # = 1
ssh user@nullstone 'docker exec jellyfin curl -s http://127.0.0.1:8096/Branding/Css.css | wc -c' # ~36000
ssh user@nullstone 'docker ps --format "{{.Names}} {{.Status}}" | grep "^jellyfin "' # healthy
```
## Step 6 — manual smoke on prod
Open arrflix.s8n.ru in incognito. Run testing/SMOKE-TEST.md manual checklist.
## Step 7 — commit + push to repo
```bash
cd /tmp/arrflix-recon
cp web-overrides/index.html snapshots/2026-05-09-v6-stable/index.html # update snapshot
git add bin/inject-middle-theme.py web-overrides/index.html snapshots/2026-05-09-v6-stable/index.html docs/ # whatever changed
git -c user.name=s8n -c user.email=admin@s8n.ru commit -m "<one-line summary>"
git push origin main
```
## If verify fails
Run `testing/ROLLBACK.md` immediately. Don't try to fix forward on prod.
## Common deploy gotchas
- Forget `docker restart jellyfin` after `cp` → bind-mount inode swap → container serves stale
- Forget `chown root:root` → user can't write but root needs to own per host config
- Forget snapshot bump → next "what's deployed" question gets wrong answer
- Forget commit → repo drifts from prod (per doc 26 INC1 root cause)

215
testing/ERROR-PATTERNS.md Normal file
View file

@ -0,0 +1,215 @@
# ERROR-PATTERNS — recurring theme/deploy/playback bugs
> Bookmark this. Every pattern below has happened multiple times. Read before debugging.
## Index of past errors
| # | Pattern | First seen | Last seen | Recurrences |
|---|---------|------------|-----------|-------------|
| 1 | Black screen over video (CSS overlay) | 2026-05-09 INC1 | 2026-05-10 image-12 | 6+ |
| 2 | Video covers OSD controls (z-index too high) | 2026-05-10 image-12 | 2026-05-10 image-12 | 1 |
| 3 | Bind-mount inode swap (container serves stale) | 2026-05-09 backHide | 2026-05-10 v4-selector | 3+ |
| 4 | branding.xml XML parse silent fail | 2026-05-09 v6-stable | 2026-05-09 v6-stable | 1 |
| 5 | test/123 password nuked after docker cp | 2026-05-09 multi | 2026-05-10 multi | 5+ |
| 6 | DB readonly after docker cp (uid 101000) | 2026-05-09 multi | 2026-05-10 multi | 4+ |
| 7 | camelCase class typo (.htmlVideoPlayer) | 2026-05-09 a6cf925 | 2026-05-09 a6cf925 | 1 |
| 8 | Wrong Jellyfin class assumption | 2026-05-09 multi | 2026-05-10 multi | 3+ |
| 9 | HDR10 grey wash (tonemap off) | 2026-05-08 doc 21 | 2026-05-09 fix | 1 |
| 10 | Favicon clobbered by lockFavicon shim | 2026-05-09 favfix | 2026-05-09 favfix | 1 |
| 11 | Backdrop residue / carousel black band | 2026-05-09 multi | 2026-05-10 multi | 2+ |
| 12 | Quick Connect bypass + login mismatch | 2026-05-09 dev | 2026-05-09 dev | 1 |
## ERROR 1 — Black screen over video (CSS overlay)
**Symptom.** `<video>` decodes (`currentTime` advances, `readyState=4`, `videoWidth=1920`, `error=null`, `drawImage` luma >100) but viewport is opaque black. `darkPct=100%`. Doc 28: *"`<video>` is decoding actual pixels — yet a screenshot is all-black. Pixels never reach page composition."*
**Root cause.** Opaque `background-color` on a `<video>` ancestor while `body.arrflix-video-active` set OR `.htmlvideoplayer` in DOM. Offenders: `#videoOsdPage`, `.libraryPage`, `.layout-desktop`, `.pageContainer`, `.skinBody`, `.emby-scroller`.
**Diagnostic.** Probe DOM stack at video centre via `elementsFromPoint`; log `getComputedStyle(el).backgroundColor` per ancestor. `drawImage` luma >50 + screenshot all-black = overlay bug. See doc 28 §"Headless comparison".
**Fix.** Pair L1 (off-video opaque) / L2 (on-video transparent), scoped on body class:
```css
body.arrflix-video-active #videoOsdPage,
body.arrflix-video-active .libraryPage:has(.htmlvideoplayer) {
background: transparent !important;
}
```
**Prevention.** Any new bg-color rule on layer 04 ancestors MUST scope `:not(.arrflix-video-active)`. Add `darkPct` assertion to `bin/headless-test-v2.py` (TODO doc 30/31). Ref docs/26 INC7-final, docs/28 INC7-final, docs/31 layer model.
## ERROR 2 — Video covers OSD controls (z-index hack)
**Symptom.** Frames visible, OSD scrubber/buttons clipped or unclickable.
**Root cause.** Forced `<video> { z-index: 9999 }` to "lift" above an unknown overlay. Stock OSD sits at z 11001500; lifting `<video>` buries the controls.
**Diagnostic.** DevTools → click where scrubber should be → if click target is `<video>`, z-index is wrong.
**Fix.** Revert. Delete the override. Real bug is always opaque ancestors (Error 1). Commit `d4ddf6f` reverted.
**Prevention.** Stock Jellyfin owns z 10002000. Never override. Docs/31: *"If you think you need to z-index `<video>` higher: you don't."*
## ERROR 3 — Bind-mount inode swap
**Symptom.** Host file md5 changed after `cp`/`scp`, but `docker exec md5sum` returns old hash.
**Root cause.** Single-file Docker bind mount tracks inode at container start, not path. `cp src dest` (or scp) creates a NEW inode; container keeps the old one. Docs/31: *"bind-mount inode swap doesn't refresh container view."*
**Diagnostic.**
```bash
ssh user@nullstone 'md5sum /opt/docker/jellyfin/web-overrides/index.html'
ssh user@nullstone 'docker exec jellyfin md5sum /jellyfin/jellyfin-web/index.html'
# Differ → inode swap
```
**Fix.** `docker restart jellyfin` (or `jellyfin-dev`) after every `cp`/`scp` of a single-file bind.
**Prevention.** Deploy: scp → restart → curl-verify md5. Ref docs/26 §A, docs/31 DO-NOT-DO.
## ERROR 4 — branding.xml XML parse silent fail
**Symptom.** Theme partially loads. `curl /Branding/Css.css` returns HTTP 200 with **0 bytes**. No log, no banner. Doc 30: *"Silent XML parse failures with zero UI feedback are the worst class of bug."*
**Root cause.** Unescaped `<` in `<CustomCss>`. CSS comment with `<video>` literal makes XML parser treat it as a child element. Branding loader catches the exception, serves empty CSS.
**Diagnostic.**
```bash
docker run --rm --userns=host -v /home/docker/jellyfin/config/config:/d:ro alpine sh -c \
"apk add --no-cache libxml2-utils >/dev/null 2>&1 && xmllint --noout /d/branding.xml"
curl -s https://arrflix.s8n.ru/Branding/Css.css | wc -c # expect ~36000
```
**Fix.** Escape: `<video>``&lt;video&gt;` for any `<tag>` literal in CSS comments.
**Prevention.** Add `xmllint --noout branding.xml` to CI gate (TODO doc 30/31). Smoke-test `/Branding/Css.css` byte count.
## ERROR 5 — test/123 password nuked after docker cp
**Symptom.** Dev login rejects `test/123` with 401, no password change requested.
**Root cause.** `docker cp` (or in-container cp) on `jellyfin.db` either replaced it with an older copy or triggered SQLite Error 8 readonly that rolled back the password write. Userns-remap leftovers (uid 101000) loop this.
**Diagnostic.**
```bash
ssh user@nullstone 'ls -ln /home/docker/jellyfin-dev/config/data/jellyfin.db' # uid 1000
docker logs jellyfin-dev 2>&1 | grep -iE "readonly|sqlite.*error 8"
```
**Fix.** Stop container; sqlite3 `UPDATE Users SET Password=NULL WHERE Username='test'`; restart; API-set password (doc 28 "Headless comparison" recipe).
**Prevention.** Never `docker cp` a live SQLite file. Stop first. `chown 1000:1000` after any cp into config volume.
## ERROR 6 — DB readonly after docker cp (uid 101000)
**Symptom.** POST `/Users/{id}/Configuration` returns 204 but GET shows field unchanged.
**Root cause.** `jellyfin.db` owned by uid 101000 (Docker userns subuid leftover); container runs as 1000. SQLite throws Error 8 readonly; API returns 204 anyway. Doc 26 §B: *"EVERY user-config save silently fails (HTTP 204 success, value not persisted)."*
**Diagnostic.**
```bash
ls -ln /home/docker/jellyfin/config/data/jellyfin.db # uid must be 1000
docker logs jellyfin 2>&1 | grep -i "readonly\|error 8"
```
**Fix.** `sudo chown -R 1000:1000 /home/docker/jellyfin/config /home/docker/jellyfin/cache && docker restart jellyfin`.
**Prevention.** Never trust 204 — always GET-verify (doc 26 forbidden #4, post-mortem #3). Init-container chowning to 1000:1000 on boot.
## ERROR 7 — camelCase class name typo
**Symptom.** `:has(.htmlVideoPlayer)` (camelCase V) never matches. Body stays opaque → black screen (Error 1).
**Root cause.** Jellyfin's class is **lowercase** `.htmlvideoplayer`. Commit `a6cf925` shipped the typo. Docs/31: *"There is no `.htmlVideoPlayer` (camelCase). Don't confuse them."*
**Diagnostic.** DevTools → search selector. "0 results" while video plays = wrong casing.
**Fix.** Use `.htmlvideoplayer` lowercase OR rely on `body.arrflix-video-active` toggled by JS (preferred — class-on-body robust to DOM changes).
**Prevention.** Read docs/31 layer model BEFORE writing `:has()`. Inspect live DOM, never guess casing.
## ERROR 8 — Wrong Jellyfin class assumption
**Symptom.** CSS rule appears correct but does nothing. e.g. `body.itemDetailPage { ... }` — body's actual class is `libraryDocument` in 10.10.3.
**Root cause.** Jellyfin web class names aren't stable. Doc 26 post-mortem #4: *"Body class on detail pages is `libraryDocument`, not `itemDetailPage`. Use `.itemDetailPage` directly or `:has(.itemDetailPage)`."* Same trap with `.skinHeader` (z:1) vs `.videoPlayerContainer` (z:1000).
**Diagnostic.** `document.body.className`; `document.querySelectorAll('.itemDetailPage').length`.
**Fix.** Use `:has()` on ancestors: `.layout-desktop:has(.itemDetailPage) { ... }`.
**Prevention.** Inspect live DOM in 10.10.3 — never trust forum/older-doc selectors. Ref doc 26 post-mortem #4, doc 31 layer model.
## ERROR 9 — HDR10 grey wash (tonemap off)
**Symptom.** HDR10 source (4K Rick & Morty) renders desaturated grey. NOT pure black — distinct from Error 1.
**Root cause.** `EnableTonemapping=false` while serving HDR10 (`smpte2084` / `bt2020nc` / `yuv420p10le`). ffmpeg passes HDR pixels to SDR transcode without zscale→tonemap→format → wrong colorspace → grey wash. Doc 21 traced for R&M Pilot.
**Diagnostic.** `grep -E "EnableTonemapping|TonemappingAlgorithm" /home/docker/jellyfin/config/config/encoding.xml` — expect `true` + `bt2390`.
**Fix.** `sed -i s|<EnableTonemapping>false|<EnableTonemapping>true|` then `docker restart jellyfin`. Or Dashboard → Playback → Tone Mapping. Commit `1168ba6`.
**Prevention.** Tonemap ON for any library with HDR10 sources. Don't toggle off as "perf fix" — grey wash is worse than slow encode.
## ERROR 10 — Favicon clobbered by lockFavicon shim
**Symptom.** Browser tab shows stock Jellyfin purple-swirl despite ARRFLIX overlay shipping A-mark icon link.
**Root cause.** Jellyfin's `lockFavicon()` runs on `setInterval`, re-pinning its `<link rel="icon">` and overwriting our overlay's link. Two shims race; Jellyfin wins.
**Diagnostic.** `[...document.querySelectorAll('link[rel*=icon]')].map(l => l.href)` — our `data-arrflix-icon="A"` element gone or href swapped.
**Fix.** ARRFLIX shim stamps `data-arrflix-icon="A"` and runs its own re-pin loop on the same interval. Removes stock wordmark links every tick. Commit `1168ba6`.
**Prevention.** Never one-shot DOM writes for elements Jellyfin actively manages (favicon, body class, drawer). Use observe + reapply, or class-on-body.
## ERROR 11 — Backdrop residue / carousel black band
**Symptom.** Backdrop missing on detail-page mid-scroll → black band behind "More from Season N". Or previous item's blurhash sticks after navigation.
**Root cause.** `.backdropContainer` defaults to non-fixed positioning — scrolls out of view (INC2). Sections below paint against body's `#000`. Separately, opaque `.emby-scroller { background:#000 !important }` (originally for home grey strips) leaks into detail-page carousel wrappers (INC4).
**Diagnostic.** DOM-walk every `.scrollSlider` in `.itemDetailPage`, log ancestors with non-transparent computed bg. Locator pattern in doc 26 §INC4.
**Fix.** Pin backdrop `position:fixed; top:0; height:100vh; z-index:0` (INC2). Transparent-scope `.itemDetailPage` wrappers: `.emby-scroller`, `.scrollSliderContainer`, `.detailVerticalSection*`, `.padded-bottom-page`, `.itemsContainer` (INC3+INC4).
**Prevention.** Any new `background:#000 !important` MUST be scoped from day one — never bare `.emby-scroller` (INC4 lesson). Headless test takes top + scrolled (50%) screenshots.
## ERROR 12 — Quick Connect bypass + login mismatch
**Symptom.** Login shows Quick Connect button + user-picker tiles instead of curated ARRFLIX manual-login.
**Root cause.** `system.xml` has `QuickConnectAvailable=true` AND non-admin `IsHidden=false` so picker enumerates. Theme expects QC off and all non-admin hidden.
**Diagnostic.**
```bash
grep QuickConnectAvailable /home/docker/jellyfin/config/config/system.xml
docker exec jellyfin sqlite3 /config/data/jellyfin.db "SELECT Username,IsHidden FROM Users"
```
**Fix.** `QuickConnectAvailable=false` in `system.xml`, restart. `UPDATE Users SET IsHidden=1 WHERE Username != 's8n'`.
**Prevention.** Closed in v6-stable (doc 30). Headless test asserts no `.btnQuickConnect` and no `.cardBox-login` tiles on login.
## Pattern recognition cheat sheet
| If you see... | Likely # |
|---|---|
| black video, audio plays, element decoding | 1 |
| video clipped, OSD controls hidden | 2 |
| local file changed, live page unchanged | 3 |
| empty `/Branding/Css.css` | 4 |
| test/123 401 / sqlite readonly logs | 5+6 |
| selector typo, "rule not applied" | 7+8 |
| HDR content washed-out grey | 9 |
| wrong logo in browser tab | 10 |
| black band behind carousel | 11 |
| Quick Connect / user picker on login | 12 |
## When to add a new error
After ANY incident:
1. Add to index table with date + recurrence count.
2. Add full Symptom / Root cause / Diagnostic / Fix / Prevention.
3. Update cheat sheet if symptom phrasing is novel.
4. If recurrence ≥ 3: add CI gate to `testing/SMOKE-TEST.md`.

209
testing/HEADLESS-PROBE.md Normal file
View file

@ -0,0 +1,209 @@
# HEADLESS-PROBE — playwright + DOM recipes
> Copy-paste these to verify any theme/playback change. All use `mcr.microsoft.com/playwright/python:v1.49.0-noble` with `--userns=host --network container:jellyfin-dev` (or `jellyfin` for prod).
## Setup (one-time per session)
```bash
ssh user@192.168.0.100 'docker pull mcr.microsoft.com/playwright/python:v1.49.0-noble' >/dev/null
mkdir -p /tmp/arrflix-probes
# Run pattern (on nullstone):
docker run --rm --userns=host --network container:jellyfin-dev \
-v /tmp/arrflix-probes:/out -v /tmp/probe-X.py:/probe.py:ro \
mcr.microsoft.com/playwright/python:v1.49.0-noble python /probe.py
```
## RECIPE 1 — auth + pre-seed credentials
Boilerplate every recipe imports. `/Users/AuthenticateByName` returns `{AccessToken, User.Id, ServerId}`. Jellyfin web reads `localStorage['jellyfin_credentials']` on boot — pre-seeding via `add_init_script` skips login.
```python
import asyncio, json, urllib.request
from playwright.async_api import async_playwright
URL='http://127.0.0.1:8096'
USER,PW='test','123'
def auth():
req=urllib.request.Request(f"{URL}/Users/AuthenticateByName",
data=json.dumps({"Username":USER,"Pw":PW}).encode(),
headers={"Content-Type":"application/json","Authorization":'MediaBrowser Client="probe", Device="x", DeviceId="probe-1", Version="1.0"'},method="POST")
return json.loads(urllib.request.urlopen(req,timeout=15).read())
a=auth(); token=a["AccessToken"]; uid=a["User"]["Id"]; sid=a["ServerId"]
```
Pre-seed creds via `add_init_script`:
```python
await page.add_init_script(f"""
localStorage.setItem('jellyfin_credentials', JSON.stringify({{Servers:[{{ManualAddress:'{URL}',Id:'{sid}',Name:'D',UserId:'{uid}',AccessToken:'{token}',DateLastAccessed:Date.now(),UserLinkType:'LinkedUser'}}]}}));
""")
```
If auth returns 401 + `sqlite-readonly` in `docker logs jellyfin-dev`, test password got nuked. Recovery: `docker exec jellyfin-dev sqlite3 /config/data/jellyfin.db "UPDATE Users SET Password=NULL,EasyPassword=NULL WHERE Username='test'"` then `docker restart jellyfin-dev` and POST `/Users/{uid}/Password` with `{NewPw:"123"}`.
## RECIPE 2 — bg-color of every ancestor of `<video>`
Tests L1/L2 transparent rules. Run during playback.
```js
() => {
const v = document.querySelector('video.htmlvideoplayer'); if (!v) return {found:false};
const chain=[];
for (let el=v; el; el=el.parentElement) {
chain.push({tag:el.tagName, cls:String(el.className).slice(0,80), id:el.id, bg:getComputedStyle(el).backgroundColor, z:getComputedStyle(el).zIndex});
}
return {found:true, chain};
}
```
Expect every ancestor `rgba(0,0,0,0)` except `<html>` = `rgb(0,0,0)`.
## RECIPE 3 — darkPct on rendered viewport
Detects "video decodes but is visually black" (doc 28 INC7).
```js
() => {
const v = document.querySelector('video.htmlvideoplayer'); if (!v) return null;
const c = document.createElement('canvas'); c.width=320; c.height=180;
const ctx = c.getContext('2d'); ctx.drawImage(v, 0, 0, 320, 180);
const data = ctx.getImageData(0, 0, 320, 180).data;
let dark=0, total=320*180;
for (let i=0; i<data.length; i+=4) {
const max = Math.max(data[i], data[i+1], data[i+2]);
if (max < 32) dark++;
}
return {darkPct: dark/total, currentTime:v.currentTime, videoWidth:v.videoWidth};
}
```
Expect `darkPct < 0.2` during playback. `> 0.7` = black overlay.
## RECIPE 4 — md5 chain (host → container → served)
```bash
ssh user@nullstone 'md5sum /opt/docker/jellyfin-dev/web-overrides/index-dev.html'
ssh user@nullstone 'docker exec jellyfin-dev md5sum /jellyfin/jellyfin-web/index.html'
ssh user@nullstone 'docker exec jellyfin-dev curl -s http://127.0.0.1:8096/web/index.html | md5sum'
```
All three must match. If host ≠ container: bind-mount inode swap (ERROR-PATTERNS#3) — `docker restart jellyfin-dev`.
## RECIPE 5 — computed style of selector
```js
() => {
const el = document.querySelector('TARGET-SELECTOR-HERE');
if (!el) return {found:false};
const cs = getComputedStyle(el);
const props = ['backgroundColor', 'color', 'outline', 'border', 'zIndex', 'display', 'visibility'];
return {found:true, computed:Object.fromEntries(props.map(p=>[p, cs[p]]))};
}
```
## RECIPE 6 — dump CSS rules matching selector token (cascade debug)
```js
(token) => {
const rules=[];
for (const s of document.styleSheets) {
try { for (const r of s.cssRules) {
if (r.style && r.selectorText && r.selectorText.indexOf(token)>=0)
rules.push({sel:r.selectorText.slice(0,180), css:r.style.cssText.slice(0,200), src:(s.href||'inline').slice(-80)});
}} catch(e){ rules.push({err:String(e).slice(0,40), src:s.href}); }
}
return rules; // later sheets override earlier; arrflix overrides should appear last
}
// page.evaluate("(t)=>{...}", "htmlVideoPlayer")
```
## RECIPE 7 — open dropdown, sample selected listItem
Audio/subtitle picker theme check.
```js
async () => {
const btn = document.querySelector('.btnAudio, .audioMenuButton'); btn?.click();
await new Promise(r => setTimeout(r, 600));
const sel = document.querySelector('.actionSheet .listItem.selected, .actionSheet .listItem-button.selected, .actionSheet .listItem.focused');
if (!sel) return {found:false};
const cs = getComputedStyle(sel);
return {found:true, computed:{outline:cs.outline, bg:cs.backgroundColor, color:cs.color}};
}
```
## RECIPE 8 — full playback smoke (auth + play + sample @5/10/15s)
Reuses Recipe 1 boilerplate. Save as `testing/recipes/smoke-playback.py`:
```python
# (prelude from Recipe 1: auth(), token/uid/sid)
ITEM='324f75b84f394a5d9b0749c0679f23b9'
SAMPLE = """()=>{const v=document.querySelector('video.htmlvideoplayer');
if(!v)return{hasVideo:false};
const c=document.createElement('canvas');c.width=320;c.height=180;
const x=c.getContext('2d');x.drawImage(v,0,0,320,180);
const d=x.getImageData(0,0,320,180).data;let dark=0;
for(let i=0;i<d.length;i+=4)if(Math.max(d[i],d[i+1],d[i+2])<32)dark++;
return{hasVideo:true,t:v.currentTime,w:v.videoWidth,darkPct:dark/57600};}"""
async def main():
async with async_playwright() as p:
b=await p.chromium.launch(headless=True,args=["--ignore-certificate-errors","--autoplay-policy=no-user-gesture-required"])
page=await (await b.new_context(viewport={"width":1280,"height":720})).new_page()
await page.add_init_script(f"localStorage.setItem('jellyfin_credentials',JSON.stringify({{Servers:[{{ManualAddress:'{URL}',Id:'{sid}',Name:'D',UserId:'{uid}',AccessToken:'{token}',DateLastAccessed:Date.now(),UserLinkType:'LinkedUser'}}]}}))")
await page.goto(f"{URL}/web/index.html#/details?id={ITEM}",wait_until="networkidle",timeout=30000)
await page.wait_for_timeout(4000)
try: await page.click('button.btnPlay,.mainDetailButtons .btnPlay,button[title="Play"]',timeout=5000)
except: pass
out=[]
for t in (5,10,15):
await page.wait_for_timeout(5000)
out.append({"at_s":t, **(await page.evaluate(SAMPLE))})
await page.screenshot(path="/out/smoke.png"); print("SMOKE",json.dumps(out,indent=1))
await b.close()
asyncio.run(main())
```
Pass: all three samples `hasVideo:true`, `t` advancing, `darkPct < 0.2`.
## RECIPE 9 — compare two overlays (visual diff)
```bash
# Nullstone: swap+render each, scp screenshots back.
for V in baseline candidate; do
ssh user@nullstone "docker cp /tmp/$V-index.html jellyfin-dev:/jellyfin/jellyfin-web/index.html && docker restart jellyfin-dev"; sleep 8
ssh user@nullstone "docker run --rm --userns=host --network container:jellyfin-dev -v /tmp/probe-real.py:/p.py:ro -v /tmp/arrflix-probes:/out mcr.microsoft.com/playwright/python:v1.49.0-noble python /p.py"
scp user@nullstone:/tmp/arrflix-probes/dev-real-vid.png /tmp/$V.png
done
# Onyx: Pillow diff
python -c "from PIL import Image,ImageChops as C
a=Image.open('/tmp/baseline.png').convert('RGB');b=Image.open('/tmp/candidate.png').convert('RGB')
d=C.difference(a,b);h=d.histogram();print(f'changed={sum(h[1:256])+sum(h[257:512])+sum(h[513:768])} bbox={d.getbbox()}');d.save('/tmp/overlay-diff.png')"
```
## RECIPE 10 — favicon shim (lockFavicon) verify
```js
() => Array.from(document.querySelectorAll('link[rel*="icon"]')).map(l => ({
rel:l.rel, sizes:l.sizes?.value, dataAttr:l.getAttribute('data-arrflix-icon'),
isArrflixA:l.href.indexOf('iVBORw0KGgoAAAANSUhEUgAAAIo')>0,
hrefHead:l.href.slice(0,80)
}))
```
Expect `dataAttr:"1"` + arrflix base64 prefix on every icon link after ~5s (poll interval).
## RECIPE 11 — force `arrflix-video-active` (theme isolation, no real playback)
```js
() => {
const v=document.createElement('div'); v.className='htmlVideoPlayer';
v.style.cssText='position:fixed;inset:0;z-index:5;'; document.body.appendChild(v);
document.body.classList.add('arrflix-video-active');
const els=['body','#reactRoot','.skinBody','.backgroundContainer','.mainAnimatedPages','.pageContainer','.videoPlayerContainer','.htmlVideoPlayer'];
return Object.fromEntries(els.map(s=>{const el=document.querySelector(s); return [s, el?{bg:getComputedStyle(el).backgroundColor,z:getComputedStyle(el).zIndex}:null];}));
}
```
## Where to store probe scripts
`testing/snipUSER-Es/` — one-liners + bash. `testing/recipes/` — full python (recipes 1, 8, 9 live here).

32
testing/README.md Normal file
View file

@ -0,0 +1,32 @@
# testing/
Manual + automated verification + recovery for the ARRFLIX overlay (web-overrides/index.html) + branding.xml + system.xml. Read this folder before any theme edit, deployment, or recovery.
## Index
| File | Purpose |
|------|---------|
| THEMING.md | Safe-edit checklist + layer model + specificity rules (links to docs/31) |
| ERROR-PATTERNS.md | Catalog of every theme/deploy error so far + fixes |
| HEADLESS-PROBE.md | Playwright + DOM-probe recipes for verifying changes |
| ROLLBACK.md | Emergency revert procedures (overlay, branding, full prod) |
| SMOKE-TEST.md | Manual 4-step verify checklist before/after deploy |
| DEPLOY.md | Dev → prod promotion workflow (overlay swap + restart + verify) |
| snipUSER-Es/ | Reusable bash, sqlite, playwright, ssh snipUSER-Es |
| recipes/ | Step-by-step recipes for common tasks (apply theme variant, fix pw, etc) |
| incidents/ | Post-mortems for past + future bugs (referenced from docs/26, 28, 30, 31) |
## Quickstart for "I want to edit the theme"
1. Read `THEMING.md` — pay attention to the layer model + L1/L2 paired rules
2. Edit `bin/inject-middle-theme.py`. Keep it dev-only, scoped under `body.arrflix-themed`
3. `python3 bin/inject-middle-theme.py` to regenerate `web-overrides/index.html`
4. scp the regenerated file to dev's overlay path (NOT prod)
5. `docker restart jellyfin-dev` — bind-mount inode swap requires restart
6. Run `SMOKE-TEST.md` checklist on dev
7. Hard-refresh the browser (Ctrl+Shift+R) — defeats Service Worker + HTTP cache
8. If green: promote per `DEPLOY.md`. If red: revert per `ROLLBACK.md`
## Why this exists
Five+ regressions in 24 hours during 2026-05-09 (docs/26 INC1-5, docs/28 INC7, docs/30 v6-stable). Each one was the same anti-pattern: an opaque CSS rule painted over the video. Combined with bind-mount inode + browser-cache + XML-parse-silent-failures + Cineplex CSS shadow specificity → debugging nightmare. This folder is the institutional memory.

154
testing/ROLLBACK.md Normal file
View file

@ -0,0 +1,154 @@
# ROLLBACK — emergency revert procedures
> When something breaks, follow these recipes. Each is one shell block + verify step.
## When to use this
Any of:
- Live users report black screens, missing UI, can't login
- Headless probe shows `darkPct > 50%` during playback
- `/Branding/Css.css` returns 0 bytes
- Container won't start or stays unhealthy
- `curl -s https://arrflix.s8n.ru/web/index.html | grep -c ARRFLIX-MIDDLE-THEME-BEGIN` returns 0
- Owner says "rollback" — don't debate, restore first, diagnose after
## ROLLBACK 1 — overlay (most common)
Symptom: theme regression, wrong colors, missing features after a deploy. Login or home page renders but looks wrong.
Source: every prod deploy creates a `.bak.pre-<reason>.<unix-ts>` file in `/opt/docker/jellyfin/web-overrides/`. Pick the most recent (or the one matching the last-known-good state — e.g. `index.html.bak.pre-favfix.1778318089` is pre-v6+favfix).
```bash
ssh user@nullstone 'set -e
TS=$(ls /opt/docker/jellyfin/web-overrides/index.html.bak.* 2>/dev/null | sort -V | tail -1)
echo "Restoring from: $TS"
docker run --rm --userns=host -v /opt/docker/jellyfin/web-overrides:/d:rw alpine sh -c "cp $TS /d/index.html && chown root:root /d/index.html && md5sum /d/index.html"
docker restart jellyfin && sleep 12
docker exec jellyfin md5sum /jellyfin/jellyfin-web/index.html'
```
Verify: `curl -s https://arrflix.s8n.ru/web/index.html | grep -c ARRFLIX-MIDDLE-THEME-BEGIN` returns `1`. Then hard-refresh the browser (`Ctrl+Shift+R`) — defeats Service Worker + HTTP cache.
## ROLLBACK 2 — branding.xml
Symptom: `/Branding/Css.css` returns 0 bytes (Cineplex theme stops loading site-wide). Usually caused by an unescaped `<video>` literal or other XML parse failure inside `<CustomCss>` (silent failure — HTTP 200 empty body, no admin alert).
```bash
ssh user@nullstone 'set -e
TS=$(ls /home/docker/jellyfin/config/config/branding.xml.bak.* 2>/dev/null | sort -V | tail -1)
echo "Restoring from: $TS"
docker run --rm --userns=host -v /home/docker/jellyfin/config/config:/d:rw alpine sh -c "cp $TS /d/branding.xml"
docker restart jellyfin && sleep 12
docker exec jellyfin curl -s http://127.0.0.1:8096/Branding/Css.css | wc -c'
```
Expect: `> 30000` bytes (v6-stable serves 36 256 B). 0 bytes = still broken — the backup itself was corrupt; pick an older `.bak.*` and retry.
## ROLLBACK 3 — encoding.xml (HDR / tonemap)
Symptom: HDR10 playback looks washed out / grey, or transcode fails after flipping `EnableTonemapping`. Backup created pre-flip as `encoding.xml.bak.pre-tonemap.1778318089`.
```bash
ssh user@nullstone 'set -e
TS=$(ls /home/docker/jellyfin/config/config/encoding.xml.bak.* 2>/dev/null | sort -V | tail -1)
echo "Restoring from: $TS"
docker run --rm --userns=host -v /home/docker/jellyfin/config/config:/d:rw alpine sh -c "cp $TS /d/encoding.xml"
docker restart jellyfin && sleep 12
docker exec jellyfin grep -E "EnableTonemapping|TonemappingAlgorithm|HardwareAccelerationType" /config/config/encoding.xml'
```
Verify: values match the last-known-good (v6-stable: `EnableTonemapping=true`, `TonemappingAlgorithm=bt2390`, `HardwareAccelerationType=none`). Stop any in-flight HDR10 transcode and re-start it from the client.
## ROLLBACK 4 — full prod = exact state from repo HEAD
When you don't trust the live state (drift, tampering, multiple bad deploys), force prod to match `git origin/main`:
```bash
cd /tmp/arrflix-recon
git fetch origin && git checkout origin/main
md5sum web-overrides/index.html
scp web-overrides/index.html user@nullstone:/tmp/repo-overlay.html
ssh user@nullstone 'set -e
docker run --rm --userns=host -v /opt/docker/jellyfin/web-overrides:/d:rw -v /tmp:/src:ro alpine sh -c "cp /src/repo-overlay.html /d/index.html && chown root:root /d/index.html && md5sum /d/index.html"
docker restart jellyfin && sleep 12
docker exec jellyfin md5sum /jellyfin/jellyfin-web/index.html'
```
Verify: container md5 == repo md5 (v6-stable: `364cc890c58f02d07cf50b43b31a48f0`). `curl -s https://arrflix.s8n.ru/web/index.html | md5sum` should match too.
## ROLLBACK 5 — dev = exact clone of prod
When dev has drifted and you want to reset it to live prod state (for a clean theme test sandbox):
```bash
ssh user@nullstone 'set -e
docker run --rm --userns=host \
-v /opt/docker/jellyfin/web-overrides:/p:ro \
-v /opt/docker/jellyfin-dev/web-overrides:/d:rw \
alpine sh -c "cp /p/index.html /d/index-dev.html && chown 1000:1000 /d/index-dev.html && md5sum /p/index.html /d/index-dev.html"
docker restart jellyfin-dev && sleep 12
docker exec jellyfin-dev md5sum /jellyfin/jellyfin-web/index.html'
```
Note dev's overlay filename is `index-dev.html` (NOT `index.html`) and ownership is `user:user` (1000:1000), unlike prod's `root:root`. Also resync `branding.xml` if needed: source `/home/docker/jellyfin/config/config/branding.xml` → dest `/home/docker/jellyfin-dev/config/config/branding.xml` (backup as `branding.xml.bak.dev-pre-resync` first).
## ROLLBACK 6 — git revert last commit
When the bad change is in repo HEAD and you want it gone from history (then redeploy cleanly):
```bash
cd /tmp/arrflix-recon
git log --oneline -5
git revert HEAD --no-edit
git push origin main
```
Then redeploy via ROLLBACK 4 to push the reverted state to prod. If the bad commit is more than one back, use `git revert <sha>` for each, or `git revert <bad-sha>..HEAD --no-edit` for a range.
## ROLLBACK 7 — recover dev `test`/`123` password
Symptom: dev login broken, can't auth as `test`/`123`. Standard sqlite password-reset cycle (dev only — never prod).
```bash
ssh user@nullstone 'set -e
docker stop jellyfin-dev
DB=/home/docker/jellyfin-dev/config/data/jellyfin.db
cp $DB ${DB}.bak.pre-pwreset.$(date +%s)
docker run --rm -v /home/docker/jellyfin-dev/config/data:/d:rw alpine sh -c "apk add --no-cache sqlite >/dev/null && sqlite3 /d/jellyfin.db \"UPDATE Users SET Password=NULL, EasyPassword=NULL WHERE Username=\x27test\x27;\""
chown -R 1000:1000 /home/docker/jellyfin-dev/config/data
docker start jellyfin-dev && sleep 12'
```
Then in browser: log in as `test` with **blank** password → admin → user `test` → set password to `123`. Verify `curl -X POST https://dev.arrflix.s8n.ru/Users/AuthenticateByName -H 'Content-Type: application/json' -d '{"Username":"test","Pw":"123"}'` returns a token.
## ROLLBACK 8 — bind-mount inode swap (just restart)
Symptom: you changed an overlay file and the served bytes don't match the file on disk. `docker exec jellyfin md5sum /jellyfin/jellyfin-web/index.html` differs from `md5sum /opt/docker/jellyfin/web-overrides/index.html`. Bind-mount captured the old inode; container is serving stale.
```bash
ssh user@nullstone 'docker restart jellyfin && sleep 12 && docker exec jellyfin md5sum /jellyfin/jellyfin-web/index.html'
# or for dev:
ssh user@nullstone 'docker restart jellyfin-dev && sleep 12 && docker exec jellyfin-dev md5sum /jellyfin/jellyfin-web/index.html'
```
This is not a "rollback" per se — it's the cure for any `cp`-without-restart that left the container out of sync.
## Backups directory layout
| Path | Purpose |
|------|---------|
| `/opt/docker/jellyfin/web-overrides/index.html.bak.*` | prod overlay (root:root) |
| `/opt/docker/jellyfin-dev/web-overrides/index-dev.html.bak.*` | dev overlay (user:user, note `-dev` suffix) |
| `/home/docker/jellyfin/config/config/branding.xml.bak.*` | prod branding |
| `/home/docker/jellyfin/config/config/encoding.xml.bak.*` | prod encoding |
| `/home/docker/jellyfin-dev/config/config/branding.xml.bak.*` | dev branding |
| `/home/docker/jellyfin-dev/config/data/jellyfin.db.bak.*` | dev sqlite (user db) |
Keep the **most recent** `.bak` per file; older ones can be deleted (per doc 30 cleanup). Never delete a `.bak.*` you haven't verified is older than the current good state.
## After any rollback
1. **Notify users** — restart drops in-flight stream sessions; if anyone was mid-playback they'll get bumped.
2. **Open an incident** in `testing/incidents/` (post-mortem) — what broke, what backup was used, container md5 before/after, owner-visible impact.
3. **Add the failure mode to `testing/ERROR-PATTERNS.md`** if novel.
4. **Verify v6-stable invariants** — overlay md5 prod==dev, `/Branding/Css.css` > 30 000 B, `EnableTonemapping=true`, login + playback both green via `testing/SMOKE-TEST.md`.

74
testing/SMOKE-TEST.md Normal file
View file

@ -0,0 +1,74 @@
# SMOKE-TEST — pre/post-deploy verify checklist
> Run this on dev BEFORE every prod promotion. Run again on prod AFTER deploy.
## Manual (5 min, browser)
1. **Login pristine** — open dev.arrflix.s8n.ru in a fresh incognito window. Hard-refresh.
- [ ] ARRFLIX red logo top-left
- [ ] No Movies/Series links visible (gated by auth)
- [ ] User+Password fields, red Sign In button, "Welcome to ARRFLIX" footer
- [ ] Background pure black, no #101010 stripe
2. **Login + home** — sign in as `test/123` (dev) or your account (prod). After login:
- [ ] Wordmark logo dead-center in header
- [ ] MOVIES + SERIES uppercase nav links left
- [ ] 🔍 search icon right
- [ ] No My Media row (.section0 hidden)
- [ ] Continue Watching / Next Up / Recently Added rows render
- [ ] No grey stripe at bottom of page when scrolled
3. **Movies / Series navigation** — click MOVIES.
- [ ] MOVIES link gets red glow + bold (variant E active state)
- [ ] No back arrow visible on header
- [ ] No duplicate "Movies" h3 title
- [ ] Library renders or shows spinner (Jellyfin viewContainer)
4. **Search** — click 🔍 icon.
- [ ] Search input has red bottom underline on focus (NOT cyan ring)
- [ ] Suggestions list red text on black
5. **Playback** — click any movie's Play button. Wait 10 seconds.
- [ ] Video frame visible (not black, not white, not grey washed out)
- [ ] OSD scrubber + play/pause buttons + settings icon click-able above video
- [ ] Header bar HIDDEN during playback
- [ ] Letterbox bars (top/bottom of video) are BLACK not white
- [ ] Seek with scrubber works
- [ ] Click settings → audio/subtitle dropdowns show red hairline ring on selected
6. **Browser tab favicon** — check tab.
- [ ] Red ARRFLIX "A" mark (not Jellyfin triangle, not wordmark)
## Headless (1 min, automated)
```bash
ssh user@nullstone '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 /tmp/smoke.py"'
```
(`/tmp/smoke.py` lives in testing/recipes/smoke-headless.py — TODO)
Expected diag output:
```
{
"loginRedSignIn": true,
"wordmarkCenter": true,
"myMediaHidden": true,
"darkPctOnVideoFrame": 0.10,
"osdControlsClickable": true,
"letterboxBlack": true,
"favIconAMark": true,
"selectorOutlineRed": true
}
```
If ANY false: rollback (testing/ROLLBACK.md) and check testing/ERROR-PATTERNS.md for the matching pattern.
## md5 chain check
```bash
ssh user@nullstone 'md5sum /opt/docker/jellyfin/web-overrides/index.html /opt/docker/jellyfin-dev/web-overrides/index-dev.html'
ssh user@nullstone 'docker exec jellyfin md5sum /jellyfin/jellyfin-web/index.html'
ssh user@nullstone 'docker exec jellyfin-dev md5sum /jellyfin/jellyfin-web/index.html'
```
Expected on dev-only deploy: prod ≠ dev (intentional). Container view = host file (else inode swap, restart). Once prod-promoted: all 4 should match.

123
testing/THEMING.md Normal file
View file

@ -0,0 +1,123 @@
# 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:~11001500) | 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 (INC1INC7, 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.

View file

0
testing/recipes/.gitkeep Normal file
View file

View file