# contexts.live — hard-earned knowledge

Non-obvious behaviours, browser quirks, and production lessons.
Every item is a bug fixed or an incident survived.

---

## Connection & config

---

## 1. The first-click / autoplay gate

Browsers block audio autoplay without prior user interaction.

Critical details:
- A **silent WAV** is played on first click to unlock the audio context
- `speechSynthesis.speak()` needs its own unlock call
- All incoming socket cues blocked until first click (`state.firstClickDone`)
- Splash element removed after first click

```js
const SILENT_WAV = 'data:audio/wav;base64,UklGRigAAABXQVZFZm10IBIAAAABAAEARKwAAIhYAQACABAAAABkYXRhAgAAAAEA';
```

Keep this exact data URI — it is a valid minimal WAV silence file.

---

## 2. The double-message guard

The server may send cues twice via different paths. Guard:

```js
if (message.hash != null && message.hash !== '' && typeof message.lineNumber === 'number') return;
```

`typeof lineNumber === 'string'` is the canonical test for a real pack cue.

---

## 3. lineNumber === -1 means blank

A line number of `-1` (or string `'-1'`) is the blanking signal.
Every render handler must handle it.

---

## 4. Video element reuse

Do NOT create a new `<video>` element on each cue:

```js
let player = element.querySelector('.cx-video-player');
if (!player) {
    player = document.createElement('video');
    player.setAttribute('playsinline', ''); // essential for iOS
    element.appendChild(player);
}
player.pause();
```

`playsinline` is required on iOS — without it video plays fullscreen.

---

## 5. Audio objects pool

```js
state.audioObjects = {};  // source → Audio instance
if (!state.audioObjects[source]) state.audioObjects[source] = new Audio();
```

One Audio instance per source — `ad`, `st` etc. — so multiple streams can overlap.

---

## 6. The play() promise and AbortError

`audio.play()` returns a promise that rejects with `AbortError` when a
new `src` is set before the previous finishes loading. Normal — ignore it:

```js
audio.play().catch(err => {
    if (err.name === 'AbortError') return;
    tryNext();
});
```

---

## 7. Audio not stopped on blank

`dispatchToElements` does NOT stop audio when `lineNumber === -1`.
Audio plays to natural completion. Stopping on blank was cutting AD audio
mid-play at `cueOut`.

---

## 8. Wake lock

- Request on first socket cue, not on page load
- Only request once (`state.wakeLockSent` flag)
- Always catch errors — not supported everywhere

---

## 9. The inpend sorted insert

```js
const key = div.textContent; // not innerHTML — ignores HTML tags
for (const existing of element.children) {
    const cmp = key.localeCompare(existing.textContent);
    if (cmp === 0) return; // duplicate
    if (cmp < 0) { element.insertBefore(div, existing); return; }
}
element.append(div);
```

---

## 10. tabindex="-1" on text elements

Required for programmatic focus — accessibility and braille readers.
Set at init time by scanning `[data-cx]` elements, not in HTML.

---

## 11. Braille focus-on-update

Focus text element after DOM update so VoiceOver sends to braille device:

```js
new MutationObserver(() => {
    if (updateTimeout) clearTimeout(updateTimeout);
    updateTimeout = setTimeout(() => el.focus(), 200);
}).observe(el, { childList: true, characterData: true, subtree: true });
```

200ms delay ensures DOM update completes before focusing.

---

## 12. The controlArrived flag

Without this, the first line of a `words` list is highlighted on load
before any cue has arrived. `state.controlArrived` set to `true` when
first cue arrives.

---

## 13. Language code aliases

```js
if (value === 'kr') return 'ko';
if (value === 'jp') return 'ja';
```

Real-world users type these wrong.

---

## 14. Cache busting on pack loads

```js
const BUST = '?v=' + Date.now();
fetch(CX.fileBase + 'packs/' + name + '.json' + BUST)
```

Without cache busting, nginx/browser caches serve stale JSON.
Note: JS files themselves (cx-render.js etc.) are NOT cache-busted automatically.
Use a version query on script tags when deploying: `cx-render.js?v=2`.

---

## 15. Scroll-into-view alignment

Always use `block: "center"`:

```js
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
```

`"start"` or `"end"` causes jumpy behaviour near top/bottom.

---

## 16. Key suppression — arrows in capture phase

```js
window.addEventListener('keydown', e => {
    if ([13,32,37,38,39,40].includes(e.keyCode)) e.preventDefault();
}, true); // true = capture phase
```

---

## 17. Audio and auto cleanup on page unload

```js
window.addEventListener('beforeunload', () => {
    for (const audio of Object.values(state.audioObjects)) {
        audio.pause(); audio.src = '';
    }
    state.wordsComponents.forEach(c => c.stopAuto?.());
});
```

`stopAuto()` on unload prevents a timer firing a cue via a socket that's
still technically open during the unload event window.

---

## 18. HTML is the default state

Elements keep their initial HTML until the first real cue arrives.
`dispatchToElements` not called on page load for text elements — only
`words` elements build their line list immediately. Meta elements
(`data-cx="meta title"` etc.) render when the pack loads, not at page load.

