Layout control surface: operate /layout from a second, broadcast-clean tab - #2
Open
jburkeucla wants to merge 14 commits into
Open
Layout control surface: operate /layout from a second, broadcast-clean tab#2jburkeucla wants to merge 14 commits into
jburkeucla wants to merge 14 commits into
Conversation
…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
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEUEMdhXo2SQ4LjtE2twRt
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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./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/layouttab over aBroadcastChannel, which alone owns the real YouTube players.static/wall-engine.js(shared by/and/layout) now funnels every local interaction through oneapplyIntent()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.Test plan
uv run pytest tests/ -v— 280 passednode --test 'static/*.test.mjs'— 143 passeduv 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 passeduv run pytest tests/test_layout_control_smoke.py -m browser -v— 3 passed (two-pageBroadcastChannelrelay proof)docs/superpowers/specs/2026-08-29-layout-control-surface-design.mddocs/superpowers/plans/2026-08-29-layout-control-surface.mdCLAUDE.mdupdated (new file rows, gotcha 40/41, amended gotchas 2 and 14)🤖 Generated with Claude Code
https://claude.ai/code/session_01FEUEMdhXo2SQ4LjtE2twRt