Stop presenter screens reload-looping; stack flow regions on phones
B3: the presenter view and phone remote never carried data-slide, so present.js read slide 0 and reloaded on every stream event (~100 loads in 3s). Both templates now render the 1-based slide, and present.js reloads at most once per state, which also covers a deck shortened mid-talk (eventFor publishes an unclamped slide; follow-up). B4: the phone container-query rule lost on specificity to the named region placements, so headings painted over body text on every flow slide in the viewer, share links and the editor's Phone/Both views. Reviewed by Fable 5.1 on discussion #13. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Squashed from fix/r1-present-reload-phone-regions into main.
9 files changed,
+341
−12
internal/slides/credits/assets.json+1 −1internal/slides/presentation.go+12 −2internal/slides/static/present.js+42 −7internal/slides/static/slides.css+11 −2internal/slides/templates/present_notes.html+1 −0internal/slides/templates/remote.html+1 −0internal/slidestest/browser_phone_test.go+135 −0internal/slidestest/browser_present_test.go+108 −0internal/slidestest/present_test.go+30 −0
CI passed — run details
CI log
=== gate ===
=== rust: fmt
=== rust: clippy
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.11s
=== wasm: the artifact the host embeds
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.02s
Finished `release` profile [optimized] target(s) in 0.09s
core/target/wasm32-unknown-unknown/release/slides_core.wasm (1228229 bytes)
=== the embedded wasm must match a fresh build
=== rust: test
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.04s
Running unittests src/lib.rs (core/target/debug/deps/slides_core-929bfceb2edd4a94)
running 30 tests
test layout::tests::aside_region_rect ... ok
test layout::tests::body_region_rect_narrows_for_an_aside ... ok
test layout::tests::heading_region_rect ... ok
test layout::tests::default_region_by_kind ... ok
test layout::tests::media_region_rect ... ok
test markdown::tests::empty_input_is_no_paragraphs_and_lines_keep_blanks ... ok
test markdown::tests::marks_nest_and_links_carry_marks ... ok
test markdown::tests::inline_marks ... ok
test markdown::tests::paragraphs_split_on_blank_lines_and_list_markers ... ok
test check::tests::the_empty_document_passes ... ok
test schema::tests::an_empty_map_reads_as_a_set_mark ... ok
test schema::tests::an_href_map_without_a_string_url_reads_as_no_link ... ok
test schema::tests::href_is_written_as_a_one_key_map_and_reads_back ... ok
test layout::tests::to_canvas_narrows_body_when_an_aside_is_present ... ok
test schema::tests::href_written_as_a_plain_string_still_reads_back ... ok
test layout::tests::to_flow_is_a_no_op_when_already_in_order ... ok
test layout::tests::to_flow_classifies_by_position_and_kind ... ok
test layout::tests::to_canvas_splits_a_shared_region_equally_and_sets_z_by_index ... ok
test layout::tests::to_flow_orders_y_then_x ... ok
test view::tests::malformed_background_is_the_default ... ok
test view::tests::malformed_box_is_null_and_phone_keeps_only_typed_fields ... ok
test view::tests::assets_are_typed_or_skipped ... ok
test check::tests::what_the_ops_write_passes ... ok
test view::tests::malformed_fonts_are_null ... ok
test check::tests::y_prosemirror_shapes_pass ... ok
test check::tests::bare_text_at_fragment_level_is_reported ... ok
test check::tests::an_element_inside_a_paragraph_is_reported ... ok
test check::tests::a_non_paragraph_child_is_reported ... ok
test check::tests::an_unknown_formatting_attribute_is_reported ... ok
test layout::tests::to_flow_keeps_a_concurrent_text_edit ... ok
test result: ok. 30 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running tests/no_host_imports.rs (core/target/debug/deps/no_host_imports-184c0d8bf00e14f9)
running 1 test
test no_host_imports ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running tests/ops.rs (core/target/debug/deps/ops-256addce27f366d1)
running 12 tests
test ops_are_atomic ... ok
test add_slide_then_view_lists_it ... ok
test add_block_with_markdown_produces_runs ... ok
test layout_switch_assigns_boxes ... ok
test remove_block_drops_it_from_columns_children ... ok
test duplicate_slide_remaps_columns_children ... ok
test columns_children_must_name_blocks_on_the_slide ... ok
test update_roundtrip ... ok
test columns_children_have_one_parent_and_no_cycles ... ok
test move_block_keeps_a_concurrent_text_edit ... ok
test move_slide_keeps_a_concurrent_text_edit ... ok
test remaining_ops_roundtrip ... ok
test result: ok. 12 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
Doc-tests slides_core
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
=== rust: fmt (pdf)
=== rust: clippy (pdf)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.11s
=== wasm: the pdf artifact the host embeds
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.03s
Finished `release` profile [optimized] target(s) in 0.09s
core/pdf/target/wasm32-unknown-unknown/release/slides_pdf.wasm (5301002 bytes)
=== the embedded pdf wasm must match a fresh build
=== rust: test (pdf)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.05s
Running unittests src/lib.rs (core/pdf/target/debug/deps/slides_pdf-891fafaa44f9fd64)
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running tests/no_host_imports.rs (core/pdf/target/debug/deps/no_host_imports-17ccbb1d62120093)
running 1 test
test no_host_imports ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
Running tests/render.rs (core/pdf/target/debug/deps/render-2e1d7e8b19d78595)
running 6 tests
test a_null_zero_length_pair_is_no_frames_not_a_dereference ... ok
test text_with_no_fonts_at_all_still_renders_the_shapes ... ok
test raster_image_is_embedded ... ok
test abi_frames_round_trip ... ok
test two_svgs_make_two_pages ... ok
test renders_text_as_embedded_glyphs ... ok
test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s
Doc-tests slides_pdf
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
=== fixtures: node dependencies
v24.19.0
11.17.0
=== fixtures: npm ci (needs the registry — see .amadan/README.md)
added 19 packages, and audited 20 packages in 500ms
5 packages are looking for funding
run `npm fund` for details
found 0 vulnerabilities
npm warn allow-scripts 1 package has install scripts not yet covered by allowScripts:
npm warn allow-scripts esbuild@0.28.2 (postinstall: node install.js)
npm warn allow-scripts
npm warn allow-scripts Run `npm approve-scripts --allow-scripts-pending` to review, or `npm approve-scripts <pkg>` to allow.
=== fixtures: the JS side's own tests
✔ one finished upload is register_asset plus the block that uses it, in one batch (13.446651ms)
✔ a record missing its numbers still makes an op the core accepts (1.535787ms)
✔ unusedAssets is what no slide refers to, and remove_asset drops one (3.49121ms)
✔ the Unused images list reconciles by id and names each Remove button (0.751869ms)
✔ an upload refusal is said in words the person can act on (0.180544ms)
✔ clampBox keeps the box inside the frame and no smaller than the minimum (1.343482ms)
✔ fixBox writes four decimals (0.172017ms)
✔ guides are the thirds plus every other block edge (0.209237ms)
✔ snapMove pulls the nearest edge onto a guide within one per cent, and leaves a far one (0.368411ms)
✔ snapResize moves only the edges the handle drags (0.191644ms)
✔ moveBox and resizeBox by handle (0.207784ms)
✔ nudge: arrows move by half a per cent, Shift by two, Alt resizes (0.214486ms)
✔ rectBox reads a block rectangle against its frame (0.219756ms)
✔ typing into a block is a text change on its slide only (9.45256ms)
✔ a block's props changing is structure on its slide (1.85652ms)
✔ adding, moving or removing a block is structure on its slide (2.955069ms)
✔ the notes are text on their slide (1.405496ms)
✔ a slide added, removed or moved is an order change (2.000706ms)
✔ one transaction touching several things reports each once (0.782155ms)
✔ what the ops write passes, and so does an empty document (9.636391ms)
✔ the shapes y-prosemirror and older documents write pass (2.032836ms)
✔ an unknown formatting attribute is reported with the core's message (2.438344ms)
✔ an element inside a paragraph, a non-paragraph child and bare text are reported (3.08633ms)
✔ the unknownmark poison shape is what the check refuses (1.870976ms)
✔ y-prosemirror 1.3.7 deletes the words under an unknown mark; sanitising first keeps them (5.299069ms)
✔ the vocabulary is the five marks and nothing else (0.188578ms)
✔ an empty body is no frames (1.57553ms)
✔ frames come back in order with their exact bytes (0.38945ms)
✔ a length prefix is big-endian (0.209527ms)
✔ a truncated frame throws rather than returning a short update (1.802881ms)
✔ deck controls are set_meta (27.029322ms)
✔ slide controls are set_slide, and background keeps its colour (2.526216ms)
✔ move up and down are move_slide with the right after (1.790157ms)
✔ block props: value kinds, region, a shape label (1.528172ms)
✔ box fields are set_block on the desktop and set_phone in the phone view (3.349798ms)
✔ move block and the layer buttons (1.435482ms)
✔ add block: region after the selection on flow, a default box on canvas, a heading level that fits (5.533853ms)
✔ a block is named by its type and its first words (0.697639ms)
✔ blockChange: a control element to its op (0.904521ms)
✔ the Blocks list reconciles by id and keeps its buttons (0.771314ms)
✔ open resolves to the button that closed it and the trimmed field (1.726108ms)
✔ a second open while the dialog is up joins the first (0.218584ms)
✔ a missing dialog mounts as nothing (0.144426ms)
✔ a tab advances its own cursor only when its POST is the very next row (2.233416ms)
✔ the cursor never moves backwards when a response is overtaken by this tab's own write (0.501877ms)
✔ a later response still carries the cursor forward (0.288453ms)
✔ a burst of notifications collapses into one fetch at a time (0.259649ms)
✔ an event naming a seq the tab already has fetches nothing (0.20553ms)
✔ a stopped subscriber drops a response that arrives late (0.265941ms)
✔ stop closes the stream, and start opens exactly one (0.211971ms)
✔ a hidden tab closes its stream on a shortened deadline, and activity reopens it (0.929948ms)
✔ switching away and back inside the grace never drops the stream, so nothing has to be caught up (0.264008ms)
✔ a visible tab that is only being READ closes when idle and comes back on any activity (0.347201ms)
✔ activity while streaming defers the idle close rather than reopening anything (0.213484ms)
✔ a stream failure is never reported as a save failure, and heals on activity (0.289926ms)
✔ an empty response advances the cursor without applying anything (0.208545ms)
✔ a failed fetch leaves the cursor alone so the next notification retries it (1.349272ms)
✔ a decode failure retries once through the raw log, then gives up (0.705343ms)
✔ updatesURL spells the query string exactly, in particular raw as the literal raw=1 (0.13577ms)
✔ empty_doc_views_as_defaults (7.858026ms)
✔ set_meta (3.68647ms)
✔ slides: add, move, remove, set_slide, set_notes (7.470701ms)
✔ duplicate_slide (4.203946ms)
✔ blocks: add, move, remove, set_block (3.788308ms)
✔ set_phone (1.478621ms)
✔ table cells follow rows and cols (1.79787ms)
✔ canvas blocks: box defaults from the region, z from the index (1.316822ms)
✔ assets: register and remove (0.901084ms)
✔ markdown: every mark, code blocks are plain lines (2.243765ms)
✔ batch_is_atomic (2.314917ms)
✔ a batch applies in one transaction with the given origin (0.574872ms)
✔ columns_children_validated (2.128502ms)
✔ layout_switch_round_trip (2.497634ms)
✔ view_matches_snapshot_fixtures (15.551589ms)
✔ columns children have one parent and no cycles (codex 7) (1.471048ms)
✔ canvas-to-flow keeps a concurrent text edit (codex 4) (3.494616ms)
✔ move_block keeps a concurrent text edit, and later inserts land where after says (codex 4) (2.961912ms)
✔ move_slide keeps a concurrent text edit, and later inserts land where after says (codex 4 at slide level) (2.023358ms)
✔ format: {name} and {n} interpolate; an unknown key is left as text (0.962669ms)
✔ a beat on connect, then one every twenty seconds (2.095901ms)
✔ a move posts once, 200ms after the last of a burst, and re-arms the beat (0.284175ms)
✔ a hidden tab stops beating; coming back posts at once (0.279866ms)
✔ applySet leaves this tab out and answers what changed (0.567739ms)
✔ a set with rubbish in it is read for what it does carry (0.27106ms)
✔ initials, and a client id per tab (3.831639ms)
✔ a resync that fails is retried with backoff until it succeeds, without ever running twice at once (2.247643ms)
✔ the browser's online event retries immediately, not just on the next backoff tick (0.431717ms)
✔ reportDivergence before any failure runs the first attempt without a caller ever calling online (1.480876ms)
✔ a permanent 4xx drops its batch instead of retrying it, and does not poison the next edit (84.82171ms)
✔ a network failure or a 5xx is retried, not dropped (59.937795ms)
✔ a 500 is retried the same way a network failure is, never dropped (60.338643ms)
✔ the core-written structure reads as ProseMirror marks (5.754731ms)
✔ a y-prosemirror round trip preserves bold and the link (1.952497ms)
✔ y-prosemirror writes href as the one-key map and marks as {} (0.657344ms)
✔ a plain-string href, written before the map shape, still reads (0.515963ms)
✔ textFragment writes the same shape as writeText (0.409877ms)
✔ safeHref is the server allowlist, and a disallowed href never draws an anchor (0.309342ms)
✔ parseHash splits the slide and the block, and survives a stray % (1.728323ms)
✔ destroy takes the listeners off, and a second mount leaves one of each (0.824944ms)
✔ formatHash is the inverse (1.524215ms)
✔ textBlocks finds the text-bearing blocks and skips the rest (1.296785ms)
✔ mountTarget is the element whose children are the paragraphs (0.177928ms)
✔ paragraphTag keeps the renderer's own markup per type (0.117588ms)
✔ blockFragment finds a block's text by slide and block id (5.094381ms)
✔ notesFragment finds the slide's notes (0.645713ms)
✔ blockIDs and slideIDs are the orders on the stage and the strip (0.691568ms)
✔ cellFragments are a table's cells, row-major (0.941619ms)
✔ a load that fails leaves the stage on the slide it was showing and says so (1.169951ms)
✔ slideAfter is the slide to show once one is gone (0.153012ms)
✔ the queue words map to the four states, failed staying failed (0.748944ms)
✔ a dropped batch outranks a load error outranks the queue (0.18432ms)
✔ dropTarget: before puts it ahead of the item, after behind it, null means first (1.062893ms)
✔ a drop that leaves the order as it is is no move (0.214216ms)
✔ dropOp is the move_slide (0.863806ms)
✔ labelFor is the slide's first heading, else the numbered fallback (4.187315ms)
✔ flush re-points every dirty thumbnail and relabels it (1.44538ms)
✔ destroy takes the item-menu listener off; a second mount leaves one (0.231828ms)
✔ plan says what to add, drop and where each item goes (0.601431ms)
✔ sync moves only the items out of place and keeps focus (0.949594ms)
✔ desktop view: box and size are the block; hidden and order are always the phone map (1.409253ms)
✔ phone view: everything goes to set_phone, carrying the rest of the map (0.357381ms)
✔ the ops it makes are ones the document takes (8.268204ms)
✔ phoneMap reads only the four keys, and only well-typed (0.235906ms)
✔ the stored view is read defensively (0.326283ms)
✔ linkAt reads the link under the cursor and nothing elsewhere (2.277147ms)
✔ setLink marks the selection (1.998121ms)
✔ setLink on a collapsed cursor inside a link replaces that link (0.528706ms)
✔ setLink with nothing selected and no link does nothing (0.167589ms)
✔ removeLink clears the link under the cursor (0.348834ms)
✔ the keymap binds every documented shortcut (0.184601ms)
✔ Mod-b toggles bold through the keymap (0.468385ms)
✔ setLink refuses an address outside the allowlist and writes nothing (0.567027ms)
✔ the state machine runs choose → sign → put → register → op (2.542267ms)
✔ a file the deck will not take never reaches the network (0.721402ms)
✔ alt text is required before the bytes move, unless the image is decorative (0.401431ms)
✔ a step that fails stops the ones after it and reports its own code (0.643789ms)
✔ a failure that is not ours still becomes an UploadError (0.355236ms)
✔ a second pick while one is in flight is refused, not raced (0.34077ms)
✔ an unreadable size is zero rather than a failure (0.212212ms)
✔ a per-call applyOp wins, so an upload lands on the block it was chosen for (0.286329ms)
✔ checkFile takes exactly the four types (0.330611ms)
✔ sha256Hex is the hex digest of the bytes (11.150188ms)
✔ refusal reads this app's JSON error shape, and survives one that is not (0.40607ms)
✔ malformed fonts are null (2.890259ms)
✔ a malformed background is the theme default (1.321852ms)
✔ a malformed box is null (0.597774ms)
✔ the phone layer keeps only its typed fields, order as a whole number (1.520539ms)
✔ assets are typed or skipped (0.33545ms)
✔ every poison update is deterministic bytes that apply to any deck (5.93261ms)
ℹ tests 149
ℹ suites 0
ℹ pass 149
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 275.058979
=== the vectors must match a fresh generation
canvas: 22 ops, 3715 bytes
columns: 23 ops, 3134 bytes
every-block: 36 ops, 3180 bytes
table: 14 ops, 1509 bytes
title-only: 6 ops, 1136 bytes
=== the editor bundle must match a fresh build
wrote internal/slides/static/editor.ea69e9f399e840e0.js (375198 bytes)
=== go: vet, gofmt, test, migration check
go vet ./...
go test ./...
? amadan.net/oficina/slides/cmd/slides [no test files]
ok amadan.net/oficina/slides/internal/slides 10.559s
ok amadan.net/oficina/slides/internal/slides/core 2.413s
ok amadan.net/oficina/slides/internal/slides/deck (cached)
ok amadan.net/oficina/slides/internal/slides/fonts (cached)
? amadan.net/oficina/slides/internal/slides/icons [no test files]
ok amadan.net/oficina/slides/internal/slides/layout 0.012s
ok amadan.net/oficina/slides/internal/slides/live (cached)
ok amadan.net/oficina/slides/internal/slides/pdfcore (cached)
ok amadan.net/oficina/slides/internal/slides/present (cached)
ok amadan.net/oficina/slides/internal/slides/render 0.012s
ok amadan.net/oficina/slides/internal/slides/svg (cached)
ok amadan.net/oficina/slides/internal/slidestest 34.112s
go run amadan.net/rastrillo/rastrillo/cmd/rastrillo migration check
rastrillo migration check: models and migrations agree
python3 scripts/credits.py --check
credits: checked
gate: ok
diff --git a/internal/slides/credits/assets.json b/internal/slides/credits/assets.json| index 17768b0..02d1e1d 100644 |
| --- a/internal/slides/credits/assets.json |
| +++ b/internal/slides/credits/assets.json |
| @@ -42,7 +42,7 @@ |
| "internal/slides/static/fonts/LiberationSerif-BoldItalic.ttf": "4b2635d2ec4d992373dcae577c742a98ecfd841ffc1b8a103b5f10db163dbc7a", |
| "internal/slides/static/fonts/LiberationSerif-Italic.ttf": "de18fe26337e03241952dca46398d5b66f3c5738d080bfd284f66e67b926f597", |
| "internal/slides/static/fonts/LiberationSerif-Regular.ttf": "9caef765d2e891c10dd73658894f01e660fe0c1c83e0bda7d6edf561d8f623d4", |
| - "internal/slides/static/present.js": "da431f7e84127fcdde770feda45f0c719c8b7fa4d2a5d698fdd1dc45465977a3", |
| + "internal/slides/static/present.js": "a28c9d039e5ca3ae09e4de3e1bc2ee2c7950458606962269f95477fd1f3a6424", |
| "internal/slides/static/rastrillo.js": "3489c972007390ee955d3592583406a20ad8f57993a73d56f545ef06870b8c58", |
| "internal/slides/static/select.js": "6a9548abfb177f65361fcfc4333d9b0a068cec6b9acf8d7eada05591409584a0", |
| "rust-toolchain.toml": "88bc3a3493bc98cad9f5bc5b4f8948739e1a418d8367373bb9b854fecaf5edc6" |
diff --git a/internal/slides/presentation.go b/internal/slides/presentation.go| index 8cf083a..4e99a54 100644 |
| --- a/internal/slides/presentation.go |
| +++ b/internal/slides/presentation.go |
| @@ -472,6 +472,7 @@ type presenterPage struct { |
| Session string |
| Token string |
| + Now int |
| Counter string |
| CurrentLabel string |
| Current template.HTML |
| @@ -521,6 +522,7 @@ type remotePage struct { |
| DeckTitle string |
| Session string |
| + Now int |
| Counter string |
| CurrentLabel string |
| Current template.HTML |
| @@ -792,7 +794,7 @@ func (a *app) presenterNotes(w http.ResponseWriter, r *http.Request) { |
| ToolsLabel: rastrillo.T(r, "slides.viewer.tools"), |
| } |
| shown := presentedFor(r, view, p) |
| - page.Counter, page.Current = shown.Counter, shown.Current |
| + page.Now, page.Counter, page.Current = shown.Now, shown.Counter, shown.Current |
| page.Next, page.HasNext, page.Notes = shown.Next, shown.HasNext, shown.Notes |
| if holder { |
| @@ -845,6 +847,13 @@ func clockWords(t time.Time) string { |
| // a hotel wifi. The projector at /p/{session} still gets the real |
| // rendering, because that one is the slide. |
| type presented struct { |
| + // Now is the 1-based slide these were rendered for — the number the |
| + // stream's events carry — and it goes on the page's script root as |
| + // data-slide, so static/present.js knows what it is already showing. |
| + // Without it the script took the page to be on slide 0, the first |
| + // event "moved" it, and the page reloaded itself on every load |
| + // (polish audit B3). |
| + Now int |
| Counter string |
| Current template.HTML |
| Next template.HTML |
| @@ -863,6 +872,7 @@ func presentedFor(r *http.Request, view *deck.Deck, p Presentation) presented { |
| } |
| index := clampSlide(p.Slide, count) |
| out := presented{ |
| + Now: index + 1, |
| Counter: rastrillo.Tf(r, "slides.present.progress", "n", index+1, "total", count), |
| Current: slideThumbImg(r, view, p, index), |
| } |
| @@ -965,7 +975,7 @@ func (a *app) remote(w http.ResponseWriter, r *http.Request) { |
| ToolsLabel: rastrillo.T(r, "slides.viewer.tools"), |
| } |
| shown := presentedFor(r, view, p) |
| - page.Counter, page.Current = shown.Counter, shown.Current |
| + page.Now, page.Counter, page.Current = shown.Now, shown.Counter, shown.Current |
| page.Next, page.HasNext, page.Notes = shown.Next, shown.HasNext, shown.Notes |
| render(w, "remote", page) |
| } |
diff --git a/internal/slides/static/present.js b/internal/slides/static/present.js| index 8d46682..5cc4509 100644 |
| --- a/internal/slides/static/present.js |
| +++ b/internal/slides/static/present.js |
| @@ -105,13 +105,6 @@ |
| // event this page understands and is left alone rather than fetched. |
| if (!(slide > 0) || slide === requested) return; |
| requested = slide; |
| - if (!stage || !fragmentHref) { |
| - // The presenter view and the remote show two slides and the |
| - // notes; re-rendering all that in here would be a second |
| - // renderer to keep in step with the Go one. |
| - window.location.reload(); |
| - return; |
| - } |
| var mine = ++newest; |
| var back = function () { |
| // Only this request's own failure rolls the record back, and |
| @@ -131,12 +124,54 @@ |
| .catch(back); |
| } |
| + // The pages with no stage to swap — the presenter view and the |
| + // remote — follow by reloading: they show two slides, the notes and |
| + // the blackout state, and re-rendering all that in here would be a |
| + // second renderer to keep in step with the Go one. Their root says |
| + // what they were rendered with (data-slide, data-blackout), and they |
| + // reload only when an event says something else. |
| + // |
| + // And at most once for any one state. A page that has just reloaded |
| + // for slide 3 and still says something else — a root missing its |
| + // data-slide, or a session pointing past a deck that has since got |
| + // shorter, which the server clamps — would otherwise reload again on |
| + // the stream's first event, and again after that, forever: the |
| + // presenter view once did exactly that about fifty times a second |
| + // (polish audit B3). The record is taken off at load, so it only |
| + // ever guards the load straight after the reload that wrote it. |
| + var renderedBlackout = root.getAttribute('data-blackout') === 'true'; |
| + var reloadKey = 'slides-present-reload:' + window.location.pathname; |
| + var reloadedFor = null; |
| + try { |
| + reloadedFor = window.sessionStorage.getItem(reloadKey); |
| + window.sessionStorage.removeItem(reloadKey); |
| + } catch (err) { /* no storage: see follow */ } |
| + |
| + function follow(state) { |
| + var slide = Number(state.slide); |
| + if (!(slide > 0)) return; |
| + var blackout = !!state.blackout; |
| + if (slide === displayed && blackout === renderedBlackout) return; |
| + var want = String(slide) + (blackout ? ':blackout' : ''); |
| + if (want === reloadedFor) return; |
| + try { |
| + window.sessionStorage.setItem(reloadKey, want); |
| + } catch (err) { |
| + // Nowhere to remember that this reload happened, so only reload |
| + // a page that at least knows which slide it is on. |
| + if (!displayed) return; |
| + } |
| + reloadedFor = want; |
| + window.location.reload(); |
| + } |
| + |
| // apply is the whole of what an event does. The state is complete — |
| // never a delta — so applying one is exactly right rather than |
| // approximately, and a page that missed some is right again on the |
| // next one. |
| function apply(state) { |
| if (state.ended) { window.location.reload(); return; } |
| + if (!stage || !fragmentHref) { follow(state); return; } |
| root.setAttribute('data-blackout', state.blackout ? 'true' : 'false'); |
| if (note) note.hidden = !state.blackout; |
| show(Number(state.slide)); |
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/slides/templates/present_notes.html b/internal/slides/templates/present_notes.html| index 221dd2b..d8d9469 100644 |
| --- a/internal/slides/templates/present_notes.html |
| +++ b/internal/slides/templates/present_notes.html |
| @@ -110,6 +110,7 @@ |
| A presenter view opened without the token watches and cannot |
| drive, which is exactly what the buttons above do too. */}} |
| <div slides-preview data-session="{{.Session}}" |
| + data-slide="{{.Now}}" |
| data-blackout="{{if .Blackout}}true{{else}}false{{end}}" |
| {{- if .Token}} data-remote-token="{{.Token}}"{{end}}> |
| <div> |
diff --git a/internal/slides/templates/remote.html b/internal/slides/templates/remote.html| index db74c99..8e984aa 100644 |
| --- a/internal/slides/templates/remote.html |
| +++ b/internal/slides/templates/remote.html |
| @@ -61,6 +61,7 @@ |
| {{template "page-header" dict "Title" .Title "Sub" .DeckTitle}} |
| <div slides-remote data-session="{{.Session}}" data-remote-token="{{.Controls.Token}}" |
| + data-slide="{{.Now}}" |
| data-blackout="{{if .Blackout}}true{{else}}false{{end}}"> |
| <div slides-remote-preview> |
| <div> |
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]") |
| +} |
diff --git a/internal/slidestest/browser_present_test.go b/internal/slidestest/browser_present_test.go| new file mode 100644 |
| index 0000000..13ed87b |
| --- /dev/null |
| +++ b/internal/slidestest/browser_present_test.go |
| @@ -0,0 +1,108 @@ |
| +//go:build browser |
| + |
| +// The presenting screens with their script on. The Go tests read the |
| +// markup; only a browser runs static/present.js against it, and the |
| +// failure this file was written for (polish audit B3, 2026-09-23) was |
| +// the script and the markup disagreeing: the presenter view and the |
| +// remote carried no data-slide, so the page believed it was on slide |
| +// 0, the stream's first event said slide 1, and the page reloaded — |
| +// and did the same on every load after, about fifty times a second. |
| +package slidestest |
| + |
| +import ( |
| + "regexp" |
| + "sync/atomic" |
| + "testing" |
| + "time" |
| + |
| + "github.com/chromedp/cdproto/page" |
| + "github.com/chromedp/chromedp" |
| + "github.com/chromedp/chromedp/kb" |
| +) |
| + |
| +var notesLocation = regexp.MustCompile(`^(/p/[23456789abcdefghjkmnpqrstuvwxyz]{10})/notes\?t=([A-Za-z0-9_-]{43})$`) |
| + |
| +// TestBrowserPresentingScreensHoldStill: the presenter view and the |
| +// phone remote each load once and then stay put while nothing |
| +// changes, and each reloads exactly once when the slide does move — |
| +// which is how those two pages follow a talk (present.js reloads them |
| +// rather than re-rendering two slides and the notes itself). |
| +func TestBrowserPresentingScreensHoldStill(t *testing.T) { |
| + rig, mailer := newRig(t) |
| + browserSignIn(t, rig, mailer) |
| + |
| + // Every top-level navigation the tab makes, reloads included. |
| + var loads atomic.Int64 |
| + chromedp.ListenTarget(rig.Context(), func(ev any) { |
| + if e, ok := ev.(*page.EventFrameNavigated); ok && e.Frame.ParentID == "" { |
| + loads.Add(1) |
| + } |
| + }) |
| + |
| + 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 path string |
| + rig.Run( |
| + chromedp.Navigate(rig.Origin+deck), |
| + chromedp.Click(`form[action$="/present"] button`, chromedp.ByQuery), |
| + waitFor(`location.pathname.endsWith('/notes')`), |
| + chromedp.Evaluate(`location.pathname + location.search`, &path), |
| + ) |
| + m := notesLocation.FindStringSubmatch(path) |
| + if m == nil { |
| + t.Fatalf("Present landed on %q, want /p/{session}/notes?t={token}", path) |
| + } |
| + session, token := m[1], m[2] |
| + |
| + // holdsStill waits for the page to settle and the stream to have |
| + // said its piece (one event on connect), then counts loads over a |
| + // window long enough for the broken page to have reloaded hundreds |
| + // of times. |
| + holdsStill := func(name string) { |
| + t.Helper() |
| + rig.Run(chromedp.Sleep(1500 * time.Millisecond)) |
| + before := loads.Load() |
| + rig.Run(chromedp.Sleep(3 * time.Second)) |
| + if n := loads.Load() - before; n != 0 { |
| + t.Errorf("the %s loaded %d more times in 3s with nothing changing, want 0", name, n) |
| + } |
| + // And it holds still because it knows where it is, not only |
| + // because present.js refuses to reload twice for one slide: |
| + // the script's root names the slide the counter shows. |
| + var marked, counter string |
| + rig.Run( |
| + chromedp.Evaluate(`document.querySelector('[data-session]')?.getAttribute('data-slide') ?? 'missing'`, &marked), |
| + chromedp.Evaluate(`(document.querySelector('[slides-counter]')?.textContent ?? '').trim().split(' ')[0]`, &counter), |
| + ) |
| + if marked != counter { |
| + t.Errorf("the %s's script root says data-slide=%q; the counter shows slide %q", name, marked, counter) |
| + } |
| + } |
| + // movesOnce presses the right arrow — present.js posts `next` — and |
| + // the page must reload onto the new slide once, and only once. |
| + movesOnce := func(name, counter string) { |
| + t.Helper() |
| + before := loads.Load() |
| + rig.Run( |
| + chromedp.KeyEvent(kb.ArrowRight), |
| + waitFor(`document.querySelector('[slides-counter]')?.textContent.trim().startsWith('`+counter+` /')`), |
| + chromedp.Sleep(3*time.Second), |
| + ) |
| + if n := loads.Load() - before; n != 1 { |
| + t.Errorf("the %s loaded %d times after one slide change, want exactly 1", name, n) |
| + } |
| + } |
| + |
| + holdsStill("presenter view") |
| + rig.Screen("body", "presenter view") |
| + movesOnce("presenter view", "2") |
| + |
| + rig.Run(chromedp.Navigate(rig.Origin + session + "/remote?t=" + token)) |
| + holdsStill("remote") |
| + rig.Screen("body", "remote") |
| + movesOnce("remote", "3") |
| +} |
diff --git a/internal/slidestest/present_test.go b/internal/slidestest/present_test.go| index b482905..fd69a71 100644 |
| --- a/internal/slidestest/present_test.go |
| +++ b/internal/slidestest/present_test.go |
| @@ -567,3 +567,33 @@ func TestFollowOfferedOnlyWhileLive(t *testing.T) { |
| t.Errorf("following an ended session: status %d, want 303 back to the deck", rec.Code) |
| } |
| } |
| + |
| +// TestEveryPresentingPageSaysWhichSlideItShows: every page that loads |
| +// static/present.js puts the slide it was rendered for on the script's |
| +// root as data-slide, the same 1-based number the stream's events |
| +// carry. The presenter view and the remote once had none, so the |
| +// script took them to be on slide 0 and reloaded on every event — |
| +// the stream's first included — forever (polish audit B3). The |
| +// browser half is TestBrowserPresentingScreensHoldStill; this one is |
| +// in the plain suite so the gate holds it too. |
| +func TestEveryPresentingPageSaysWhichSlideItShows(t *testing.T) { |
| + a := newApp(t) |
| + cookies := signedIn(t, a, allowedAddress) |
| + path := sampleDeck(t, a, cookies) |
| + session, token := startPresenting(t, a, path, cookies) |
| + command(t, a, session, "next", token, "", nil) |
| + |
| + for _, page := range []string{ |
| + "/p/" + session + "?t=" + token, |
| + "/p/" + session + "/notes?t=" + token, |
| + "/p/" + session + "/remote?t=" + token, |
| + } { |
| + body := mustGet(t, a, page, http.StatusOK, cookies).Body.String() |
| + if !strings.Contains(body, `static/present`) { |
| + t.Errorf("%s no longer loads present.js; this test is about the pages that do", page) |
| + } |
| + if !strings.Contains(body, `data-slide="2"`) { |
| + t.Errorf("%s: the script root does not say data-slide=\"2\" after one next:\n%s", page, body) |
| + } |
| + } |
| +} |