diff --git a/engine/app/assets/stylesheets/coplan/deck.css b/engine/app/assets/stylesheets/coplan/deck.css index ff9ee8fb..f5336cbf 100644 --- a/engine/app/assets/stylesheets/coplan/deck.css +++ b/engine/app/assets/stylesheets/coplan/deck.css @@ -473,11 +473,14 @@ object-fit: contain; } -/* ---- presenting — the deck takes the screen --------------------------- +/* ---- presenting — the deck takes the window --------------------------- Present mode (coplan--deck-presenter) shows the deck as a top-layer - popover sized to the largest 16:9 canvas that fits the viewport — the - deck stays the size container, so every cqi measurement above scales to - the screen with no presentation-specific typography. Top layer matters: + popover sized to the largest 16:9 canvas that fits the viewport. That + is the whole mechanism, in a window and under native fullscreen alike: + viewport units mean pressing `f` needs no presentation-specific rule, + the canvas just grows into the screen. The deck stays the size + container, so every cqi measurement above scales with it and no + presentation-specific typography is needed. Top layer matters: fixed positioning alone resolves against any backdrop-filter ancestor (the host's glass card), while top-layer elements always position against the viewport. The spread shadow blacks out the letterbox bars diff --git a/engine/app/javascript/controllers/coplan/deck_presenter_controller.js b/engine/app/javascript/controllers/coplan/deck_presenter_controller.js index d425da1d..c5b24f28 100644 --- a/engine/app/javascript/controllers/coplan/deck_presenter_controller.js +++ b/engine/app/javascript/controllers/coplan/deck_presenter_controller.js @@ -9,12 +9,20 @@ const DRAG_SLOP = 4 /* * coplan--deck-presenter * - * Present mode: the deck takes the screen as one 16:9 canvas — exactly what - * a Zoom or Meet screen-share needs. One slide shows at a time; arrows, - * space, page keys, and clicks advance; Escape (or leaving native - * fullscreen) ends the show. Native fullscreen is requested on this - * wrapper, with the fixed-overlay CSS as the fallback when the browser - * refuses, so presenting works either way. + * Present mode: the deck takes the browser window as one 16:9 canvas. One + * slide shows at a time; arrows, space, page keys, and clicks advance; + * Escape ends the show. + * + * The window, not the screen, is the default on purpose. Presenting over + * Zoom or Meet means sharing a window, and on macOS native fullscreen moves + * the window onto its own Space — where screen-share pickers can no longer + * see it at all. Taking the screen also buries the call controls the + * presenter is talking through. So the show fills the window (the top-layer + * popover in _promoteDeck is what makes that airtight), and `f` takes the + * whole screen for the times there's a projector and no call. Fullscreen is + * a mode within the show, not the show itself: dropping out of it — by `f`, + * by Escape, by the browser's own chrome — leaves the deck presenting in + * the window. * * Two gestures mark up a slide without leaving the show. Dragging across * text highlights it in the deck's accent — the room's eyes follow the @@ -78,10 +86,9 @@ export default class extends Controller { // Presenting borrows the URL fragment for #present-N; remember what // was there (a heading deep link, a footnote) to give back on exit. this._priorHash = resumed ? "" : window.location.hash - this._peeling = false this._show(resumed ? Number(resumed[1]) - 1 : 0) - // _show stops the show itself on a slideless deck — don't take the - // screen for nothing. + // _show stops the show itself on a slideless deck — don't black out + // the window for nothing. if (!this.presenting) return // Move focus into the show. Clicking the Present button leaves the @@ -93,12 +100,18 @@ export default class extends Controller { deck.tabIndex = -1 deck.focus({ preventScroll: true }) } + } + + // `f` mid-show. Fullscreen is asked for on the wrapper rather than the + // deck so the deck stays free to be a popover; the top-layer popover is + // what fills the space either way, and re-promoting is what keeps it + // visible — the fullscreen wrapper enters the top layer ABOVE the + // already-shown popover and would otherwise cover it. + _toggleFullscreen() { + if (document.fullscreenElement === this.element) return document.exitFullscreen().catch(() => {}) + if (!this.element.requestFullscreen) return - // Native fullscreen when the browser grants it; the top-layer popover - // (see _promoteDeck) is what actually fills the screen either way. - // Re-promote once fullscreen resolves — the fullscreen wrapper enters - // the top layer above the already-shown popover and would cover it. - this.element.requestFullscreen?.().then(() => { + this.element.requestFullscreen().then(() => { // The grant can outlive a fast Escape: if the show already ended, // give the screen back instead of re-promoting a stopped deck. if (!this.presenting) { @@ -237,18 +250,28 @@ export default class extends Controller { event.stopPropagation() this.ink.toggle() break + case "f": + // Full screen. For a projector in a room; a call wants the window + // (see the note at the top of this file), which is what the show + // gives back when this toggles off. + event.preventDefault() + event.stopPropagation() + this._toggleFullscreen() + break case "Escape": event.preventDefault() event.stopPropagation() // Escape peels one layer at a time: whatever is visibly in front of // the show goes first — a popover (a reference preview, a pinned - // thread), then the pen — and only a bare Escape ends the show. The - // browser may drop native fullscreen on the same keypress (that - // exit is uncancelable), so mark the peel: the resulting - // fullscreenchange is forgiven and the show continues on the - // top-layer fallback. - if (this._dismissForeignPopovers() || this._stowPen()) { - if (document.fullscreenElement === this.element) this._peeling = true + // thread), then the pen, then the screen — and only a bare Escape + // ends the show. Giving the screen back is its own step because + // full screen is the one layer the presenter took deliberately; + // Escape should undo that, not the whole show. (The browser drops + // fullscreen on this keypress anyway, uncancelably — exiting here + // just makes the same thing happen on purpose.) + if (this._dismissForeignPopovers() || this._stowPen()) return + if (document.fullscreenElement === this.element) { + document.exitFullscreen().catch(() => {}) return } this.stop() @@ -390,13 +413,13 @@ export default class extends Controller { _handleFullscreenChange() { if (!this.presenting || document.fullscreenElement) return - // One exit is forgiven when Escape was consumed dismissing a popover — - // the keypress was aimed at the popover, not the show. - if (this._peeling) { - this._peeling = false - return - } - this.stop() + // Losing the screen no longer ends the show — the presenter is back to + // the window they started in, which is the shape a call wants. However + // it happened (the `f` toggle, Escape, the browser's own chrome), the + // one thing to check is that the deck is still in the top layer: the + // fullscreen wrapper leaving it is the moment a promotion could be + // dropped, and a closed popover is a blank page. + this._promoteDeck() } _typing(target) { diff --git a/engine/app/views/coplan/plans/show.html.erb b/engine/app/views/coplan/plans/show.html.erb index 0066b7c5..beeb1acc 100644 --- a/engine/app/views/coplan/plans/show.html.erb +++ b/engine/app/views/coplan/plans/show.html.erb @@ -80,11 +80,13 @@ anchored to diagram labels get their marks (and pending ?thread= deep links can resolve). %>
- <%# Presentations present: `p` or the button starts a fullscreen - show (deck_presenter_controller). The wrapper sits outside the - live-update swap target so a collaborator's edit landing - mid-show doesn't disconnect the presenter — and the toolbar sits - outside the text-selection content target below, because its + <%# Presentations present: `p` or the button fills the window with + the deck (deck_presenter_controller) — a window is what a call + can screen-share, and `f` takes the whole screen for a room. + The wrapper sits outside the live-update swap target so a + collaborator's edit landing mid-show doesn't disconnect the + presenter — and the toolbar sits outside the text-selection + content target below, because its label is visible text and comment anchors count visible-text occurrences under that target. %> <% if @plan.presentation? %> @@ -93,7 +95,7 @@ <%# The only place the show's keys are advertised before it starts — mid-show there is no room for a legend, and the pen is the one gesture nobody would guess. %> - diff --git a/spec/system/deck_ux_spec.rb b/spec/system/deck_ux_spec.rb index ff37c7b7..13bc422a 100644 --- a/spec/system/deck_ux_spec.rb +++ b/spec/system/deck_ux_spec.rb @@ -1,9 +1,10 @@ require "rails_helper" # Browser-level coverage for the deck's pointer and navigation behavior: -# the two ways a presenter marks up a live slide (selecting text, and the -# pen), the Mermaid expand chip staying chip-sized on a slide, and the -# back-matter links jumping in place instead of refetching the page. +# the show filling the window rather than taking the screen (a call shares +# windows), the two ways a presenter marks up a live slide (selecting text, +# and the pen), the Mermaid expand chip staying chip-sized on a slide, and +# the back-matter links jumping in place instead of refetching the page. RSpec.describe "Deck UX", type: :system do let(:user) { create(:coplan_user, email: "presenter@example.com") } let(:deck_type) { create(:plan_type, name: "Presentation", behavior: "presentation") } @@ -96,7 +97,14 @@ def strokes_drawn page.evaluate_script("window.__strokes") end + # Wait for the deck itself before reaching for the button. This page + # renders Mermaid, so it settles well past Capybara's 2s default on a + # loaded CI runner — and the toolbar only exists once the deck does, so + # a bare click_button spends that whole default budget looking for a + # button the server hasn't sent yet. (The examples below that don't + # present already wait explicitly for the same reason.) def start_show + expect(page).to have_css(".deck-slide", wait: 10) click_button "Present" expect(page).to have_css(".deck--presenting .deck-slide--current", wait: 5) end @@ -155,6 +163,46 @@ def attachments_on_screen? send_keys(:space) expect(checkbox).to be_checked end + + # A call shares a window; macOS moves a fullscreen window onto its own + # Space, where screen-share pickers can't see it. So starting the show + # must not take the screen — `f` is the presenter asking for it. + # + # Reached by its readable address rather than plan_path's /_/plans/. + # That's the canonical URL — PlansController 301s the id form onto it — + # so it's the address a presenter actually opens. It also steps around + # a CI-only routing failure on the legacy id path that this example + # kept tripping (see the note on the issue filed alongside this). + it "fills the window without taking the screen, and takes it on f" do + visit "/#{plan.url_path}" + start_show + expect(page.evaluate_script("!!document.fullscreenElement")).to be(false) + + # The window is the canvas either way: the deck is in the top layer, + # so no glass card ancestor can trap or cover it. + expect(page.evaluate_script(<<~JS)).to be(true) + (() => { + const deck = document.querySelector(".deck--presenting"); + if (!deck.matches(":popover-open")) return false; + const box = deck.getBoundingClientRect(); + const fits = Math.min(window.innerWidth, window.innerHeight * 16 / 9); + return Math.abs(box.width - fits) < 2; + })() + JS + + send_keys("f") + expect(page).to have_css(".deck-presenter:fullscreen", wait: 5) + + # Escape gives the screen back and the show carries on in the window — + # full screen is a layer to peel, not the show itself. + send_keys(:escape) + expect(page).to have_no_css(".deck-presenter:fullscreen", wait: 5) + expect(page).to have_css(".deck--presenting") + expect(current_slide).to eq("1") + + send_keys(:escape) + expect(page).to have_no_css(".deck--presenting") + end end describe "the pen" do