From ac5708399c82f359f8fa297448f2620705a2ab57 Mon Sep 17 00:00:00 2001 From: James Thompson Date: Thu, 12 Nov 2026 08:18:32 -0500 Subject: [PATCH 01/15] test(support): extract narrow_frame_fits? from open_in_narrow_frame (#318) Same measure (scrollWidth against innerWidth), now callable after a page in the frame changes, so the next commit's example can overflow it by a few pixels. Co-Authored-By: Claude Sonnet 5.5 --- spec/support/narrow_frame.rb | 8 +++++++- spec/system/narrow_frame_spec.rb | 16 ++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 spec/system/narrow_frame_spec.rb diff --git a/spec/support/narrow_frame.rb b/spec/support/narrow_frame.rb index ccbc6acd..dcf21a20 100644 --- a/spec/support/narrow_frame.rb +++ b/spec/support/narrow_frame.rb @@ -13,9 +13,15 @@ def open_in_narrow_frame(path, width:, ready:, height: 800) document.body.append(frame) JS within_narrow_frame { find(ready) } + [ page.evaluate_script("document.getElementById('narrow').contentWindow.innerWidth"), narrow_frame_fits? ] + end + + # Whether the page in the narrow frame fits without horizontal scrolling, measured now. + # Call it from the top page, outside within_narrow_frame (it reaches the frame through #narrow). + def narrow_frame_fits? page.evaluate_script(<<~JS) (() => { const view = document.getElementById("narrow").contentWindow - return [ view.innerWidth, view.document.documentElement.scrollWidth <= view.innerWidth ] })() + return view.document.documentElement.scrollWidth <= view.innerWidth })() JS end diff --git a/spec/system/narrow_frame_spec.rb b/spec/system/narrow_frame_spec.rb new file mode 100644 index 00000000..c6cb0be1 --- /dev/null +++ b/spec/system/narrow_frame_spec.rb @@ -0,0 +1,16 @@ +require "rails_helper" + +# The narrow-frame helper (spec/support/narrow_frame.rb) is what every phone-width example trusts to say a page doesn't +# scroll sideways (#318), so it is checked against a page it can make overflow. +RSpec.describe "The narrow-frame helper", type: :system do + # A short frame, so the sign-in page is taller than it and the frame draws its (classic, 15px in system specs) vertical scrollbar. + def open_sign_in_frame + visit new_session_path + open_in_narrow_frame(new_session_path, width: 360, height: 320, ready: "main") + end + + it "reports a page that fits as fitting", :aggregate_failures do + expect(open_sign_in_frame).to eq([ 360, true ]) + expect(narrow_frame_fits?).to be(true) + end +end From 8d0da39f1ef37850ef80b9e4083584f498df4734 Mon Sep 17 00:00:00 2001 From: James Thompson Date: Thu, 12 Nov 2026 08:19:04 -0500 Subject: [PATCH 02/15] test(support): narrow-frame helper compares with clientWidth, not innerWidth (#318) innerWidth includes the frame's classic 15px scrollbar, so a page scrolling sideways by up to that much passed whenever the frame scrolled vertically. The new example overflows the sign-in page by 5px under a vertical scrollbar: red on the old comparison (expected false, got true), green now. Co-Authored-By: Claude Sonnet 5.5 --- spec/support/narrow_frame.rb | 10 +++++++--- spec/system/narrow_frame_spec.rb | 24 ++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/spec/support/narrow_frame.rb b/spec/support/narrow_frame.rb index dcf21a20..22261ee8 100644 --- a/spec/support/narrow_frame.rb +++ b/spec/support/narrow_frame.rb @@ -2,7 +2,7 @@ # the page in a fixed-width same-origin iframe (id "narrow") on top of the current page. module NarrowFrame # Loads path in a width × height frame, waits for the ready selector inside it and returns - # [the frame's innerWidth, whether its page fits without horizontal scrolling]. + # [the frame's innerWidth, whether its page fits without horizontal scrolling (see narrow_frame_fits?)]. def open_in_narrow_frame(path, width:, ready:, height: 800) page.execute_script(<<~JS, path, width, height) document.getElementById("narrow")?.remove() @@ -18,10 +18,14 @@ def open_in_narrow_frame(path, width:, ready:, height: 800) # Whether the page in the narrow frame fits without horizontal scrolling, measured now. # Call it from the top page, outside within_narrow_frame (it reaches the frame through #narrow). + # + # The page's scrollWidth is compared with the documentElement's clientWidth, the width the layout has, and not with + # innerWidth: innerWidth includes a vertical scrollbar (classic, 15px, in system specs, #271), which the frame draws + # whenever its page is taller than it, so a page scrolling sideways by up to that much would pass (#318). def narrow_frame_fits? page.evaluate_script(<<~JS) - (() => { const view = document.getElementById("narrow").contentWindow - return view.document.documentElement.scrollWidth <= view.innerWidth })() + (() => { const doc = document.getElementById("narrow").contentWindow.document.documentElement + return doc.scrollWidth <= doc.clientWidth })() JS end diff --git a/spec/system/narrow_frame_spec.rb b/spec/system/narrow_frame_spec.rb index c6cb0be1..00259ac4 100644 --- a/spec/system/narrow_frame_spec.rb +++ b/spec/system/narrow_frame_spec.rb @@ -9,8 +9,32 @@ def open_sign_in_frame open_in_narrow_frame(new_session_path, width: 360, height: 320, ready: "main") end + # Makes the frame's page scroll sideways by overflow px (a one-pixel-high box at the left edge, wider than the viewport) and + # returns what the frame measured, so an example can't pass while its overflow or its scrollbar is not really there. + def overflow_frame_page(overflow) + within_narrow_frame do + page.evaluate_script(<<~JS, overflow) + (() => { const doc = document.documentElement + const box = document.createElement("div") + box.style.cssText = `position:absolute;left:0;top:0;height:1px;width:${doc.clientWidth + arguments[0]}px` + document.body.append(box) + return { scrollbar: window.innerWidth - doc.clientWidth, overflow: doc.scrollWidth - doc.clientWidth } })() + JS + end + end + it "reports a page that fits as fitting", :aggregate_failures do expect(open_sign_in_frame).to eq([ 360, true ]) expect(narrow_frame_fits?).to be(true) end + + # The trap of #318: the old comparison was against innerWidth, which includes the scrollbar, so a page scrolling sideways + # by less than the scrollbar's width passed whenever the frame scrolled vertically. + it "reports a page that scrolls sideways by less than a scrollbar's width", :aggregate_failures do + open_sign_in_frame + measured = overflow_frame_page(5) + expect(measured["scrollbar"]).to be > 5 + expect(measured["overflow"]).to eq(5) + expect(narrow_frame_fits?).to be(false) + end end From 4740f4843676117f3ca1bcee01c967aa0ce49ee7 Mon Sep 17 00:00:00 2001 From: James Thompson Date: Thu, 12 Nov 2026 08:24:15 -0500 Subject: [PATCH 03/15] docs(support): document the narrow-frame helper's params, platform scrollbar widths, sign-up page (#318) Review fixes: YARD tags on open_in_narrow_frame and narrow_frame_fits?, the scrollbar is about 12 to 15px by platform, the example opens the sign-up page (first run redirects the sign-in page), and the memory note no longer lists the trap as open. Co-Authored-By: Claude Sonnet 5.5 --- .claude/memory/headless-firefox-narrow-frame.md | 4 ++-- spec/support/narrow_frame.rb | 13 +++++++++++-- spec/system/narrow_frame_spec.rb | 13 +++++++------ 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/.claude/memory/headless-firefox-narrow-frame.md b/.claude/memory/headless-firefox-narrow-frame.md index 6b65c69b..9488702e 100644 --- a/.claude/memory/headless-firefox-narrow-frame.md +++ b/.claude/memory/headless-firefox-narrow-frame.md @@ -7,7 +7,7 @@ metadata: Headless Firefox (Selenium, system specs and screenshot scripts) clamps the window to at least 500px wide. A `driven_by … screen_size: [390, 844]` driver actually renders at 500px, so phone-width assertions silently test the wrong layout. -- **Specs:** use `spec/support/narrow_frame.rb` (`open_in_narrow_frame(path, width:, height:, ready:)` returns `[innerWidth, no_horizontal_overflow]`, and `within_narrow_frame { … }`) with the normal driver. Media queries and container queries evaluate against the iframe's own width. Prove each narrow assertion by breaking the CSS once. Touch-target and coarse-pointer rules still can't be tested this way. The window must be taller than the frame: links below the window's bottom edge can't be clicked, and the click silently does nothing (seed 12061, 2026-10-31). +- **Specs:** use `spec/support/narrow_frame.rb` (`open_in_narrow_frame(path, width:, height:, ready:)` returns `[innerWidth, no_horizontal_overflow]`, where "no overflow" compares `scrollWidth` with `clientWidth` since #318, and `narrow_frame_fits?` re-measures from the top page after a change inside the frame; `within_narrow_frame { … }`) with the normal driver. Media queries and container queries evaluate against the iframe's own width. Prove each narrow assertion by breaking the CSS once. Touch-target and coarse-pointer rules still can't be tested this way. The window must be taller than the frame: links below the window's bottom edge can't be clicked, and the click silently does nothing (seed 12061, 2026-10-31). - **Screenshots:** inject a fixed-width iframe on a same-origin page, grow the frame to its content height, resize the window taller than the frame, then take an element screenshot of the frame. Without the taller window only the visible part is captured. - Separately named drivers (`options: { name: :firefox_dark }`) are still needed for different Firefox prefs, e.g. the dark theme, because Capybara reuses sessions by driver name. @@ -20,4 +20,4 @@ Related: [[pr-screenshots-workflow]]. - Measure related boxes in one script call. Two calls raced on the scanner page, which grows while the camera starts. - **Keyboard-only selects (spec 041, 2026-11-06):** under WebDriver in headless Firefox, arrow keys don't change a closed ``'s value (its `selectedIndex` stays 0 whether or not the list opens). A keyboard-only example chooses by type-ahead instead: focus the select and `send_keys` the option's first letter. - **Scrollbars are pinned to classic (#271, 2026-11-10):** on a Mac, Firefox follows "Show scroll bars: Automatic" and draws overlay scrollbars (no width) when no mouse is connected, which made a 360px frame 15px wider than CI's and broke a wrap check locally only. `spec/support/system.rb` now sets `ui.useOverlayScrollbars = 0`. A driver registered under another name (the dark-theme ones) needs the same pref if it opens a narrow frame. See `docs/lessons/2026-11-10-a-local-only-failure-was-the-macs-scrollbar-setting.md`. -- **A 360px frame is not a 360px phone (spec 069, 2026-11-11):** the frame keeps the forced classic scrollbar (15px on the Mac, about 12px on CI) and the body has an 8px margin, so a 360px frame's page container is 297px (360 − 15 − 16 body − 32 padding) and a deck-shape panel's interior 263px; a real 360px viewport gives a 312px container and a 278px interior. Model a phone as a **375px frame** (a 360px viewport). The `@container page` breakpoints (640px, 992px) are on the container (frame − 15 − 16 − page padding): a 640px frame is a 545px container and still shows the fold; the aside needs a frame of 735px or more. Write the chain in the spec; see `docs/lessons/2026-11-11-a-phone-width-criterion-needs-a-measured-chain.md` and #318 (the helper used to miss overflow a scrollbar hides; it compares with `clientWidth` now). The pinned classic scrollbar is 15px on the Mac but about 12px on the Linux CI runner (spec 069's first CI run, interior 281px vs 278px): size a phone frame from the measured `innerWidth - documentElement.clientWidth`, `360 + scrollbar`, not 375. +- **A 360px frame is not a 360px phone (spec 069, 2026-11-11):** the frame keeps the forced classic scrollbar (15px on the Mac, about 12px on CI) and the body has an 8px margin, so a 360px frame's page container is 297px (360 − 15 − 16 body − 32 padding) and a deck-shape panel's interior 263px; a real 360px viewport gives a 312px container and a 278px interior. Model a phone with `open_in_viewport_frame` (a 360px viewport; a 375px frame is only right where the scrollbar is 15px). The `@container page` breakpoints (640px, 992px) are on the container (frame − 15 − 16 − page padding): a 640px frame is a 545px container and still shows the fold; the aside needs a frame of 735px or more. Write the chain in the spec; see `docs/lessons/2026-11-11-a-phone-width-criterion-needs-a-measured-chain.md` and #318 (the helper used to miss overflow a scrollbar hides; it compares with `clientWidth` now). The pinned classic scrollbar is 15px on the Mac but about 12px on the Linux CI runner (spec 069's first CI run, interior 281px vs 278px): `open_in_viewport_frame` sizes the frame from the measured scrollbar (`360 + innerWidth - clientWidth`), not 375. The zone table of the deck page's worst-case row (60-char name, 1,234 proxies) spills 7 to 10px past a 360px frame's 345 to 348px layout but not a true 360px viewport (spec 074, #318). diff --git a/spec/support/narrow_frame.rb b/spec/support/narrow_frame.rb index b9b9ac04..6345fe51 100644 --- a/spec/support/narrow_frame.rb +++ b/spec/support/narrow_frame.rb @@ -25,7 +25,10 @@ def open_in_narrow_frame(path, width:, ready:, height: 800) # Loads path in a frame whose viewport (its documentElement.clientWidth) is viewport px wide, the way a phone of that width is: # open_in_narrow_frame's width includes the frame's classic scrollbar, so a "360px" frame there lays a page out in 345px or 348px. # The frame is the viewport plus the scrollbar it draws, measured (about 15px on a Mac, 12px on the Linux runner) and reopened - # once if the first guess was wrong. + # once if the first guess was wrong. A page shorter than the frame has no scrollbar and gets a frame exactly viewport px wide. + # + # It does not raise: when the reopened page gains or loses its scrollbar the viewport it got differs, so the caller asserts the + # width it returns. # # @param path [String] the page to load, on the current origin # @param viewport [Integer] the width the page is laid out in, in px diff --git a/spec/system/deck_page_spec.rb b/spec/system/deck_page_spec.rb index 2aa55f2b..73f7aee1 100644 --- a/spec/system/deck_page_spec.rb +++ b/spec/system/deck_page_spec.rb @@ -1,7 +1,9 @@ require "rails_helper" # Spec 066 Story 5, spec 068 AC-2.4, AC-2.5 and AC-2.11: the deck page at 360, 640, 768 and 1280px in both themes, in headless -# Firefox. Phone widths use the narrow frame (a window can't go below 500px), whose page is a same-origin iframe. +# Firefox. Phone widths use the narrow frame (a window can't go below 500px), whose page is a same-origin iframe. Two kinds of +# 360px are in this file: the overflow and zone-head examples open a true 360px viewport (open_deck_page_at, #318), the structure +# examples (headers, wrapping, button heights) a 360px frame, which lays out in about 345px with its classic scrollbar. RSpec.describe "Deck page", type: :system do let!(:user) { system_sign_in_as(create(:user)) } let(:account) { user.account } @@ -67,7 +69,7 @@ def column_boxes # 360px is a phone's viewport (spec 074 measured the worst case there: the document does not scroll sideways), so its frame is # sized from the measured scrollbar. A "360px" narrow frame lays out in about 345px, where the worst-case row's table spills - # 10px past the page (#318: the old checks compared with innerWidth and could not see it). The wider widths are frames. + # 7 to 10px past the page, by platform (#318: the old checks compared with innerWidth and could not see it). The wider widths are frames. def open_deck_page_at(width) ready = ".c-zone:first-of-type table" return open_in_narrow_frame(deck_path(deck), width:, ready:) unless width == 360 diff --git a/spec/system/narrow_frame_spec.rb b/spec/system/narrow_frame_spec.rb index 756b8e41..63b7f114 100644 --- a/spec/system/narrow_frame_spec.rb +++ b/spec/system/narrow_frame_spec.rb @@ -54,7 +54,7 @@ def overflow_frame_page(overflow) it "reports the same overflow for the page the driver is in", :aggregate_failures do open_sign_up_frame within_narrow_frame { expect(page_fits_sideways?).to be(true) } - overflow_frame_page(5) + expect(overflow_frame_page(5)["scrollbar"]).to be > 5 # under a scrollbar wider than the overflow, the case the old measure missed within_narrow_frame { expect(page_fits_sideways?).to be(false) } end end From f4147e7063affd4ac9778cf674f33411200eb6b0 Mon Sep 17 00:00:00 2001 From: James Thompson Date: Thu, 12 Nov 2026 08:41:06 -0500 Subject: [PATCH 14/15] docs(lessons): sweep a measurement trap by swapping the measure and running (#318) Co-Authored-By: Claude Sonnet 5.5 --- ...rap-by-swapping-and-running-not-by-grep.md | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 docs/lessons/2026-11-12-sweep-a-measurement-trap-by-swapping-and-running-not-by-grep.md diff --git a/docs/lessons/2026-11-12-sweep-a-measurement-trap-by-swapping-and-running-not-by-grep.md b/docs/lessons/2026-11-12-sweep-a-measurement-trap-by-swapping-and-running-not-by-grep.md new file mode 100644 index 00000000..e3900fc2 --- /dev/null +++ b/docs/lessons/2026-11-12-sweep-a-measurement-trap-by-swapping-and-running-not-by-grep.md @@ -0,0 +1,30 @@ +--- +date: 2026-11-12 +spec: none (direct fix, #318) +tags: [system-specs, firefox, layout, narrow-frame, measurement] +--- + +# Lesson: sweep a measurement trap by swapping the measure and running, not by grep + +## Context + +#318: the narrow-frame helper compared `scrollWidth` with `innerWidth`, which includes the frame's classic scrollbar (15px on the Mac, about 12px on CI), so a page overflowing sideways by up to that much passed. Spec 072's review had named the trap on 2026-11-11 (`docs/lessons/2026-11-11-two-measurement-traps-the-classic-scrollbar-and-closed-details-menus.md`). The helper fix was four lines. + +## What happened + +- The helper's change alone left all 452 examples that use it green. The same trap was written 25 more times as inline `scrollWidth <= window.innerWidth` checks in 17 other specs, nine of them as a local `fits?` method each. A grep found those. +- The first review round found them, and a second grep still missed two variants: `footer_spec` compared the footer text's right edge with `width - gutter`, where `width` is the frame's width and so includes the scrollbar, and `scanner_sitting_spec` compared `scrollWidth` with the literal 360. Neither contains `innerWidth`. The second round's reviewer found them by reading. +- Swapping the measure to `clientWidth` and running was what found real findings: 5 examples failed, all at a "360px" narrow frame of the deck page. The cause was not a bug: a 360px frame with a 15px scrollbar lays a page out in 345px (348px on CI), and the zone table's worst-case row spills 7 to 10px past that, but not past a true 360px viewport (spec 074 recorded the spill and its limit). The examples had been passing because the scrollbar hid it, and "360px" meant two layouts in one file. +- The honest fix was to say which 360px each example means: `open_in_viewport_frame` opens a frame as the viewport plus the measured scrollbar, and the two overflow examples use it. The helper is the one measure now (`page_fits_sideways?`), so the next check has nothing to copy. + +## What to do next time + +- To remove a measurement trap, put the correct measure in one helper first, then swap every use and run the files. Grep finds the copies that look like the trap; the run finds the ones that behave like it. A comparison against a frame's width, a viewport literal, or a width variable that includes a scrollbar is the same trap without its name. +- When a stricter measure turns an example red, ask what viewport the name promises ("360px" is a phone's viewport, not a 360px frame) before touching the page. +- Probe before asking for a scope ruling: a temporary `sed` over the files, one run and a `git checkout` took under four minutes for 17 files and let the question to the coordinator name the 5 failing examples and their cause. + +## Signals to watch for + +- `scrollWidth` or an element's `right` compared with `innerWidth`, a frame `width` parameter, or a number that is a frame's width. +- A local `fits?` or `frame_fits?` defined in a spec: the measure belongs in `spec/support/narrow_frame.rb`. +- An example named for a phone width that opens `open_in_narrow_frame(…, width: 360)` and checks overflow or an edge. From ff8ddbfafe7b1f6c660d65c9751b8cc21bccbe26 Mon Sep 17 00:00:00 2001 From: James Thompson Date: Thu, 12 Nov 2026 09:04:52 -0500 Subject: [PATCH 15/15] test(system): shape panel phone frames reuse open_in_viewport_frame (#318) open_phone_frame and open_fold_in_frame repeated its scrollbar measuring; they now call it and open the fold afterwards. Deferred until #330 (spec 078) merged, as the PR said. deck_shape_panel_spec and deck_page_spec: 66 examples green. Co-Authored-By: Claude Sonnet 5.5 --- spec/system/deck_shape_panel_spec.rb | 41 ++++++++-------------------- 1 file changed, 11 insertions(+), 30 deletions(-) diff --git a/spec/system/deck_shape_panel_spec.rb b/spec/system/deck_shape_panel_spec.rb index a29948f1..bd8679f8 100644 --- a/spec/system/deck_shape_panel_spec.rb +++ b/spec/system/deck_shape_panel_spec.rb @@ -110,23 +110,13 @@ def open_fold_fit JS end - # Opens the deck page in a frame whose viewport (the page's `documentElement.clientWidth`, the frame less its classic scrollbar) is - # 360px, with the fold open, and returns that viewport's width so the example can assert it. The scrollbar's width depends on the - # platform (15px on the Mac, about 12px on the Linux CI runner, found when CI measured an inside of 281px where the Mac measured - # 278px), so the frame is sized from the measured scrollbar, `360 + scrollbar`, and never from a constant: it opens at 375px, the - # Mac's answer, and reopens only when the scrollbar isn't 15px. + # Opens the deck page in a frame whose viewport is 360px (open_in_viewport_frame sizes it from the measured scrollbar: 15px on the Mac, + # about 12px on the Linux CI runner, found when CI measured an inside of 281px where the Mac measured 278px), with the fold open, and + # returns that viewport's width so the example can assert it. def open_phone_frame - width = 375 - 2.times do - open_in_narrow_frame(deck_path(deck), width:, ready: "details.c-shapefold") - within_narrow_frame do - page.execute_script('document.querySelector("details.c-shapefold").open = true') - scrollbar = page.evaluate_script("window.innerWidth - document.documentElement.clientWidth") - width = 360 + scrollbar - end - break if width == 375 - end - within_narrow_frame { page.evaluate_script("document.documentElement.clientWidth") } + viewport = open_in_viewport_frame(deck_path(deck), viewport: 360, ready: "details.c-shapefold") + within_narrow_frame { page.execute_script('document.querySelector("details.c-shapefold").open = true') } + viewport end # A phone's 360px viewport has no scrollbar and the frame keeps a classic one, so the frame is 360px plus the measured scrollbar @@ -203,21 +193,12 @@ def fold_panel = "details.c-shapefold .c-shape" def open_fold = page.execute_script('document.querySelector("details.c-shapefold").open = true') def inject_css(rules) = page.execute_script("const style = document.createElement('style'); style.textContent = #{rules.to_json}; document.head.append(style)") - # A frame whose viewport is `viewport` px wide with the fold open: the viewport plus the scrollbar the frame draws (measured, then - # the frame is reopened if it was not 15px). Returns the viewport it got, for the example to assert. + # A frame whose viewport is `viewport` px wide (open_in_viewport_frame) with the fold open. Returns the viewport it got, for the + # example to assert. def open_fold_in_frame(path, viewport) - width = viewport + 15 - 2.times do - open_in_narrow_frame(path, width:, ready: "details.c-shapefold") - scrollbar = within_narrow_frame do - open_fold - page.evaluate_script("window.innerWidth - document.documentElement.clientWidth") - end - break if viewport + scrollbar == width - - width = viewport + scrollbar - end - within_narrow_frame { page.evaluate_script("document.documentElement.clientWidth") } + got = open_in_viewport_frame(path, viewport:, ready: "details.c-shapefold") + within_narrow_frame { open_fold } + got end # Opens the deck page as `width` says (a viewport in px, a fixed frame, or :aside at 1280px) and yields the panel's selector, inside