---

## 19. CXParam TDZ issue

`const CXParam = (() => { typeof CXParam ... })()` causes a temporal dead
zone error. Fix: assign directly to `window.CXParam` instead of `const`.

---

## 20. RTL language list

```js
const RTL = new Set([
    'ar','ar-dz','ar-bh','ar-eg','ar-iq','ar-jo','ar-kw','ar-lb',
    'ar-ly','ar-ma','ar-om','ar-qa','ar-sa','ar-sy','ar-tn','ar-ae',
    'ar-ye','arc','fa','he','ur'
]);
```

Apply `dir="rtl"` to text elements when language is in this set.

---

## 21. Content resolution — liberal gettext pattern

```js
function resolveLang(obj, lang) {
    if (!obj || typeof obj !== 'object') return typeof obj === 'string' ? obj : null;
    if (lang           && obj[lang]             != null) return obj[lang];
    if (state.language && obj[state.language]   != null) return obj[state.language];
    if (CXParam.lang   && obj[CXParam.lang]     != null) return obj[CXParam.lang];
    return Object.values(obj).find(v => v != null) ?? null;
}
```

---

## 22. Client-side autoplay — timer tracking

Autoplay runs entirely in the browser. `startAuto(fromLine)` schedules
all subsequent lines using `setTimeout`:

```js
const delay = Math.max(0, ((cueIn - baseIn) + nudgeVal) * 1000);
autoTimers.push(setTimeout(() => sendCue(i), delay));
```

All timer IDs are tracked in `autoTimers`. `stopAuto()` clears them all.

Any manual line click calls `stopAuto()` first. Page unload also calls
`stopAuto()` via `beforeunload` — prevents a timer squeezing out a cue
via a still-open socket during the unload window.

Nudge is applied at schedule time — adjusting nudge while auto is running
does NOT reschedule pending timers. Reschedule by clicking Play again.

---

## 23. Auto play state — onPlayStateChange

The words component maintains a `_playing` flag, updated by `stopAuto()`
and `startAuto()`. External code (e.g. a Play/Pause button) subscribes:

```js
comp.onPlayStateChange(fn)  // fn(true/false) called on each change
comp.isPlaying              // getter — current state
```

`stopAuto()` is called by manual line clicks, keyboard shortcuts, and
blank button — all of which fire `onPlayStateChange(false)`. Register
the listener before the first click, or lazily on first interaction.

---

## 24. Per-element pack scoping — shared line number

Currently all pack-scoped elements share `state.lineNumber`. Works when
one operator drives all packs simultaneously. If packs need independent
line tracking, per-pack line state will be needed.

---

## 25. languageLinker must receive whole text

Pass the entire sentence to `languageLinker()`, not word by word.
`languageLinker` needs the full sentence for:
- Phrase matching (multi-word te reo phrases)
- Ambiguous word resolution (words that are only te reo in context)

`window.languageLink` is the per-match callback, not the top-level processor.

---

## 26. window.languageLink not const languageLink

`const languageLink = maoriMeaning` defined in a `<script>` tag is
block-scoped — not visible to `cx-render.js`. Must use `window.languageLink`.

---

## 27. cx-connect.js auto-init suppression

`cx-render.js` sets `window._cxRenderLoaded = true` at the top of the file,
before `cx-connect.js`'s `DOMContentLoaded` listener fires. `cx-connect.js`
checks this flag before calling `CXConnect.init()`. Without this, both
`cx-render.js` and `cx-connect.js` would each try to connect a controller socket.

---

## 28. offset lines strip `<br>` tags

Offset elements (`cx-prev`, `cx-next`) use `white-space: nowrap` for
ellipsis truncation. A `<br>` inside `nowrap` wraps to a second line that
overflows. Fix: strip `<br>` from content before setting innerHTML for
offset ≠ 0 elements.

```js
const offset = params?.offset ? parseInt(params.offset) : 0;
element.innerHTML = offset !== 0 ? text.replace(/<br\s*\/?>/gi, ' ').trim() : text;
```

---

## 29. flex children need min-width: 0

In a flex container, text elements default to `min-width: auto` — they
can overflow rather than wrapping. Add `min-width: 0` to flex children
that contain text.

---

## 30. Socket namespace structure

Three namespaces per org:
- `/{slug}/client` — display clients
- `/{slug}/controller` — controlling operators
- `/{slug}/cueing` — reserved for future external cueing tool

Both client and controller namespaces receive every cue broadcast.

---

## 31. convertToSeconds — all timecode formats

```js
function convertToSeconds(t) {
    if (typeof t === 'number') return t;
    const colonParts = String(t).split(':');
    if (colonParts.length === 4) {
        // HH:MM:SS:FF broadcast timecode
        const frac = Number(colonParts[3]) / 10 ** colonParts[3].length;
        return parseInt(colonParts[0]) * 3600 + parseInt(colonParts[1]) * 60
             + parseInt(colonParts[2]) + frac;
    }
    // MM:SS.ff or SS.ff
    const dotParts = String(t).split('.');
    const whole = dotParts[0].split(':').reverse();
    const frac  = dotParts[1] ? Number(dotParts[1]) / 10 ** dotParts[1].length : 0;
    return parseInt(whole[2]||0)*3600 + parseInt(whole[1]||0)*60 + parseInt(whole[0]||0) + frac;
}
```

