Skip to content

Layout control surface: operate /layout from a second, broadcast-clean tab - #2

Open
jburkeucla wants to merge 14 commits into
feat/cloudflare-deployfrom
feat/layout-control-surface
Open

Layout control surface: operate /layout from a second, broadcast-clean tab#2
jburkeucla wants to merge 14 commits into
feat/cloudflare-deployfrom
feat/layout-control-surface

Conversation

@jburkeucla

Copy link
Copy Markdown
Member

Summary

  • /layout's header is now hidden by CSS (it's what the NDI broadcaster captures full-frame, so no visible chrome/cursor/menu should ever appear in it) — the elements stay in the DOM, only their visibility changes.
  • A new page, /layout-control, mirrors the header controls plus one rectangle per video cell. Every interaction there (button clicks, hover-to-unmute, scroll-to-zoom, drag-to-pan, double-click-to-lock, the full right-click context menu) relays to the real /layout tab over a BroadcastChannel, which alone owns the real YouTube players.
  • static/wall-engine.js (shared by / and /layout) now funnels every local interaction through one applyIntent() dispatcher — the same function a relayed message calls — plus an opt-in snapshot publisher so the control page can render without any independent state or fetch of its own. /'s behavior is unchanged; this was proven by keeping its full existing browser suite green, unmodified, through every task.
  • Empirically verified (not just assumed): unmuting/starting playback via a relayed intent works with no special browser flag, because Chromium's autoplay policy gates starting new audible playback, not clearing the mute flag on media that's already playing (from muted pre-roll).

Test plan

  • uv run pytest tests/ -v — 280 passed
  • node --test 'static/*.test.mjs' — 143 passed
  • uv run pytest tests/test_player_smoke.py -m browser -v — 40 passed (proof / is unchanged)
  • uv run pytest tests/test_layout_smoke.py -m browser -v — 3 passed
  • uv run pytest tests/test_layout_control_smoke.py -m browser -v — 3 passed (two-page BroadcastChannel relay proof)
  • Design spec: docs/superpowers/specs/2026-08-29-layout-control-surface-design.md
  • Implementation plan: docs/superpowers/plans/2026-08-29-layout-control-surface.md
  • CLAUDE.md updated (new file rows, gotcha 40/41, amended gotchas 2 and 14)

🤖 Generated with Claude Code

https://claude.ai/code/session_01FEUEMdhXo2SQ4LjtE2twRt

jburkeucla and others added 14 commits August 29, 2026 08:13
…t menu

Extract togglePlayForCell, toggleLockedIndex, restartCell, and resetCellZoom as
named functions that were previously duplicated or inlined between the dblclick
listener and the context menu's run callbacks. Zero behavior change, pure
refactoring to prepare for Task 3's intent dispatcher.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEUEMdhXo2SQ4LjtE2twRt
- Add controlChannel parameter to startWall() (default null for zero behavior change)
- Add broadcastChannel variable that stays null until controlChannel is provided
- Implement buildSnapshot() to construct wall state snapshots with global and cell data
- Implement publishSnapshot() as a no-op until BroadcastChannel is opened
- Wire snapshot publishing after applyIntent and finishPreroll for discrete changes
- Add 1-second heartbeat to catch late-joining control pages
- Set up BroadcastChannel listener to route incoming messages through applyIntent
- No page passes controlChannel yet (Task 5), so this adds zero behavior change

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code
Opens /layout and /layout-control from one Playwright BrowserContext and
proves a wheel zoom, a menu action, and a mute toggle on the control page
all relay over BroadcastChannel and land on the real broadcast page.
…oplay investigation, CLAUDE.md, and CSS cleanup

applyIntent's newQuery case now checks `generating` before dispatching. The
flag existed already, but the guard a local click gets is the button's own
disabled state -- and a relayed click never sees that, because
/layout-control's button only learns it is disabled when the next snapshot
arrives up to a heartbeat later. Two fast clicks there were two generations at
100 units each. The control page also disables its button optimistically now,
so the feedback is immediate either way.

The open question about whether unmuting survives the gesture moving to a
different document is answered empirically rather than argued: probed with
Playwright Chromium launched WITHOUT --autoplay-policy=no-user-gesture-required,
headless and headed, a control-page Play took all 8 players to state 1, a
control-page Unmute took all 8 to isMuted()===false while all 8 stayed at
state 1, and hover-to-unmute made exactly cell 0 audible with everything still
playing. The policy gates starting new audible playback, not clearing the mute
flag on media already running -- and preroll starts everything muted, so by the
time an unmute is relayed the gate is already behind us. No kiosk launch flag
is required. The mute test now pins that permanently: it plays and unmutes
entirely from the control page and asserts the players are unmuted AND still at
state 1, since a browser refusing the unmute could pause rather than re-mute.

CLAUDE.md gains the two new files, the amended wall-engine.js and
build-dist.sh rows, a third browser suite in gotcha 14, a third quota trigger
in gotcha 2 (including that BroadcastChannel is a real broadcast, so two open
/layout tabs both spend), and gotchas 40 and 41.

layout-control.html loses the CSS it inherited from player.html and never
needed: the iframe pointer-events rule, the data-preroll opacity gate, the
"deliberately not styled" comment that this page contradicts, the shadowed
"no playable result" empty-cell rule, and the duplicate #grid/.cell base block.
user-select:none is kept, moved onto the surviving rule -- it was cascading
from the deleted one and dragging would otherwise select the cell label.

Also: /layout hides #menu alongside header (preventDefault only stops the
browser's menu, not the app's own, which would paint into the NDI feed);
publishSnapshot logs what it catches instead of swallowing it, and skips the
post when buildSnapshot returns null because config has not landed yet;
applyCellPan and toggleLockedIndex get the empty-cell guard applyCellWheel
already had; the control page's reserve menu item shows how many are left; and
a status message the control page wrote itself is held for 1.5s so the
heartbeat cannot paint over a copy confirmation before it is read.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEUEMdhXo2SQ4LjtE2twRt
The only symptom of opening /layout-control in a different browser (or,
in production, alongside the broadcaster's own separate off-screen Chrome
instance) was this page sitting on "waiting" forever with nothing to click.
Say so in the status line and the console.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEUEMdhXo2SQ4LjtE2twRt
For an external controller with no browser to share a BroadcastChannel
with (e.g. chasa, an OSC/show-control router), relays a wall-wide
control intent over the same /ws connection put_config already uses
for config broadcasts. wall-engine.js's socket handler calls
applyIntent() on it directly -- no new dispatch logic, one new branch.

Deliberately restricted to WALL_WIDE_INTENT_TYPES (play, pause,
muteToggle, rewind, shuffle, resetView, newQuery, hoverUnmuteToggle,
followToggle), not the cell-indexed intents -- those carry an index
tied to whichever page's grid is open, and this broadcasts to every
connected tab, not just one wall.

Verified against a real chasa config end to end: real OSC UDP cues
arrived on yt-matrix's /ws as {"type": "intent", ...} within
milliseconds, exactly matching what applyIntent() expects.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEUEMdhXo2SQ4LjtE2twRt
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant