oficina / slides Public

Phone layout: stack flow regions instead of drawing them in one cell

Inside @container slide (max-width:600px) the collapse rule
`[slides-region] { grid-area: auto }` is (0,1,0) and lost to the named
placements `[slides-region][data-region="…"] { grid-area: … }` at
(0,2,0). Every region kept an area name that `grid-template-areas: none`
had just removed, so all of them landed in the same implicit cell: the
heading drawn over the body on every flow slide at phone width — the
viewer, share links, and the editor's Phone and Both views (polish
audit B4).

The collapse rule is now `[slides-region][data-region]`: same
specificity, later in the file, so it wins.

Test: TestBrowserPhoneStacksFlowRegions (-tags browser) walks every
slide of the sample deck in the viewer at 390px, then the editor's
Phone and Both views, and fails on any two visible regions of a flow
slide that share height. Before: title, title-body and two-column all
overlapped on every surface; after: none.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev cdb479d5589b54b3d65d6208a06c39b7c03bb239 parent 5856dd5
2 files changed, +146 −2
  • internal/slides/static/slides.css +11 −2
  • internal/slidestest/browser_phone_test.go +135 −0
diff --git a/internal/slides/static/slides.css b/internal/slides/static/slides.css
index 5d03c4e..17276eb 100644
--- a/internal/slides/static/slides.css
+++ b/internal/slides/static/slides.css
@@ -682,12 +682,21 @@
template that still carries an explicit multi-column track (only
`two-column` does, at 1fr 1fr) auto-places its items into that
track instead of stacking them — the region rule below undoes the
- named placement, this one undoes the columns underneath it. */
+ named placement, this one undoes the columns underneath it.
+
+ The region rule has to be `[slides-region][data-region]`, not
+ `[slides-region]`: the named placements above are (0,2,0), and a
+ (0,1,0) rule here lost to them however late it came, so every
+ region kept an area the collapsed grid no longer has and all of
+ them landed in one implicit cell — the heading drawn over the
+ body on every flow slide (polish audit B4). Equal specificity and
+ later in the file is what wins; the browser drive
+ TestBrowserPhoneStacksFlowRegions holds it. */
[slides-slide][data-layout="flow"] > [slides-frame] {
grid-template-columns: 1fr;
grid-template-areas: none;
}
- [slides-region] {
+ [slides-region][data-region] {
grid-area: auto;
}
diff --git a/internal/slidestest/browser_phone_test.go b/internal/slidestest/browser_phone_test.go
new file mode 100644
index 0000000..fcf39fa
--- /dev/null
+++ b/internal/slidestest/browser_phone_test.go
@@ -0,0 +1,135 @@
+//go:build browser
+
+// A flow slide on a phone reads top to bottom, one region under the
+// next. The container query in slides.css collapses every template to
+// one column; the failure this file was written for (polish audit B4,
+// 2026-09-23) was that collapse losing a specificity fight — the
+// phone's `[slides-region] { grid-area: auto }` (0,1,0) against the
+// template's `[slides-region][data-region="heading"] { grid-area:
+// heading }` (0,2,0) — so every region kept an area name the collapsed
+// grid no longer had, and all of them landed in the same cell: the
+// heading drawn over the body on every flow slide, in the viewer, on
+// share links and in the editor's Phone and Both views.
+package slidestest
+
+import (
+ "encoding/json"
+ "strconv"
+ "testing"
+
+ "amadan.net/rastrillo/rastrillo/harness"
+ "github.com/chromedp/cdproto/runtime"
+ "github.com/chromedp/chromedp"
+)
+
+// stackedRegions is a JS expression that answers, for every flow
+// slide under root, the pairs of visible regions that overlap: an
+// empty list is the pass. Reading order is DOM order after the phone
+// layer's own `order`, so the check is on the boxes and not on which
+// comes first — two regions in one place is the bug whichever is on
+// top.
+func stackedRegions(root string) string {
+ return `JSON.stringify((() => {
+ const out = [];
+ for (const s of document.querySelectorAll('` + root + ` [slides-slide][data-layout="flow"]')) {
+ const rs = [...s.querySelectorAll(':scope > [slides-frame] > [slides-region]')]
+ .map(e => ({name: e.dataset.region, r: e.getBoundingClientRect()}))
+ .filter(x => x.r.width > 0 && x.r.height > 0);
+ if (s.getBoundingClientRect().width > 600) out.push('slide wider than a phone: ' + s.getBoundingClientRect().width);
+ for (let i = 0; i < rs.length; i++) for (let j = i + 1; j < rs.length; j++) {
+ const a = rs[i].r, b = rs[j].r;
+ const overlap = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
+ if (overlap > 0.5) out.push(s.dataset.template + ': ' + rs[i].name + ' [' + a.top.toFixed(1) + '–' + a.bottom.toFixed(1) +
+ '] overlaps ' + rs[j].name + ' [' + b.top.toFixed(1) + '–' + b.bottom.toFixed(1) + ']');
+ }
+ }
+ return out;
+ })())`
+}
+
+func noStackedRegions(t *testing.T, rig *harness.Rig, where, root string) {
+ t.Helper()
+ var raw string
+ var flows int
+ rig.Run(
+ chromedp.Evaluate(`document.querySelectorAll('`+root+` [slides-slide][data-layout="flow"]').length`, &flows),
+ chromedp.Evaluate(stackedRegions(root), &raw),
+ )
+ var bad []string
+ if err := json.Unmarshal([]byte(raw), &bad); err != nil {
+ t.Fatalf("%s: read the browser's answer: %v\n%s", where, err, raw)
+ }
+ for _, b := range bad {
+ t.Errorf("%s: %s", where, b)
+ }
+ if flows == 0 {
+ t.Logf("%s: no flow slide on screen", where)
+ }
+}
+
+// TestBrowserPhoneStacksFlowRegions walks the sample deck in the
+// viewer at 390px — every slide of it, so every template it carries —
+// and then opens the editor's Phone and Both views on a desktop
+// window, and requires that no two regions of a flow slide share any
+// height.
+func TestBrowserPhoneStacksFlowRegions(t *testing.T) {
+ rig, mailer := newRig(t)
+ browserSignIn(t, rig, mailer)
+
+ var deck string
+ rig.Run(chromedp.Evaluate(`(document.querySelector('a[href^="/d/"]')?.getAttribute('href') ?? '')`, &deck))
+ if !deckPath.MatchString(deck) {
+ t.Fatalf("the deck list links no deck: %q", deck)
+ }
+
+ var count, flowsSeen int
+ rig.Run(
+ chromedp.EmulateViewport(390, 844, chromedp.EmulateMobile),
+ chromedp.Navigate(rig.Origin+deck),
+ chromedp.WaitVisible(`[slides-slide]`, chromedp.ByQuery),
+ chromedp.Evaluate(`document.querySelectorAll('[slides-film] a').length`, &count),
+ )
+ if count < 2 {
+ t.Fatalf("the viewer lists %d slides; the sample deck has more", count)
+ }
+ for n := 1; n <= count; n++ {
+ var flow int
+ rig.Run(
+ chromedp.Navigate(rig.Origin+deck+"?slide="+strconv.Itoa(n)),
+ chromedp.WaitVisible(`[slides-slide]`, chromedp.ByQuery),
+ chromedp.Evaluate(`document.fonts.ready.then(() => document.querySelectorAll('[slides-slide][data-layout="flow"]').length)`, &flow,
+ func(p *runtime.EvaluateParams) *runtime.EvaluateParams { return p.WithAwaitPromise(true) }),
+ )
+ flowsSeen += flow
+ noStackedRegions(t, rig, "viewer at 390, slide "+strconv.Itoa(n), "body")
+ if n == 1 {
+ rig.Screen("body", "viewer at 390, slide 1")
+ }
+ }
+ if flowsSeen < 3 {
+ t.Errorf("the walk met only %d flow slides; the check needs real ones to mean anything", flowsSeen)
+ }
+
+ // The editor's Phone and Both views, on a desktop window: the phone
+ // stage is a slide in a phone-sized container, so the same query
+ // fires inside a 1440px page.
+ rig.Run(
+ chromedp.EmulateViewport(1440, 900),
+ chromedp.Navigate(rig.Origin+deck+"/edit"),
+ chromedp.WaitVisible(`[slides-editor]`, chromedp.ByQuery),
+ waitFor(`document.querySelector('[slides-stage] [slides-slide]')`),
+ chromedp.Click(`input[name="slides-view"][value="phone"]`, chromedp.ByQuery),
+ waitFor(`document.querySelector('[slides-stage-pane]').dataset.view === 'phone'`),
+ waitFor(`document.querySelector('[slides-stage] [slides-slide]')?.getBoundingClientRect().width <= 600`),
+ )
+ rig.Screen("[slides-editor]", "editor phone view")
+ noStackedRegions(t, rig, "editor Phone view", "[slides-stage]")
+
+ rig.Run(
+ chromedp.Click(`input[name="slides-view"][value="both"]`, chromedp.ByQuery),
+ waitFor(`document.querySelector('[slides-stage-pane]').dataset.view === 'both'`),
+ waitFor(`document.querySelector('[slides-stage-phone] [slides-slide]')`),
+ )
+ rig.Screen("[slides-editor]", "editor both view")
+ noStackedRegions(t, rig, "editor Both view, phone half", "[slides-stage-phone]")
+}