The fractional part uses `/ 10^len` not `/ 100` — handles `.5` and `.23`.

---

## 32. liveMeta — runtime state via data-cx

`state.liveMeta` holds runtime values that aren't in the pack JSON.
`setLiveMeta(field, value)` updates the value and immediately re-renders
all `[data-cx]` elements whose source is `meta` and whose field matches:

```js
setLiveMeta('status', '● connected');
setLiveMeta('pack', 'erlkoenig');
setLiveMeta('line', 4);
```

This replaces the old `setStatus()` / `#cx-conn-status` id pattern.
Any page can show connection state with `data-cx="meta status"` — no
element id needed.

---

## 33. meta elements render after pack loads

`data-cx="meta title"` gets its value from `json.meta.title` — the pack
must be loaded first. `cx-render.js` calls `dispatchToElements()` after
the pack loads, which renders all meta elements. Before the pack loads,
meta elements show their initial HTML content (or nothing).

Do not try to render meta elements at page init — the data isn't there yet.

---

## 34. optCharStandalone removed from editor

The "Also emit character rows as standalone lines" option was a v1 workaround.
In v2, speaker names live in the `sp` field on the content row. The option
was removed in editor v2.3.

---

## 35. Editor validation — per-row fatal indicators

Fatal validation errors (cueOut ≤ cueIn, media filename missing
extension) show a red left border on the grid row without requiring the
validation panel to be open.

---

## 36. Editor validation — what goes in the grid vs panel only

**Grid (red left border) + panel:** hard errors that will definitely break
something at runtime — cueOut before cueIn, media filename missing extension.

**Panel only:** warnings and notes that might be wrong but could be
intentional during authoring.

---

## 37. cueIn / cueOut — field name rationale

`timecodeIn` and `timecodeOut` renamed to `cueIn` and `cueOut`.
Both are supported by `cx-render.js` and `server2.js`:

```js
const cueIn = line.cueIn ?? line.timecodeIn;  // timecodeIn is legacy
```

Editor normalises `timecodeIn` → `cueIn` on import.

---

## What does NOT carry forward from v1

- `clid-source_n` — untested, never in production
- YouTube iframe API — HTML5 only
- jQuery — not used
- `parameters['audioST']` / `parameters['audioAD']`
- `_score` pack name suffix
- Synchronous XHR for pack loading
- `tsvToJson` / `txtToJson` — build tools only
- `ack` socket events
- `cue-ad` as a separate socket event
- `hash` and `cueTime` in cue messages
- `harness-for-STT.js` — replaced by `cx-connect.js`
- `cx-panel-*` — renamed to `cx-pane-*`
- `clid-nudge` — nudge now lives on `cx-control-auto.html`
- `#cx-conn-status` / `setStatus()` — replaced by `data-cx="meta status"`
- Server-side autoplay (PRE_FIRE mechanism) — replaced by client-side `auto` modifier
- `stop` and `nudge` socket events — no longer used for autoplay

---

## 38. Safari Web Speech API — empty voices on load

Safari returns an empty array from `speechSynthesis.getVoices()` synchronously on page load. The voices load asynchronously and fire `voiceschanged`. cx-render.js handles this:

```js
if (window.speechSynthesis.getVoices().length) {
    speakNext(0);
} else {
    window.speechSynthesis.addEventListener('voiceschanged', () => speakNext(0), { once: true });
}
```

Without this guard, `speak` modifier elements are silent on Safari even after the splash tap.

---

## 39. Safari Web Speech API — speechSynthesis must be unlocked

`speechSynthesis.speak()` is blocked by Safari until a user gesture has occurred, same as audio autoplay. cx-render.js unlocks it on the splash tap by speaking an empty utterance:

```js
const silentSpeak = new SpeechSynthesisUtterance('');
window.speechSynthesis.speak(silentSpeak);
```

This must happen synchronously in the click handler — any async gap loses the gesture context.

---

## 40. nginx ssl_session_cache mutex spike

nginx 1.31.1 on Homebrew can produce ~7–8 second response time spikes under the default `ssl_session_cache` configuration. The mutex contention on the shared cache causes stalls. If you see periodic slow responses on HTTPS connections, try:

```nginx
ssl_session_cache off;
```

Disabling the session cache eliminates the mutex and removes the spikes. The trade-off is slightly higher CPU per new TLS handshake — acceptable at low traffic volumes.

---

## 41. htpasswd bcrypt broken on nginx 1.31.1 / Homebrew

`htpasswd -B` (bcrypt) produces hashes that nginx 1.31.1 on Homebrew cannot verify — users get 401 on every request. Use MD5 instead:

```bash
htpasswd -m /path/to/htpasswd-file username
```

`-m` flag forces MD5. This is a known issue with the Homebrew build of this nginx version. May be fixed in later versions — worth re-testing before switching back to bcrypt.
