Skip to content

Present into the window, not over the screen - #199

Merged
HamptonMakes merged 6 commits into
mainfrom
hampton/zoom-presentation-window-share-430457
Aug 26, 2026
Merged

Present into the window, not over the screen#199
HamptonMakes merged 6 commits into
mainfrom
hampton/zoom-presentation-window-share-430457

Conversation

@HamptonMakes

@HamptonMakes HamptonMakes commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

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 requestFullscreen was 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 _promoteDeck is 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.

f mid-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:

  • Escape gained a rung. It already peeled one layer at a time (popovers, then the pen); now the screen is a layer too, and only a bare Escape ends the show. Full screen is the one layer the presenter took deliberately, so Escape should undo that, not everything.
  • _peeling is 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 when f lands. 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. A MediaStream'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).
  • Element Capture / Region Capture — Chromium-only, and they only apply to the page doing the capturing. Zoom is the capturer here.
  • Document Picture-in-Picture — Chrome 130, Firefox 151, Safari never. Also moving the deck into another document would disconnect the Stimulus controllers and the live-update target, breaking the "a collaborator's edit lands mid-show" behavior.

So "make the window shareable" is the whole solution space, and it's a deletion.

Testing

New spec in spec/system/deck_ux_spec.rb covers: no fullscreenElement on start; the deck sized to the window's largest 16:9 fit and genuinely in the top layer; f entering 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

  • Verified in headless Chrome, not Safari. The mechanism is the Popover API plus viewport units — both solid in Safari 17+ — and this change removes the browser-specific API call rather than adding one. Still worth one manual Zoom-share confirmation.
  • Windowed present means the browser window is a black 16:9 canvas with letterbox bars filling the remainder. Correct for sharing, but a visibly different feel from before.
  • f is keyboard-only; there's deliberately no mid-show chrome. It's advertised in the Present button's title alongside p and d, 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 use plan_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 path plan_path generates. Diagnostics showed the plan record intact (slug="readout-deck", handle="presenter", visibility="published"), both Rails.application.routes and CoPlan::Engine.routes failing 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.rb has 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#show 301s 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.

HamptonMakes and others added 6 commits August 24, 2026 13:34
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>
@HamptonMakes
HamptonMakes merged commit 0e60bad into main Aug 26, 2026
4 checks passed
@HamptonMakes
HamptonMakes deleted the hampton/zoom-presentation-window-share-430457 branch August 26, 2026 17:23
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