Landing: rig name decode/scramble in top HUD; CLAUDE.md docs sync

Landing's top-left identifier upgraded from "OPHI-118" to
"OPHI-118 // [<RIG>]" where the rig portion (read from
window.__TEL.rig in landing/telemetry.js) does a cyberpunk
decoder reveal on first paint and re-glitches briefly every
~9 s. Bracket dimming + amber flash during scramble keep the
layout width-stable.

CLAUDE.md updated to reflect everything since the last sync:
new Landing rig display, Music Box terminal pattern + dedicated
chat status strip, widget at 549×880 (camera-paired), Desktop
HUD fork, bridge nextTitles[] payload, bot !queue/!info/per-user
cooldown + Mattermost going-live notifier (MM_HOOK / STATUS_URL).
Plus an OBS-detection note: Flatpak's inner binary is `obs`, not
`flatpak run …` — `flatpak ps` or `pgrep -x obs` are the only
reliable checks. The naive `pgrep -fa 'flatpak run …'` returns
"off" while OBS is up and led to JSON edits during a live session.
This commit is contained in:
Jakub Zych
2026-04-27 03:45:47 +02:00
parent 628ba98d3b
commit 5a56e2f8e9
2 changed files with 118 additions and 13 deletions

View File

@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Current state (read this first)
This is the active OBS Studio config dir for the **ophi118** Twitch streaming rig. It hosts: five scenes (Landing, Project Loading, Game, Desktop, Good Bye), four custom HTML/CSS/JS overlays rendered as `browser_source`, an MPD-based audio architecture with a state bridge to OBS WebSocket, and a Twitch chat bot that controls MPD via `!skip`. Two rigs share this codebase via a single git repo: **Ignia** (current) and **Midgolem** (deployment target). Bring up a new rig by running `bash scripts/deploy-rig.sh` after `git clone`. All `.sh` automation lives in `scripts/`; everything Python lives where it serves (`bridges/`, `twitch-bot/`).
This is the active OBS Studio config dir for the **ophi118** Twitch streaming rig. It hosts: six scenes (Landing, Project Loading, Game, Desktop, Music Box, Good Bye), six custom HTML/CSS/JS overlays rendered as `browser_source`, an MPD-based audio architecture with a state bridge to OBS WebSocket, a Twitch chat bot that controls MPD via `!skip`/`!queue`/`!info`, and an optional Mattermost notifier that posts when the channel goes live. Two rigs share this codebase via a single git repo: **Ignia** (current) and **Midgolem** (deployment target). Bring up a new rig by running `bash scripts/deploy-rig.sh` after `git clone`. All `.sh` automation lives in `scripts/`; everything Python lives where it serves (`bridges/`, `twitch-bot/`).
## What this directory is
@@ -28,7 +28,7 @@ Or via the app menu ("OBS Studio"). The native `obs` binary still exists from `p
**OBS-owned (don't edit while OBS is running — see "OBS overwrites on exit"):**
- `user.ini` / `global.ini` — selects active scene collection and profile, plus window state. `user.ini` is the live one (currently collection `Default Stream HUD`, profile `ophi118`). Don't touch geometry/dock-state lines (huge base64 blobs OBS owns).
- `basic/scenes/<name>.json`**the scene collection**. One file = one full set of scenes + sources + transitions + audio routing. Active: `Default_Stream_HUD.json` (display name `Default Stream HUD`). Scenes inside: `Landing`, `Project Loading`, `Game`, `Desktop`, `Good Bye`.
- `basic/scenes/<name>.json`**the scene collection**. One file = one full set of scenes + sources + transitions + audio routing. Active: `Default_Stream_HUD.json` (display name `Default Stream HUD`). Scenes inside: `Landing`, `Project Loading`, `Game`, `Desktop`, `Music Box`, `Good Bye` (Ctrl+M switches to Music Box; Ctrl+, to Desktop; Ctrl+. to Game).
- `basic/profiles/<name>/` — encoder, streaming service, recording settings (separate from scenes). Active profile: `ophi118`.
- `plugin_config/<plugin-id>/` — per-plugin settings. `obs-websocket/config.json` holds the WebSocket password — secret.
- `plugin_manager/modules.json` — third-party plugins OBS loads. Currently lists only `linux-pipewire-audio` (the bundled-into-flatpak version, same source ids as the AUR plugin it replaced).
@@ -37,7 +37,7 @@ Or via the app menu ("OBS Studio"). The native `obs` binary still exists from `p
- `.sentinel/run_<uuid>` — running-instance lockfile; safe to delete only if no OBS process is alive.
**Custom overlays (browser sources rendering local HTML):**
- `landing/index.html` — Fallout-style "PLEASE STAND BY" overlay. Self-contained: HUD + cyan ring + accent triangle + footer + animated static/scanlines/vignette. Reads `landing/telemetry.js` for the rig and hardware info shown on screen.
- `landing/index.html` — Fallout-style "PLEASE STAND BY" overlay. Self-contained: HUD + cyan ring + accent triangle + footer + animated static/scanlines/vignette. Reads `landing/telemetry.js` for the rig and hardware info shown on screen. Top-left HUD identifier is `OPHI-118 // [<RIG>]` where the rig portion does a cyberpunk decode/scramble reveal on first paint and re-glitches every ~9s.
- `landing/static-hum.wav` — 12-second seamless CRT static loop, generated via `ffmpeg -filter_complex` (white+brown noise + 60Hz hum, filtered + crossfaded). Loaded by an `ffmpeg_source`.
- `landing/telemetry.json` / `telemetry.js` — hardware + OBS + rig snapshot. JSON is hand-editable (or interactively curated via `scripts/telemetry.sh --collect`), JS is the auto-generated `<script>`-injectable wrapper. The JSON's `rig` field is consumed by the Loading overlay too.
- `loading/index.html` — terminal-style Project Loading overlay. Reads `loading/loading.json` (manifest), `loading/playlist.json` (track count), `landing/telemetry.js` (rig name), and listens on OBS WS for `mpd:state` updates from the bridge.
@@ -45,14 +45,17 @@ Or via the app menu ("OBS Studio"). The native `obs` binary still exists from `p
- `loading/playlist.json` / `playlist.js` — flat library index built by `scripts/playlist.sh`.
- `loading/cmd.js` — legacy daemon command file (`skip`/`prev`/`pause`/`resume`), polled at 250 ms by `music/index.html`. Written by `scripts/playlist.sh skip` etc. Functionally superseded by direct MPD control (see Twitch bot) but still wired for browser-side commands.
- `game/index.html` — Game scene HUD. TRANSMISSION ID + SIGNAL + UTC clock (top), game/subtitle (top center), camera frame (right), status bar with mic + now-playing (bottom). Live-syncs the camera-frame transform via OBS WS (`SceneItemTransformChanged`). See "Game scene HUD — browser-resolution constraint" below.
- `desktop/index.html` — Desktop scene HUD. Forked from `game/index.html`: same top HUD + camera-frame OBS-sync, but no game-name slot (status bar reads "DESKTOP"), no manifest dependence, no status-bar music widget (now-playing lives in the dedicated `music-box/widget.html` source below the camera). Defaults to `?scene=Desktop&camera=Camera`.
- `goodbye/index.html` — sign-off overlay (Good Bye scene). Same terminal style as Loading; subscribes to `mpd:state`.
- `music-box/index.html` — Music Box scene full-screen overlay. Terminal-styled (boot sequence types `music --boot --shuffle` + `[audio]` lines; track changes log `[audio] now: <title>` into the scrolling history). Pinned at the bottom of the terminal: 3-up `next/then/then` lines + arrow-pulse now-playing line. Below the terminal sits a dedicated **status strip** that always shows the chat command help (`!skip · !queue · !info`) — kept outside the scrolling area on purpose so it stays visible no matter what the terminal is showing. Subscribes to `mpd:state`; reads `nextTitles[]` from the bridge.
- `music-box/widget.html` — compact 549×880 sidebar widget for Desktop scene. Cyan-frame panel that pairs visually with the camera (camera is 1920×1080 × 0.286 ≈ 549×309; widget sits 18 px below at the same width). Same terminal pattern as the full-screen Music Box: scrolling history of `[audio] …` log lines on top, pinned 3-up next + arrow-pulse now-playing + chat-help line at bottom. Pinned block is **static HTML** (resilient to JS errors — the structure shows even if a script throws). **Must be authored at native pixel size** — scene item uses `bounds_type: 0` (no stretch).
- `music/index.html` — shared "Music Daemon" browser source. Predates the MPD bridge; today serves mainly to receive `cmd.js` polling for legacy command paths.
- `vendor/obs-ws-mini.js` — minimal OBS WebSocket v5 client (HMAC-SHA256 auth, CEF `file://` `crypto.subtle` fallback). Used by every overlay.
- `vendor/obs-config.js` — auto-generated WS connection config (URL + password) extracted by `scripts/setup.sh`. Gitignored.
**Audio + automation surfaces (no OBS involvement until events flow through the bus):**
- `bridges/mpd-state.py` — polls MPD at 1 Hz + on track change, broadcasts `mpd:state` CustomEvents to the OBS WebSocket bus. Run as `obs-mpd-bridge.service` (systemd --user).
- `twitch-bot/bot.py` — connects to Twitch IRC as `vault118` (separate from broadcaster `ophi118`), listens for `!skip`, calls MPD directly. Run as `obs-twitch-bot.service` (systemd --user).
- `twitch-bot/bot.py` — connects to Twitch IRC as `vault118` (separate from broadcaster `ophi118`). Commands: `!skip` (per-user 60s cooldown — `SKIP_COOLDOWN`), `!queue` (peek next 3 tracks), `!info` (full metadata: artist · title · album · year · genre). When `MM_HOOK` is set in `.env`, a second async task polls the public `STATUS_URL` (default `https://ophi118.com/api/status.json`) every 60 s and POSTs a Mattermost webhook on each offline→live transition. Transition detection keys on Twitch's `started_at`, not just `live`, so a Helix flap inside one stream doesn't re-fire. Cold start mid-stream is intentionally silent (records the current `started_at`, awaits the next genuine transition). Run as `obs-twitch-bot.service` (systemd --user).
- `twitch-bot/search-game.py`, `set-channel.py`, `_twitch.py` — Twitch Helix helpers used by `scripts/loading.sh` to resolve game IDs and update the channel title/category.
- `twitch-bot/.env`, `.env.ophi118` — secrets for the bot and broadcaster tokens. **Per-rig, regenerate on each machine, never copy.**
@@ -118,10 +121,10 @@ If `ss -tlnp | grep 4455` shows nothing, OBS's WS server is disabled — flip `T
| Event | Publisher | Consumers |
|---|---|---|
| `mpd:state` | `bridges/mpd-state.py` (1 Hz + on track change) | `loading/index.html`, `game/index.html`, `goodbye/index.html` |
| `mpd:state` | `bridges/mpd-state.py` (1 Hz + on track change) | `loading/index.html`, `game/index.html`, `desktop/index.html` (camera sync only), `goodbye/index.html`, `music-box/index.html`, `music-box/widget.html` |
| `mpd:cmd` | `music/index.html` (legacy; reads `loading/cmd.js`) | self-handled inside the music daemon |
`mpd:state` payload: `{_type, index, total, title, file, currentTime, duration, paused}` — kept identical to the original pre-bridge daemon's broadcast so overlays didn't need to change.
`mpd:state` payload: `{_type, index, total, title, artist, trackTitle, file, currentTime, duration, paused, nextTitle, nextArtist, nextTrackTitle, nextFile, nextTitles}`. `nextTitles` is an array of up to 3 display strings peeked from the queue starting at the current song's successor (used by the Music Box overlays to show 3-up "coming next"). The single `nextTitle`/`nextArtist`/`nextTrackTitle`/`nextFile` fields mirror `nextTitles[0]` for overlays that only need one. All `next*` fields are populated when MPD has a queued follow-up (`status.nextsong` present); they're `None`/`[]` at the end of the queue.
## Systemd user units
@@ -147,7 +150,7 @@ journalctl --user -u obs-twitch-bot -f
| Account | Purpose | Token file | Scopes |
|---|---|---|---|
| `ophi118` (broadcaster) | Channel title/category updates by `set-channel.py` | `twitch-bot/.env.ophi118` | `channel:manage:broadcast` |
| `vault118` (bot) | Reads `!skip`, posts now-playing | `twitch-bot/.env` | `chat:read`, `chat:edit` |
| `vault118` (bot) | Handles `!skip` / `!queue` / `!info`, posts now-playing, optional Mattermost going-live notifier (outbound HTTP only — no extra Twitch scopes needed) | `twitch-bot/.env` | `chat:read`, `chat:edit` |
| `ophi118` (stream key) | RTMP stream key | `basic/profiles/ophi118/service.json` | n/a |
All three are gitignored. **Do not copy between rigs** — regenerate via https://twitchtokengenerator.com (for OAuth tokens) and the Twitch Dashboard (for the stream key).
@@ -180,7 +183,7 @@ These hardcoded paths exist in `basic/scenes/Default_Stream_HUD.json`. On a fres
**Identical across rigs** (commit and reuse):
- `basic/scenes/Default_Stream_HUD.json` — scenes + transitions + audio routing
- `basic/profiles/ophi118/{basic,streamEncoder,recordEncoder}.json` — encoder/output settings (minus `service.json`)
- All HTML overlays under `landing/`, `loading/`, `game/`, `goodbye/`, `music/`
- All HTML overlays under `landing/`, `loading/`, `game/`, `desktop/`, `goodbye/`, `music-box/`, `music/`
- All Python in `bridges/`, `twitch-bot/`
- All scripts in `scripts/`
- `vendor/obs-ws-mini.js`
@@ -190,12 +193,23 @@ These hardcoded paths exist in `basic/scenes/Default_Stream_HUD.json`. On a fres
- **Was:** native Arch `obs-studio` (no browser support) + AUR `obs-linuxbrowser-source` (id: `linuxbrowser-source`).
- **2026-04-25:** migrated to Flatpak `com.obsproject.Studio` with bundled CEF browser source (id: `browser_source`). Settings keys: `url`, `width`, `height`, `fps` map directly; CEF requires `fps_custom: true`, `reroute_audio: false`, `restart_when_active: false`, `shutdown: false`, `webpage_control_level: 1`. Stale `linuxbrowser.reloadpage` hotkey keys dropped. Pre-migration backups have been deleted as of 2026-04-26.
- **2026-04-26:** music architecture moved from a browser-side daemon (`music/index.html`) to MPD + a Python state bridge (`bridges/mpd-state.py`). Twitch chat bot (`twitch-bot/bot.py`) added with `!skip → MPD` integration. `station/` submodule added for the public rig-spec page. Game scene HUD added with live OBS WS sync. All `.sh` scripts consolidated under `scripts/`.
- **2026-04-26 (later):** Music Box scene added (`music-box/index.html`, full-screen) plus sidebar widget (`music-box/widget.html`) for the Desktop scene. Desktop scene reworked Game-style — camera in right sidebar with the Music Box widget directly below. Desktop HUD forked from Game HUD into `desktop/index.html` (no game-name slot). Bridge payload extended with `nextTitle`/`nextArtist`/`nextTrackTitle`/`nextFile`. Bot gained `!queue` (next 3 tracks) and `!info` (full metadata) commands; `!skip` cooldown shifted from global 5s to per-user 60s.
- **2026-04-27:** Music Box overlays rewritten as proper terminals (boot sequence + scrolling `[audio] now: …` history + static-HTML pinned block + ellipsis-clipped track titles). Music Box overlay gained a dedicated chat status strip between the terminal and the banner so the command help stays visible regardless of what's scrolling. Music Box widget resized to 549×880 to pair with camera width. Bridge payload gained `nextTitles[]` (array of next 3 display strings) so Music Box can show 3-up "coming next". Bot gained `live_watch()` — optional Mattermost going-live notifier driven by `MM_HOOK` env var. Landing top HUD identifier upgraded to `OPHI-118 // [<RIG>]` with cyberpunk decode/scramble reveal animation reading `window.__TEL.rig` from telemetry.
- If you encounter `linuxbrowser-source` anywhere — in a backup, a doc, a paste — assume it's pre-migration and migrate the same way.
## Critical gotcha: OBS overwrites on exit
OBS rewrites `basic/scenes/<collection>.json`, `global.ini`, and `user.ini` on shutdown (and periodically). **Close OBS before editing these files**, or the next OBS exit will clobber the edit. After editing, re-launch OBS to load the change.
**HTML overlays (`landing/`, `loading/`, `game/`, `desktop/`, `goodbye/`, `music-box/`, `music/`) are safe to edit while OBS is up** — CEF re-reads them on demand. Apply the change with right-click source → **Refresh cache** in OBS, no restart needed.
**Detecting whether OBS is running** — the Flatpak's inner binary is just `obs`, not `flatpak run com.obsproject.Studio`. Use:
```bash
flatpak ps --columns=application | grep -qx com.obsproject.Studio && echo running # canonical
pgrep -x obs # also works
```
Do NOT use `pgrep -fa 'flatpak run com.obsproject.Studio'` — it almost never matches (the wrapper exits, the sandbox keeps running) and silently returns "off" while OBS is very much up. False-negatives here have led to JSON edits during live sessions that got clobbered on the next save.
OBS itself maintains `.bak` (previous save) and `.json.v1` (pre-version-migration) sidecars in `basic/scenes/`. Those are not git history — leave them alone unless explicitly recovering.
## Flatpak permissions — what the sandbox allows
@@ -232,15 +246,17 @@ Source `id` values currently in use:
When editing scene JSON: keep `uuid` values stable across edits, preserve 4-space indentation, and validate with `python3 -m json.tool` before re-launching OBS.
## Game scene HUD — browser-resolution constraint
## Game / Desktop / Music Box HUDs — browser-resolution constraints
The Game scene has a `HUD` browser source (`game/index.html`) stretched (`bounds_type: 1`) to fill the 2560×1336 canvas. **Its `width` / `height` settings must match the canvas (2560×1336)**, not 16:9 like the output resolution. If the browser viewport is 1920×1080, the canvas stretch is non-uniform (1.333× horizontal, 1.237× vertical), which distorts every CSS box relative to the camera's transform (camera is positioned in canvas coords directly, so a CSS-positioned frame around it looks visibly off — wider/squashed than the camera). Symptom: cyan "camera frame" doesn't align with the camera feed inside it. Fix: set browser source `width: 2560, height: 1336` so 1 CSS px = 1 canvas px, then position any frame using canvas coords.
The Game (`HUD`), Desktop (`Desktop HUD`), and Music Box (`Music Box HUD`) browser sources all stretch (`bounds_type: 1`, bounds 2560×1336) to fill the canvas. **Their `width` / `height` settings must match the canvas (2560×1336)**, not 16:9 like the output resolution. If the browser viewport is 1920×1080, the canvas stretch is non-uniform (1.333× horizontal, 1.237× vertical), which distorts every CSS box relative to the camera's transform (camera is positioned in canvas coords directly, so a CSS-positioned frame around it looks visibly off — wider/squashed than the camera). Symptom: cyan "camera frame" doesn't align with the camera feed inside it. Fix: keep all three browser sources at `width: 2560, height: 1336` so 1 CSS px = 1 canvas px, then position any frame using canvas coords.
The Camera frame in `game/index.html` auto-syncs via OBS WebSocket: `GetSceneItemTransform` on connect + `SceneItemTransformChanged` event listener update four CSS vars (`--cam-x/y/w/h`). Move/resize the Camera item in OBS and the frame follows live. Assumes Camera item alignment 5 (top-left) — OBS default; if you change alignment, the math in `setCameraTransform()` needs adjusting.
The Camera frame in both `game/index.html` and `desktop/index.html` auto-syncs via OBS WebSocket: `GetSceneItemTransform` on connect + `SceneItemTransformChanged` event listener update four CSS vars (`--cam-x/y/w/h`). Move/resize the Camera item in OBS and the frame follows live. Assumes Camera item alignment 5 (top-left) — OBS default; if you change alignment, the math in `setCameraTransform()` needs adjusting. The `?scene=` and `?camera=` URL params let either HUD target a different scene or camera source name.
**The Music Box widget** (`music-box/widget.html`) at 549×880 is the exception: it sits in canvas coords as a native-sized scene item (`bounds_type: 0`), NOT stretched. Authoring viewport must match scene-item dimensions; do not toggle `bounds_type` on without resizing the source's `width`/`height` to match the new bounds. The 549 width is deliberate — camera renders at 1920×1080 × 0.286 = 549×309, so the widget pairs visually as a same-width column directly under the camera.
## Working with this directory
- **Confirm OBS is using THIS dir, not the ghost** before assuming an edit will land. While OBS is running: `ls -la /proc/$(pgrep -fx obs)/cwd` and check `lsof -p $(pgrep -fx obs) | grep scenes`. Or compare mtimes after an OBS session — the file OBS rewrites is the live one.
- **Confirm OBS is using THIS dir, not the ghost** before assuming an edit will land. While OBS is running: `ls -la /proc/$(pgrep -x obs)/cwd` and check `lsof -p $(pgrep -x obs) | grep scenes`. Or compare mtimes after an OBS session — the file OBS rewrites is the live one.
- **Inspecting a scene collection:** `python3 -m json.tool basic/scenes/Default_Stream_HUD.json | less`. List scenes: `python3 -c "import json; d=json.load(open('basic/scenes/Default_Stream_HUD.json')); print([s['name'] for s in d['scene_order']])"`.
- **Diffing after an OBS session:** OBS rewrites the file even when nothing visibly changed. Expect noisy diffs; focus on `sources[]` and `scene_order` for meaningful changes.
- **Backing up before edits:** copy the active `.json` somewhere outside this dir — OBS's own `.bak` will be overwritten on the next save.
@@ -274,6 +290,9 @@ pactl list short sinks | grep mpd_stream
# Check OBS WS port is listening
ss -tlnp | grep 4455
# Is OBS running? (flatpak-aware — DO NOT use `pgrep -fa 'flatpak run …'`)
flatpak ps --columns=application | grep -qx com.obsproject.Studio && echo running
```
## User-specific conventions (from global instructions)

View File

@@ -51,6 +51,38 @@ body {
.hud > .group:nth-child(3) { justify-self: end; }
.hud .dim { color: var(--hud-dim); }
.hud .group > span { display: inline-flex; align-items: center; gap: 12px; }
/* "OPHI-118 // <RIG>" station identifier. The rig portion gets a subtle
monochrome glitch — character scramble that resolves back to the real
name every ~9s. CSS-only flicker; the scramble itself is in JS. */
.station {
display: inline-flex;
align-items: baseline;
gap: 0;
white-space: pre; /* preserve the " // " spacing as-is */
}
.station .sep { color: var(--hud-dim); margin: 0 0.45em; letter-spacing: 0.32em; }
.station .rig {
display: inline-block;
color: var(--hud);
text-shadow: 0 0 10px rgba(79, 210, 255, 0.55);
font-variant-numeric: tabular-nums;
/* Subtle width-stable bracket dimming. The brackets sit just outside the
name so the scramble glyphs (which can be wider than 1ch in mono) don't
visually push the layout around. */
}
.station .rig::before,
.station .rig::after {
color: var(--hud-dim);
font-weight: 700;
text-shadow: none;
}
.station .rig::before { content: '['; margin-right: 0.32em; }
.station .rig::after { content: ']'; margin-left: 0.32em; }
.station .rig.glitching {
color: var(--warn);
text-shadow: 0 0 12px rgba(255, 208, 0, 0.5);
}
.hud .led {
display: inline-block;
width: 11px; height: 11px;
@@ -197,7 +229,11 @@ body {
<header class="hud">
<div class="group">
<span><span class="led"></span>TRANSMISSION</span>
<span>OPHI-118</span>
<span class="station">
<span class="station-id">OPHI-118</span>
<span class="sep">//</span>
<span class="rig" id="rigName">--</span>
</span>
</div>
<div class="group">
<span class="dim">SIGNAL</span>
@@ -287,6 +323,56 @@ body {
// 8 fps — visually almost identical to 12 fps but ~33% less paint work.
setInterval(drawNoise, 1000 / 8);
// ───────── Rig name in the top HUD with periodic glitch-decode ─────────
// Reads telemetry.js's window.__TEL.rig (set by telemetry.sh per-rig).
// Behaviour: settles on the real name, then every ~9s briefly scrambles
// each letter through random glyphs and resolves back left-to-right. Same
// effect family as the cyberpunk "decoder" trope; pairs with the CRT
// aesthetic rather than fighting it.
const rigNameEl = document.getElementById('rigName');
const RIG_NAME = (window.__TEL && window.__TEL.rig) || 'UNKNOWN';
// ASCII-only glyph pool — tabular_nums keeps width stable in DejaVu Mono.
const GLITCH_GLYPHS = '01ABCDEFGHIJKLMNOPQRSTUVWXYZ#@*?!§%';
const glyph = () => GLITCH_GLYPHS[(Math.random() * GLITCH_GLYPHS.length) | 0];
const upperRig = RIG_NAME.toUpperCase();
// Initial reveal: scramble for ~400ms, then resolve l→r.
// Subsequent: small idle scramble ~every 9s.
function scrambleOnce({ initial = false } = {}) {
const len = upperRig.length;
if (len === 0) { rigNameEl.textContent = '--'; return; }
rigNameEl.classList.add('glitching');
const totalScrambleMs = initial ? 600 : 360;
const stepMs = 50;
const scrambleSteps = Math.max(1, Math.floor(totalScrambleMs / stepMs));
let step = 0;
const scrambleTimer = setInterval(() => {
let s = '';
for (let i = 0; i < len; i++) s += glyph();
rigNameEl.textContent = s;
if (++step >= scrambleSteps) {
clearInterval(scrambleTimer);
// Resolve left-to-right
let resolved = 0;
const resolveTimer = setInterval(() => {
resolved++;
let s = upperRig.slice(0, resolved);
for (let i = resolved; i < len; i++) s += glyph();
rigNameEl.textContent = s;
if (resolved >= len) {
clearInterval(resolveTimer);
rigNameEl.textContent = upperRig;
rigNameEl.classList.remove('glitching');
}
}, 60);
}
}, stepMs);
}
// First reveal happens shortly after page load; then idle re-glitch.
setTimeout(() => scrambleOnce({ initial: true }), 600);
setInterval(scrambleOnce, 9000);
// ───────── Telemetry: rotating real-data subtitle ─────────
// All lines derive from telemetry.js (window.__TEL), generated by telemetry.sh.
const rigEl = document.getElementById('rig');