# 10 - SPA Runtime Branding Shim

> Why the static `<title>ARRFLIX</title>` patch wasn't enough, what the shim does,
> and how to extend it when Jellyfin starts overwriting more state on upgrade.

Last verified: 2026-05-08 against Jellyfin 10.10.3 web bundle.

---

## TL;DR

`/web/index.html` already says `<title>ARRFLIX</title>` and embeds our logo as the
favicon (data URL). The browser tab still showed "Jellyfin" + the Jellyfin teal
triangle because **Jellyfin's SPA overwrites `document.title` at runtime** as it
hydrates. Two compounding effects:

1. **SPA runtime overwrite (primary cause).** Bundle code does
   `document.title = d.Ay.translateHtml(document.title ...)` and per-route updates
   via `LibraryMenu.setTitle` / `Page.setTitle`. Whatever the static `<title>` is,
   the SPA will replace it on first hydrate and again on every navigation.
2. **Service worker churn (secondary).** `serviceworker.js` registers and calls
   `self.clients.claim()`. The shipped SW does NOT actually cache `index.html`
   (it's a notification-only worker), but its presence still pins old clients
   to whatever they had at first paint. Killing the SW once forces a clean reload.

Fix: a tiny self-contained shim in `<head>`, INLINE, BEFORE the bundle scripts.
It enforces the title and favicon both on hydrate and on every later DOM mutation.

---

## What the shim does

Lives in `web-overrides/index.html` between `<!-- ARRFLIX-SHIM-BEGIN -->` /
`-END` markers. Insertion is idempotent via `bin/inject-shim.py` (re-running the
script REPLACES the existing block instead of stacking duplicates).

Behaviour:

| Step | When | What |
|------|------|------|
| 1 | Page parse | `<script>` runs first thing inside `<head>`, before bundle. |
| 2 | DOM ready  | `lockTitle()` strips/replaces "Jellyfin"; `lockFavicon()` re-pins icon hrefs to the data URL already in the page. |
| 3 | Mutation   | A `MutationObserver` on `document.head` re-runs both lockers when the SPA tries to change `<title>` text or any `<link rel*="icon">` href. |
| 4 | Interval   | A 1s `setInterval` is the safety net for late-binding navigations / route changes that bypass the head observer. |
| 5 | Once       | `serviceWorker.getRegistrations()` finds anything pointing at `serviceworker.js` and calls `r.unregister()` so old clients reload fresh. `caches.keys()` is also flushed. |
| 6 | Drawer     | `nukeSettings()` finds any `a[href*="mypreferencesmenu"]` / `[to*="mypreferencesmenu"]` node, walks up to the closest `li` / `.MuiListItem-root` / `[role="menuitem"]` wrapper and sets `display:none`. Wired into `start()`, the head observer body-twin, and the 1s setInterval. |

The shim does NOT re-encode the logo. It reads the existing `data:image/png;base64,...`
href from the `<link rel="shortcut icon">` already in the page. So there's exactly
one copy of the logo data URL in the file.

---

## Files in play

```
/tmp/ARRFLIX/
  bin/inject-shim.py          # idempotent injector; the source of truth for shim content
  web-overrides/index.html    # bind-mounted to /jellyfin/jellyfin-web/index.html in the container
  docs/10-spa-runtime-shim.md # this file
```

Server bind-mount (compose):

```yaml
volumes:
  - /opt/docker/jellyfin/web-overrides/index.html:/jellyfin/jellyfin-web/index.html:ro
```

The container reads the bind-mounted file fresh on each request - no `docker
restart` needed when you re-deploy index.html.

---

## Deploying changes

```bash
# 1. Edit bin/inject-shim.py (NOT index.html directly - the script is the source of truth)
# 2. Re-run injector locally
python3 /tmp/ARRFLIX/bin/inject-shim.py

# 3. Copy to nullstone
scp /tmp/ARRFLIX/web-overrides/index.html user@192.168.0.100:/opt/docker/jellyfin/web-overrides/index.html

# 4. Verify
curl -ks https://arrflix.s8n.ru/web/index.html | grep -oE "ARRFLIX-SHIM-(BEGIN|END)"
curl -ks https://arrflix.s8n.ru/web/index.html | grep -oE "<title>[^<]*</title>"
```

No container recreate is needed; the file is read on every HTTP request.

### Single-file bind mount inode gotcha

The compose volume binds a SINGLE file (not a directory). Docker resolves the
bind once at container start and pins to that inode. If your editor or a
`python os.replace()` writes via temp-file-and-rename, the canonical path on
the host now points to a NEW inode while the container still holds the OLD
inode (visible as `Links: 0` in `stat` inside the container, and as a
`//deleted` suffix in `/proc/<pid>/mountinfo` on the host).

Symptom: you `scp` a new file, the host shows the new content, but
`curl -ks https://arrflix.s8n.ru/web/index.html` still returns the OLD content.

Two safe fixes:

1. **In-place truncate (preferred).** Edit with `cat > /opt/.../index.html`
   redirection or `truncate -s 0` then `cat >>`. This reuses the existing
   inode, so the bind mount keeps showing the new content. The `scp` command
   in step 3 above DOES truncate-in-place (not rename), so the standard
   workflow is safe.
2. **Re-bind via nsenter (if you accidentally orphaned the inode).** Requires
   privileged container + nsenter into the jellyfin mount namespace:

   ```bash
   # Copy current host file into the container's writable /tmp first
   docker cp /opt/docker/jellyfin/web-overrides/index.html \
       jellyfin:/tmp/arrflix-index.html
   CPID=$(docker inspect jellyfin --format "{{.State.Pid}}")
   docker run --rm --privileged --userns=host --pid=host -v /:/host \
       -e CPID=$CPID debian:stable-slim sh -c '
         nsenter -t $CPID -m -p -- umount /jellyfin/jellyfin-web/index.html
         nsenter -t $CPID -m -p -- mount --bind -o ro \
           /tmp/arrflix-index.html /jellyfin/jellyfin-web/index.html
       '
   ```

   This relies on the docker-group sudo-bypass trick. The new bind survives
   until the next container restart, after which Docker re-resolves from the
   host path natively.

---

## First-deploy: forcing existing clients to reload

The shim's `r.unregister()` block runs unconditionally. The first time a user with
an old SW + old `index.html` cached visits the site after this deploy:

1. They get the new `index.html` (bind-mount serves it).
2. The shim runs, unregisters the SW, flushes `caches`.
3. The current page still shows the old behaviour (because the SW already
   intercepted this navigation). They need ONE hard reload (Ctrl+Shift+R or
   Cmd+Shift+R) to get a fully clean session.
4. Subsequent visits are clean.

If you want to STOP unregistering the SW after the wide deploy (e.g. to let
Jellyfin reinstate notification-click handling), edit the script section in
`bin/inject-shim.py`:

```js
if ('serviceWorker' in navigator) {
  // disabled after 2026-05-08 wide rollout
  // navigator.serviceWorker.getRegistrations().then(...);
}
```

Then re-run the injector and redeploy. There is no harm in leaving it on - the
shipped Jellyfin SW only handles `notificationclick`, which we don't use on a
private invite-only service.

---

## Why the JS shim handles drawer Settings (not CustomCss)

The CustomCss block in `/Branding/Configuration` already includes belt-and-braces
selectors for hiding the drawer Settings entry:

```css
a[href*="mypreferencesmenu"],
li:has(> a[href*="mypreferencesmenu"]),
.MuiListItem-root:has(a[href*="mypreferencesmenu"]),
.MuiListItemButton-root:has(a[href*="mypreferencesmenu"]),
[to="/mypreferencesmenu.html"],
.MuiListItem-root:has([to="/mypreferencesmenu.html"]) { display: none !important; }
```

In testing as a non-admin user (`marco`), the entry STILL rendered in the drawer
despite all of those rules. Root cause investigation:

1. The bundle source (`main.jellyfin.bundle.js`) renders the Settings link as
   `(0,a.jsxs)(T.A,{component:C.N_,to:"/mypreferencesmenu.html",...})` where
   `T.A` is MUI `ListItemButton` and `C.N_` is React Router `NavLink`.
2. NavLink in hash-router mode renders `<a href="#/mypreferencesmenu.html">`.
3. The `a[href*="mypreferencesmenu"]` selector SHOULD match. It does at the link
   level, but MUI's `ListItemButton` puts the click target on the wrapping `li`
   which is what the user actually sees as the drawer row.
4. The `:has()` selectors targeting that wrapper depend on browser support for
   CSS `:has()` (Chromium 105+, Firefox 121+, Safari 15.4+) AND on the bundle's
   class names matching `.MuiListItem-root` — which the production build may
   minify or restructure across MUI versions.
5. CustomCss is fetched AFTER the SPA hydrates (see "Pre-bundle critical-path
   styles" above). Even when matching, there's a brief flash where the entry
   is visible.

The JS shim's `nukeSettings()` sidesteps all of that. It runs in `<head>` before
the bundle, on every body mutation, and once per second. It walks up via
`closest()` so it finds the wrapper regardless of MUI class minification, and
it doesn't rely on `:has()` browser support.

The CustomCss rules are kept in place as belt; the JS shim is the suspenders.
Both the server-side `EnableUserPreferenceAccess=false` policy AND the drawer
hide are needed: the policy stops `/mypreferencesmenu.html` from rendering its
controls, the shim stops the link from showing in the first place.

---

## Caveats

- **Owner's first browser session** must hard-reload once after deploy to evict
  the previously-registered service worker. Subsequent reloads, and all
  first-time visitors, get the clean experience.
- **CustomCss** (logo replacement on the in-app drawer) is owned by a sibling
  agent / `/Branding/Configuration`. The shim does NOT touch CustomCss. If the
  in-app drawer logo regresses, that's the CSS branch, not this one.
- **The shim must remain self-contained.** No external `src=`. If it ever needs
  more code, add it to the IIFE in `bin/inject-shim.py` and re-run.
- **First-paint flash.** Because the SPA still loads its own bundle, you may
  briefly see "Jellyfin" in the tab title before the observer kicks in. Sub-100ms
  on a fast connection - acceptable. For the colour flash (dark blue / grey
  before Cineplex CSS arrives), see "Pre-bundle critical-path styles" below.

---

## Pre-bundle critical-path styles

A second inline block — a `<style>` tag — sits immediately AFTER `<head>` and
BEFORE the shim `<script>`. It exists to kill the ~500ms-1s flash of Jellyfin
default chrome (dark blue + grey + MUI blue submit button) that was visible on
first paint at `https://arrflix.s8n.ru` before the Cineplex `@import` from
`/Branding/Configuration` (CustomCss) finished arriving.

### Why CustomCss alone is too slow

CustomCss is fetched via the SPA bundle's call to `/Branding/Configuration` —
i.e. AFTER the JS bundle parses, executes, and hydrates. So the network
sequence is:

1. HTML parses → first paint uses Jellyfin built-in CSS (dark blue / grey).
2. JS bundle downloads, parses, executes.
3. Bundle calls `/Branding/Configuration`, gets `CustomCss` body.
4. `CustomCss` does `@import url("...jsdelivr.net/...cineplex.css")`.
5. jsDelivr round-trip → Cineplex CSS arrives → re-paint to ARRFLIX brand.

Steps 1-4 are dead time. The inline `<style>` runs at step 1 and pre-paints the
black background + Netflix-red submit button so the gap is invisible.

### What's in the block

Lives between `<head>` and the `<script>` shim. Contents:

- `:root` overrides for `--primary-background-color` / `--background-color`
  to `#000000`.
- `html, body, .preload, .skinBody, .skinHeader, #reactRoot, .mainAnimatedPages`
  forced to black bg / white text.
- `.raised, .button-submit, .emby-button[type=submit], button[type=submit]`
  forced to Netflix red `#E50914` so the login submit button doesn't flash MUI
  blue before the bundle skins it.

It is INTENTIONALLY tiny. It only handles the "kill the wrong colours"
critical-path goal. All real branding (logos, fonts, posters, header layout,
hover states, etc.) still comes from Cineplex + ARRFLIX CustomCss as before.

### Maintenance warning on Jellyfin upgrade

This block targets specific Jellyfin class names. If a future Jellyfin web
release renames `.skinBody`, `.skinHeader`, `.preload`, `#reactRoot`,
`.mainAnimatedPages`, `.emby-button`, or the `--primary-background-color`
CSS variable, the pre-bundle paint will regress to defaults until the
selectors here are updated.

After every `jellyfin/jellyfin` image bump:

1. Open `https://arrflix.s8n.ru` in incognito with throttled 3G in DevTools.
2. Confirm the page goes from blank → black (not blue/grey).
3. Confirm the login submit button is red (not blue) before bundle finishes.
4. If either regresses, inspect the new bundle's body class names and update
   `web-overrides/index.html` selectors accordingly.

The block does NOT live in `bin/inject-shim.py` — it's a static `<style>`,
not a script. Edit `web-overrides/index.html` directly and redeploy via the
`scp` step in "Deploying changes" above.

---

## Extending the shim on Jellyfin upgrade

If a future Jellyfin version starts overwriting MORE state (e.g. the manifest
href, the apple-touch-icon, the theme-color meta), extend `start()` in
`bin/inject-shim.py`:

```js
function lockManifest(){
  var m = document.querySelector('link[rel="manifest"]');
  if (m && m.href.indexOf('fd4301fdc170fd202474.json') === -1) {
    m.href = 'fd4301fdc170fd202474.json';
  }
}
function lockMeta(){
  var n = document.querySelector('meta[name="application-name"]');
  if (n && n.content !== 'ARRFLIX') n.content = 'ARRFLIX';
  var t = document.querySelector('meta[id="themeColor"]');
  if (t && t.content !== '#202020') t.content = '#202020';
}
```

Then call them in `start()` and from the `MutationObserver` callback.

The MutationObserver is already configured with `attributes:true,
attributeFilter:['href']`, so href changes on existing nodes trigger a re-lock.
For new attributes, extend the filter list.

---

## Why not just patch the bundle?

Tried. The minified bundle has 30+ `document.title=` sites, plus per-page
`pageManager.setTitle()` calls. Patching all of them would produce a brittle
diff that breaks on every Jellyfin upgrade (bundle hashes change). A 70-line
runtime shim sitting on top of the unmodified bundle is the lower-maintenance
path.

---

## Verification checklist

- [ ] `curl -ks https://arrflix.s8n.ru/web/index.html | grep ARRFLIX-SHIM-BEGIN` returns 1 hit
- [ ] `curl -ks https://arrflix.s8n.ru/web/index.html | grep -oE "<title>[^<]*</title>"` returns `<title>ARRFLIX</title>`
- [ ] In a fresh incognito browser, tab title is "ARRFLIX" on `/web/index.html`
- [ ] After login, navigate to a library or item page - tab title stays "ARRFLIX"
  (or "<page name> - ARRFLIX") and never reverts to bare "Jellyfin"
- [ ] DevTools > Application > Service Workers shows no active worker for
  arrflix.s8n.ru after the first hard reload
- [ ] `curl -ks https://arrflix.s8n.ru/web/index.html | grep -c nukeSettings` returns 4
  (function definition + 3 call-sites: start, body-observer, setInterval)
- [ ] As non-admin user (`marco`), the drawer (hamburger menu) does NOT show a
  "Settings" entry between Profile and Quick Connect / About
