From ae0716294d02b7e9167b138a2fa27e562a19294e Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Mon, 24 Aug 2026 13:34:55 -0500 Subject: [PATCH 1/6] Present into the window, not over the screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- engine/app/assets/stylesheets/coplan/deck.css | 11 ++- .../coplan/deck_presenter_controller.js | 79 ++++++++++++------- engine/app/views/coplan/plans/show.html.erb | 14 ++-- spec/system/deck_ux_spec.rb | 41 +++++++++- 4 files changed, 104 insertions(+), 41 deletions(-) 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..f2f77a36 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") } @@ -227,6 +228,40 @@ def attachments_on_screen? expect(current_slide).to eq("2") 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. + it "fills the window without taking the screen, and takes it on f" do + visit plan_path(plan) + 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.evaluate_script("!!document.fullscreenElement")).to be(true) + + # 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.evaluate_script("!!document.fullscreenElement")).to be(false) + 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 + it "puts the pen away on Escape without ending the show" do visit plan_path(plan) start_show From 14ab9ef0f573f35ceafb0d3bd0e54af0a504e167 Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Mon, 24 Aug 2026 18:05:45 -0500 Subject: [PATCH 2/6] Wait for the deck before reaching for Present MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- spec/system/deck_ux_spec.rb | 75 ++++++++++++++++++++----------------- 1 file changed, 41 insertions(+), 34 deletions(-) diff --git a/spec/system/deck_ux_spec.rb b/spec/system/deck_ux_spec.rb index f2f77a36..1cdf5326 100644 --- a/spec/system/deck_ux_spec.rb +++ b/spec/system/deck_ux_spec.rb @@ -97,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 @@ -156,6 +163,40 @@ 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. + it "fills the window without taking the screen, and takes it on f" do + visit plan_path(plan) + 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 @@ -228,40 +269,6 @@ def attachments_on_screen? expect(current_slide).to eq("2") 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. - it "fills the window without taking the screen, and takes it on f" do - visit plan_path(plan) - 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.evaluate_script("!!document.fullscreenElement")).to be(true) - - # 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.evaluate_script("!!document.fullscreenElement")).to be(false) - 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 - it "puts the pen away on Escape without ending the show" do visit plan_path(plan) start_show From 39e9f5f035ec4e809e4232033a53a2c4c9f7ea98 Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Mon, 24 Aug 2026 18:16:39 -0500 Subject: [PATCH 3/6] TEMPORARY: report what the browser sees when the deck is missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- spec/system/deck_ux_spec.rb | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/spec/system/deck_ux_spec.rb b/spec/system/deck_ux_spec.rb index 1cdf5326..e0c9d509 100644 --- a/spec/system/deck_ux_spec.rb +++ b/spec/system/deck_ux_spec.rb @@ -104,7 +104,21 @@ def strokes_drawn # 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) + unless page.has_css?(".deck-slide", wait: 10) + # TEMPORARY: one CI-only example loses its deck and the failure alone + # doesn't say why. Report what the browser is actually looking at. + raise <<~DIAG + No .deck-slide. + url: #{page.current_url} + title: #{page.title.inspect} + markers: presenter=#{page.has_css?('.deck-presenter', wait: 0)} \ + toolbar=#{page.has_css?('.deck-toolbar', wait: 0)} \ + deck=#{page.has_css?('.deck', wait: 0)} \ + content=#{page.has_css?('#plan-content-body', wait: 0)} + console: #{page.driver.browser.logs.get(:browser).map(&:message).last(5).inspect} + body: #{page.find('body').text[0, 400].inspect} + DIAG + end click_button "Present" expect(page).to have_css(".deck--presenting .deck-slide--current", wait: 5) end From 6e09a547231d22e804695ad21243683c733ea16d Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Mon, 24 Aug 2026 18:32:56 -0500 Subject: [PATCH 4/6] TEMPORARY: ask CI whether the failing request is repeatable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first diagnostic paid off: the browser is sitting on a Rails routing error for /_/plans/ — "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 --- spec/system/deck_ux_spec.rb | 37 +++++++++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/spec/system/deck_ux_spec.rb b/spec/system/deck_ux_spec.rb index e0c9d509..6e729e3c 100644 --- a/spec/system/deck_ux_spec.rb +++ b/spec/system/deck_ux_spec.rb @@ -105,18 +105,35 @@ def strokes_drawn # present already wait explicitly for the same reason.) def start_show unless page.has_css?(".deck-slide", wait: 10) - # TEMPORARY: one CI-only example loses its deck and the failure alone - # doesn't say why. Report what the browser is actually looking at. + # TEMPORARY: on CI this one example lands on a routing error for + # /_/plans/ — a path the other examples in this file fetch + # happily in the same process, so a static route table can't be the + # whole story. Report the record, the path asked for, whether the + # test process's own route set recognizes it, and — the discriminator + # — whether simply asking again works. A passing retry means + # transient server state; a second failure means something about this + # request is genuinely different. + asked = plan_path(plan) + recognized = begin + Rails.application.routes.recognize_path(asked, method: :get).inspect + rescue StandardError => e + "#{e.class}: #{e.message}" + end + first = "url=#{page.current_url} title=#{page.title.inspect}" + visit asked + retried = "url=#{page.current_url} deck=#{page.has_css?('.deck-slide', wait: 10)}" + raise <<~DIAG No .deck-slide. - url: #{page.current_url} - title: #{page.title.inspect} - markers: presenter=#{page.has_css?('.deck-presenter', wait: 0)} \ - toolbar=#{page.has_css?('.deck-toolbar', wait: 0)} \ - deck=#{page.has_css?('.deck', wait: 0)} \ - content=#{page.has_css?('#plan-content-body', wait: 0)} - console: #{page.driver.browser.logs.get(:browser).map(&:message).last(5).inspect} - body: #{page.find('body').text[0, 400].inspect} + asked: #{asked} + plan: slug=#{plan.slug.inspect} suffix=#{plan.slug_suffix.inspect} \ + handle=#{plan.library_handle.inspect} url_path=#{plan.url_path.inspect} \ + visibility=#{plan.visibility.inspect} + recognized: #{recognized} + first try: #{first} + retry: #{retried} + console: #{page.driver.browser.logs.get(:browser).map(&:message).last(5).inspect} + body: #{page.find('body').text[0, 300].inspect} DIAG end click_button "Present" From af40b10a707c897f8bd4248124bca949ed75ba78 Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Mon, 24 Aug 2026 18:41:10 -0500 Subject: [PATCH 5/6] TEMPORARY: dump CI's route table for /_/plans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- spec/system/deck_ux_spec.rb | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/spec/system/deck_ux_spec.rb b/spec/system/deck_ux_spec.rb index 6e729e3c..a0a0ed12 100644 --- a/spec/system/deck_ux_spec.rb +++ b/spec/system/deck_ux_spec.rb @@ -114,11 +114,16 @@ def start_show # transient server state; a second failure means something about this # request is genuinely different. asked = plan_path(plan) - recognized = begin - Rails.application.routes.recognize_path(asked, method: :get).inspect + recognize = lambda do |set| + set.recognize_path(asked, method: :get).inspect rescue StandardError => e "#{e.class}: #{e.message}" end + # The helper generated `asked`, so a route named `plan` exists. Ask + # both sets to recognize it, and dump what the engine actually has + # under /_/plans — locally both recognize it and the route is there. + routes = CoPlan::Engine.routes.routes.map { |r| r.path.spec.to_s }.grep(%r{^/_/plans}).first(4) + mounted = Rails.application.routes.routes.count { |r| r.app.respond_to?(:app) && r.app.app == CoPlan::Engine } first = "url=#{page.current_url} title=#{page.title.inspect}" visit asked retried = "url=#{page.current_url} deck=#{page.has_css?('.deck-slide', wait: 10)}" @@ -129,7 +134,9 @@ def start_show plan: slug=#{plan.slug.inspect} suffix=#{plan.slug_suffix.inspect} \ handle=#{plan.library_handle.inspect} url_path=#{plan.url_path.inspect} \ visibility=#{plan.visibility.inspect} - recognized: #{recognized} + app recog: #{recognize.call(Rails.application.routes)} + eng recog: #{recognize.call(CoPlan::Engine.routes)} + eng routes: #{routes.inspect} (engine mounted #{mounted}x) first try: #{first} retry: #{retried} console: #{page.driver.browser.logs.get(:browser).map(&:message).last(5).inspect} From 667628640962017bbee92077b5d1cfc0f1034837 Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Mon, 24 Aug 2026 18:51:23 -0500 Subject: [PATCH 6/6] Open the deck at its readable address, and drop the diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The route dump came back: on CI both route sets fail to recognize /_/plans/, 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 --- spec/system/deck_ux_spec.rb | 48 +++++++------------------------------ 1 file changed, 8 insertions(+), 40 deletions(-) diff --git a/spec/system/deck_ux_spec.rb b/spec/system/deck_ux_spec.rb index a0a0ed12..13bc422a 100644 --- a/spec/system/deck_ux_spec.rb +++ b/spec/system/deck_ux_spec.rb @@ -104,45 +104,7 @@ def strokes_drawn # button the server hasn't sent yet. (The examples below that don't # present already wait explicitly for the same reason.) def start_show - unless page.has_css?(".deck-slide", wait: 10) - # TEMPORARY: on CI this one example lands on a routing error for - # /_/plans/ — a path the other examples in this file fetch - # happily in the same process, so a static route table can't be the - # whole story. Report the record, the path asked for, whether the - # test process's own route set recognizes it, and — the discriminator - # — whether simply asking again works. A passing retry means - # transient server state; a second failure means something about this - # request is genuinely different. - asked = plan_path(plan) - recognize = lambda do |set| - set.recognize_path(asked, method: :get).inspect - rescue StandardError => e - "#{e.class}: #{e.message}" - end - # The helper generated `asked`, so a route named `plan` exists. Ask - # both sets to recognize it, and dump what the engine actually has - # under /_/plans — locally both recognize it and the route is there. - routes = CoPlan::Engine.routes.routes.map { |r| r.path.spec.to_s }.grep(%r{^/_/plans}).first(4) - mounted = Rails.application.routes.routes.count { |r| r.app.respond_to?(:app) && r.app.app == CoPlan::Engine } - first = "url=#{page.current_url} title=#{page.title.inspect}" - visit asked - retried = "url=#{page.current_url} deck=#{page.has_css?('.deck-slide', wait: 10)}" - - raise <<~DIAG - No .deck-slide. - asked: #{asked} - plan: slug=#{plan.slug.inspect} suffix=#{plan.slug_suffix.inspect} \ - handle=#{plan.library_handle.inspect} url_path=#{plan.url_path.inspect} \ - visibility=#{plan.visibility.inspect} - app recog: #{recognize.call(Rails.application.routes)} - eng recog: #{recognize.call(CoPlan::Engine.routes)} - eng routes: #{routes.inspect} (engine mounted #{mounted}x) - first try: #{first} - retry: #{retried} - console: #{page.driver.browser.logs.get(:browser).map(&:message).last(5).inspect} - body: #{page.find('body').text[0, 300].inspect} - DIAG - end + 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 @@ -205,8 +167,14 @@ def attachments_on_screen? # 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_path(plan) + visit "/#{plan.url_path}" start_show expect(page.evaluate_script("!!document.fullscreenElement")).to be(false)