Present into the window, not over the screen - #199
Merged
HamptonMakes merged 6 commits intoAug 26, 2026
Conversation
Present mode took native fullscreen on start, which is the one thing a Zoom or Meet share can't cope with: on macOS a fullscreen window moves onto its own Space, and screen-share pickers stop listing it entirely. Taking the screen also buries the call controls the presenter is talking through. The screen was never the mechanism, though — the top-layer popover in _promoteDeck is what fills the space, and the deck's canvas is already sized in viewport units. So drop the requestFullscreen call from start() and the show fills the browser window, which is exactly what you pick in a share dialog. `f` mid-show takes the whole screen for the times there's a projector and no call. Fullscreen is now a mode within the show rather than the show itself: losing it no longer ends the presentation, it drops back to the window. That gives Escape one more rung — popovers, then the pen, then the screen, and only a bare Escape ends the show — and retires the _peeling flag, which existed solely to forgive the uncancelable fullscreen exit that Escape used to trigger. No CSS rules changed: min(100vw, 100vh * 16/9) means the canvas just grows into the screen when `f` lands. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI failed the new windowed-present example on `click_button "Present"`, in both the MySQL and Postgres jobs, while the whole file passed locally. start_show clicked the button straight after visit, inside Capybara's 2s default. This page renders Mermaid and settles well past that on a loaded runner — the file's other examples already wait explicitly (wait: 5, wait: 15) for exactly that reason, and the toolbar only exists once the deck has been sent, so the bare click spent its whole budget looking for markup that wasn't there yet. It was a latent race for every deck example; the newest one, running last on the most-worn browser session, is just where it finally landed. Wait for .deck-slide first. Two smaller fixes in the same example: assert fullscreen with Capybara's waiting matcher on :fullscreen rather than sampling document.fullscreenElement once (requestFullscreen resolves a promise, so the single sample was its own race), and move it into "present mode", where it belongs — it was anchored into the "the pen" group by mistake. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The windowed-present example still fails on CI and only on CI — the full suite passes locally, MySQL and Postgres fail it identically, and a 10s wait for .deck-slide changed nothing, so it isn't the timing race the previous commit assumed. Slides are server-rendered, so "no .deck-slide" means the page under test isn't a deck at all, and neither of the two examples immediately before it (byte-identical opening lines, same plan and plan_type lets) has any trouble. Guessing again is worse than spending a cycle, so: dump the URL, title, which deck markers exist, the last console messages, and a slice of the body when the deck doesn't appear. To be reverted with the real fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first diagnostic paid off: the browser is sitting on a Rails routing error for /_/plans/<uuid> — "No route matches [GET]". That path is what plan_path generates for every example in this file, and the two examples before this one fetch it happily in the same process, so a static route table doesn't explain it on its own. Widen the report to the facts that separate the remaining stories: the record's slug/handle/url_path (is the path even the one we think?), whether the test process's own route set recognizes it, and — the discriminator — whether visiting the exact same URL a second time works. A passing retry points at transient server state; a second failure means something about this request is genuinely different from its neighbours'. Still not reproducible locally: the full suite passes here, including on the CI code path (eager_load, current Chrome for Testing). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The retry answered its question: visiting the same URL a second time fails identically, so nothing transient is involved. And one line from that run turned out to matter after all. I assumed recognize_path failing was an artifact of asking the host set about a mounted engine's route — it isn't: locally that exact call resolves to co_plan/plans#show, under lazy loading and eager loading alike. On CI it raises RoutingError. The route table itself differs, even though engine/config/routes.rb has no conditionals in 250 lines, the engine is a path gem, and the helper that generated the path can only exist if a route named `plan` does. So ask CI directly: have both route sets try to recognize it, and dump what the engine actually has under /_/plans. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The route dump came back: on CI both route sets fail to recognize /_/plans/<uuid>, while locally both resolve it to co_plan/plans#show. The engine is mounted once and its member routes (publish, hide, archive) are present in the same set — only the bare show route is unreachable. No spec in the suite redraws routes, engine/config/routes.rb has no conditionals, and the engine is a path gem, so CI and local run the same 250 lines. What I can't explain is the shape of it: the two examples above this one fetch that exact path successfully in the same process, and every example below passes too. Only this one fails, at any position in the file — I moved it from sixth to third and it followed. That isn't a route table being cleared mid-run, and after five CI cycles I'd rather ship the fix than keep buying guesses. So open the plan the way a presenter does: its readable address, which PlansController 301s the id form onto anyway. Arguably the better test — it exercises the URL a person actually has. The legacy-path anomaly is filed separately; it predates this branch and deserves its own look, not a spot on a presentation PR. start_show keeps waiting for the deck before reaching for the button. That race was real, just not this failure. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
Presenting a deck over Zoom didn't work, and native fullscreen was the reason.
On macOS a fullscreen window moves onto its own Space, and apps sitting in a fullscreen Space drop out of screen-share pickers entirely — so
requestFullscreenwas quietly removing the browser from Zoom's window list at the exact moment you wanted to share it. Taking the whole screen also buries the call controls the presenter is talking through.What changed
start()no longer requests fullscreen. The screen was never the mechanism anyway: the top-layer popover in_promoteDeckis what fills the space, and the deck canvas is already sized in viewport units. So the show now fills the browser window, which is exactly what you pick in a share dialog.fmid-show toggles native fullscreen, for the times there's a projector and no call.Fullscreen became a mode within the show rather than the show itself — losing it drops back to the window instead of ending the presentation. Two consequences:
_peelingis gone. It existed solely to forgive the uncancelable fullscreen exit that Escape used to trigger. There's nothing left to forgive.No CSS rules changed —
min(100vw, 100vh * 16/9)means the canvas just grows into the screen whenflands. Only the section comment and the Present button's key legend needed updating.Why not something cleverer
I looked for a web API that would hand Zoom a dedicated surface. There isn't one:
navigator.presentation— Safari has never implemented it (no support on any version, macOS or iOS), and even in Chrome it hands a URL to a second-screen receiver (Chromecast, a presentation display). There's no macOS window for Zoom to pick up.getDisplayMedia— an input, not an output. A page can capture the screen; it cannot publish one. AMediaStream's only sinks are<video>,RTCPeerConnection, and Web Audio. Feeding Zoom would need a native virtual camera (a CoreMedia I/O extension, the OBS Virtual Camera approach).So "make the window shareable" is the whole solution space, and it's a deletion.
Testing
New spec in
spec/system/deck_ux_spec.rbcovers: nofullscreenElementon start; the deck sized to the window's largest 16:9 fit and genuinely in the top layer;fentering fullscreen; Escape returning to windowed with the show still running on slide 1; a second Escape ending it.Deck suite plus the plans request specs: 119 examples, 0 failures.
For the reviewer
fis keyboard-only; there's deliberately no mid-show chrome. It's advertised in the Present button's title alongsidepandd, which is the only place the show's keys are listed.🤖 Generated with Claude Code
One thing a reviewer should question
The new example opens the plan at its readable address (
visit "/#{plan.url_path}") while its neighbours useplan_path(plan). That inconsistency is deliberate but unresolved.On CI — both DB jobs, deterministically — that one example got a Rails routing error for
/_/plans/<uuid>, the pathplan_pathgenerates. Diagnostics showed the plan record intact (slug="readout-deck",handle="presenter",visibility="published"), bothRails.application.routesandCoPlan::Engine.routesfailing to recognize that path, and the engine mounted once with its member routes (publish,hide,archive) present in the same set. Re-requesting failed identically.What I could not explain: the two examples above it fetch that exact path successfully in the same process, and every example below passes. Moving it from sixth to third position in the file changed nothing.
engine/config/routes.rbhas no conditionals, the engine is a path gem, no spec redraws routes, and locally the path resolves under both lazy and eager loading.Visiting the readable address is defensible on its own — it's the canonical URL,
PlansController#show301s the id form onto it, and it's what a presenter actually opens. But it's a step around the anomaly, not an explanation. The anomaly predates this branch and is filed for separate investigation.