oficina / slides Public

Answer the review (Claude Fable 5.1); estilo 846b397

- estilo's busy.js now holds only a submit with a spinner showing
  (rastrillo discussion 51), so script submits no longer stall.
- Scripts that submit on the visitor's behalf pass the form's button, so
  it shows the spinner (Calendar quick-add, Sheets file drop).
- Slides' presenter Next/Previous/Blackout opt out (data-busy="false"):
  a live talk wants those instant. Memoria's old vendoring tool no longer
  writes rastrillo.js, which would bring the old busy rule back.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 913820e664b1b20908a15d32c7f633ec4a0e5421 parent 3078387
5 files changed, +16 −5
  • go.mod +1 −1
  • go.sum +2 −0
  • internal/slides/credits/assets.json +1 −1
  • internal/slides/static/busy.js +7 −2
  • internal/slides/templates/present_controls.html +5 −1

CI passed — run details

CI log
=== gate ===
=== rust: fmt
=== rust: clippy
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.12s
=== wasm: the artifact the host embeds
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.03s
    Finished `release` profile [optimized] target(s) in 0.10s
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 check::tests::the_empty_document_passes ... ok
test layout::tests::aside_region_rect ... ok
test layout::tests::body_region_rect_narrows_for_an_aside ... ok
test layout::tests::default_region_by_kind ... ok
test layout::tests::heading_region_rect ... ok
test layout::tests::media_region_rect ... ok
test layout::tests::to_canvas_narrows_body_when_an_aside_is_present ... ok
test layout::tests::to_canvas_splits_a_shared_region_equally_and_sets_z_by_index ... ok
test check::tests::what_the_ops_write_passes ... ok
test markdown::tests::inline_marks ... ok
test check::tests::bare_text_at_fragment_level_is_reported ... ok
test check::tests::a_non_paragraph_child_is_reported ... ok
test check::tests::an_element_inside_a_paragraph_is_reported ... ok
test markdown::tests::paragraphs_split_on_blank_lines_and_list_markers ... ok
test check::tests::y_prosemirror_shapes_pass ... ok
test layout::tests::to_flow_classifies_by_position_and_kind ... ok
test markdown::tests::empty_input_is_no_paragraphs_and_lines_keep_blanks ... ok
test check::tests::an_unknown_formatting_attribute_is_reported ... ok
test layout::tests::to_flow_is_a_no_op_when_already_in_order ... 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 schema::tests::href_written_as_a_plain_string_still_reads_back ... ok
test layout::tests::to_flow_orders_y_then_x ... ok
test view::tests::malformed_fonts_are_null ... ok
test view::tests::assets_are_typed_or_skipped ... 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 layout::tests::to_flow_keeps_a_concurrent_text_edit ... ok
test markdown::tests::marks_nest_and_links_carry_marks ... 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 columns_children_have_one_parent_and_no_cycles ... ok
test update_roundtrip ... ok
test move_block_keeps_a_concurrent_text_edit ... ok
test move_slide_keeps_a_concurrent_text_edit ... ok
test columns_children_must_name_blocks_on_the_slide ... ok
test duplicate_slide_remaps_columns_children ... 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.16s
=== wasm: the pdf artifact the host embeds
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.04s
    Finished `release` profile [optimized] target(s) in 0.10s
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.06s
     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 raster_image_is_embedded ... ok
test text_with_no_fonts_at_all_still_renders_the_shapes ... 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.01s

   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 600ms

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 (19.630821ms)
✔ a record missing its numbers still makes an op the core accepts (1.403378ms)
✔ unusedAssets is what no slide refers to, and remove_asset drops one (3.815626ms)
✔ the Unused images list reconciles by id and names each Remove button (1.285237ms)
✔ an upload refusal is said in words the person can act on (0.264115ms)
✔ clampBox keeps the box inside the frame and no smaller than the minimum (1.651162ms)
✔ fixBox writes four decimals (0.304631ms)
✔ guides are the thirds plus every other block edge (0.275857ms)
✔ snapMove pulls the nearest edge onto a guide within one per cent, and leaves a far one (0.527027ms)
✔ snapResize moves only the edges the handle drags (0.252793ms)
✔ moveBox and resizeBox by handle (0.274764ms)
✔ nudge: arrows move by half a per cent, Shift by two, Alt resizes (0.258134ms)
✔ rectBox reads a block rectangle against its frame (0.182532ms)
✔ typing into a block is a text change on its slide only (9.141269ms)
✔ a block's props changing is structure on its slide (1.509748ms)
✔ adding, moving or removing a block is structure on its slide (1.991649ms)
✔ the notes are text on their slide (1.160673ms)
✔ a slide added, removed or moved is an order change (2.00794ms)
✔ one transaction touching several things reports each once (1.027003ms)
✔ what the ops write passes, and so does an empty document (9.084561ms)
✔ the shapes y-prosemirror and older documents write pass (1.528402ms)
✔ an unknown formatting attribute is reported with the core's message (1.958267ms)
✔ an element inside a paragraph, a non-paragraph child and bare text are reported (2.499922ms)
✔ the unknownmark poison shape is what the check refuses (1.144624ms)
✔ y-prosemirror 1.3.7 deletes the words under an unknown mark; sanitising first keeps them (3.363599ms)
✔ the vocabulary is the five marks and nothing else (0.123281ms)
✔ a reload reuses the id this tab stored for the deck (1.078299ms)
✔ two tabs, and two decks in one tab, get different ids (0.22989ms)
✔ a stored value that is not an id, or storage that throws, gives a fresh id (0.379691ms)
✔ a duplicated tab gives up the copied id; the original keeps it (5.691608ms)
✔ a reload finds no rival and keeps its id (2.391097ms)
✔ presence switches id: beats carry the new one and the old one is somebody else (2.185102ms)
✔ an empty body is no frames (1.977012ms)
✔ frames come back in order with their exact bytes (0.27794ms)
✔ a length prefix is big-endian (0.30431ms)
✔ a truncated frame throws rather than returning a short update (1.501842ms)
✔ deck controls are set_meta (15.215803ms)
✔ slide controls are set_slide, and background keeps its colour (4.22403ms)
✔ move up and down are move_slide with the right after (2.359248ms)
✔ block props: value kinds, region, a shape label (2.331275ms)
✔ box fields are set_block on the desktop and set_phone in the phone view (4.617136ms)
✔ move block and the layer buttons (2.855848ms)
✔ add block: region after the selection on flow, a default box on canvas, a heading level that fits (7.3215ms)
✔ a block is named by its type and its first words (1.128003ms)
✔ blockChange: a control element to its op (1.453142ms)
✔ the Blocks list reconciles by id and keeps its buttons (1.191852ms)
✔ open resolves to the button that closed it and the trimmed field (1.830849ms)
✔ a second open while the dialog is up joins the first (0.461163ms)
✔ a missing dialog mounts as nothing (0.161573ms)
✔ a tab advances its own cursor only when its POST is the very next row (3.798202ms)
✔ the cursor never moves backwards when a response is overtaken by this tab's own write (0.553827ms)
✔ a later response still carries the cursor forward (0.2939ms)
✔ a burst of notifications collapses into one fetch at a time (0.776554ms)
✔ an event naming a seq the tab already has fetches nothing (0.215504ms)
✔ a stopped subscriber drops a response that arrives late (0.289583ms)
✔ stop closes the stream, and start opens exactly one (0.280575ms)
✔ a hidden tab closes its stream on a shortened deadline, and activity reopens it (0.970518ms)
✔ switching away and back inside the grace never drops the stream, so nothing has to be caught up (0.658513ms)
✔ a visible tab that is only being READ closes when idle and comes back on any activity (0.562062ms)
✔ activity while streaming defers the idle close rather than reopening anything (0.316352ms)
✔ a stream failure is never reported as a save failure, and heals on activity (0.237023ms)
✔ an empty response advances the cursor without applying anything (0.32603ms)
✔ a failed fetch leaves the cursor alone so the next notification retries it (1.915356ms)
✔ a decode failure retries once through the raw log, then gives up (0.267591ms)
✔ updatesURL spells the query string exactly, in particular raw as the literal raw=1 (0.159088ms)
✔ empty_doc_views_as_defaults (8.665687ms)
✔ set_meta (4.064461ms)
✔ slides: add, move, remove, set_slide, set_notes (7.285452ms)
✔ duplicate_slide (3.200804ms)
✔ blocks: add, move, remove, set_block (5.827323ms)
✔ set_phone (1.563758ms)
✔ table cells follow rows and cols (2.050569ms)
✔ canvas blocks: box defaults from the region, z from the index (1.272043ms)
✔ assets: register and remove (0.821338ms)
✔ markdown: every mark, code blocks are plain lines (1.573367ms)
✔ batch_is_atomic (6.766602ms)
✔ a batch applies in one transaction with the given origin (1.294485ms)
✔ columns_children_validated (1.939302ms)
✔ layout_switch_round_trip (2.801846ms)
✔ view_matches_snapshot_fixtures (26.534699ms)
✔ columns children have one parent and no cycles (codex 7) (1.238379ms)
✔ canvas-to-flow keeps a concurrent text edit (codex 4) (3.253673ms)
✔ move_block keeps a concurrent text edit, and later inserts land where after says (codex 4) (3.720948ms)
✔ move_slide keeps a concurrent text edit, and later inserts land where after says (codex 4 at slide level) (3.117839ms)
✔ format: {name} and {n} interpolate; an unknown key is left as text (0.727723ms)
✔ a beat on connect, then one every twenty seconds (2.109881ms)
✔ a move posts once, 200ms after the last of a burst, and re-arms the beat (0.316362ms)
✔ a hidden tab stops beating; coming back posts at once (0.630971ms)
✔ applySet leaves this tab out and answers what changed (0.481211ms)
✔ a set with rubbish in it is read for what it does carry (0.186008ms)
✔ initials, and a client id per tab (3.739864ms)
✔ a resync that fails is retried with backoff until it succeeds, without ever running twice at once (1.510889ms)
✔ the browser's online event retries immediately, not just on the next backoff tick (0.301855ms)
✔ reportDivergence before any failure runs the first attempt without a caller ever calling online (1.079061ms)
✔ a permanent 4xx drops its batch instead of retrying it, and does not poison the next edit (85.407396ms)
✔ a network failure or a 5xx is retried, not dropped (59.953018ms)
✔ a 500 is retried the same way a network failure is, never dropped (60.494072ms)
✔ the core-written structure reads as ProseMirror marks (7.75409ms)
✔ a y-prosemirror round trip preserves bold and the link (2.701178ms)
✔ y-prosemirror writes href as the one-key map and marks as {} (0.798525ms)
✔ a plain-string href, written before the map shape, still reads (0.548497ms)
✔ textFragment writes the same shape as writeText (0.556271ms)
✔ safeHref is the server allowlist, and a disallowed href never draws an anchor (0.357649ms)
✔ parseHash splits the slide and the block, and survives a stray % (1.549501ms)
✔ destroy takes the listeners off, and a second mount leaves one of each (1.805851ms)
✔ formatHash is the inverse (1.95479ms)
✔ textBlocks finds the text-bearing blocks and skips the rest (2.285369ms)
✔ mountTarget is the element whose children are the paragraphs (0.321121ms)
✔ paragraphTag keeps the renderer's own markup per type (0.185167ms)
✔ blockFragment finds a block's text by slide and block id (7.082493ms)
✔ notesFragment finds the slide's notes (1.011584ms)
✔ blockIDs and slideIDs are the orders on the stage and the strip (0.962182ms)
✔ cellFragments are a table's cells, row-major (1.262654ms)
✔ a load that fails leaves the stage on the slide it was showing and says so (1.382369ms)
✔ slideAfter is the slide to show once one is gone (0.197861ms)
✔ the queue words map to savestatus states, failed staying failed (1.140255ms)
✔ a dropped batch outranks the queue, for the rest of the session (0.186419ms)
✔ the editor status: the save line and the load error are two lines (0.265026ms)
✔ mountSaveStatus loads estilo's module and mounts it on the line (1.020762ms)
✔ a save-status module that will not load says so and refuses to mount (0.450624ms)
✔ dropTarget: before puts it ahead of the item, after behind it, null means first (0.926365ms)
✔ a drop that leaves the order as it is is no move (0.194655ms)
✔ dropOp is the move_slide (0.858017ms)
✔ labelFor is the slide's first heading, else the numbered fallback (6.752445ms)
✔ flush re-points every dirty thumbnail and relabels it (2.368034ms)
✔ destroy takes the item-menu listener off; a second mount leaves one (0.375173ms)
✔ plan says what to add, drop and where each item goes (1.037593ms)
✔ sync moves only the items out of place and keeps focus (1.64491ms)
✔ desktop view: box and size are the block; hidden and order are always the phone map (1.94391ms)
✔ phone view: everything goes to set_phone, carrying the rest of the map (0.492132ms)
✔ the ops it makes are ones the document takes (10.091738ms)
✔ phoneMap reads only the four keys, and only well-typed (0.231884ms)
✔ the stored view is read defensively (0.403505ms)
✔ linkAt reads the link under the cursor and nothing elsewhere (2.9101ms)
✔ setLink marks the selection (2.592836ms)
✔ setLink on a collapsed cursor inside a link replaces that link (0.742981ms)
✔ setLink with nothing selected and no link does nothing (0.262221ms)
✔ removeLink clears the link under the cursor (0.498444ms)
✔ the keymap binds every documented shortcut (0.272701ms)
✔ Mod-b toggles bold through the keymap (0.695442ms)
✔ setLink refuses an address outside the allowlist and writes nothing (0.626152ms)
✔ the state machine runs choose → sign → put → register → op (4.86501ms)
✔ a file the deck will not take never reaches the network (0.748962ms)
✔ alt text is required before the bytes move, unless the image is decorative (0.51776ms)
✔ a step that fails stops the ones after it and reports its own code (0.61947ms)
✔ a failure that is not ours still becomes an UploadError (0.269946ms)
✔ a second pick while one is in flight is refused, not raced (0.477124ms)
✔ an unreadable size is zero rather than a failure (0.302847ms)
✔ a per-call applyOp wins, so an upload lands on the block it was chosen for (0.216065ms)
✔ checkFile takes exactly the four types (0.327823ms)
✔ sha256Hex is the hex digest of the bytes (10.529137ms)
✔ refusal reads this app's JSON error shape, and survives one that is not (0.569726ms)
✔ malformed fonts are null (3.699999ms)
✔ a malformed background is the theme default (1.392838ms)
✔ a malformed box is null (0.616975ms)
✔ the phone layer keeps only its typed fields, order as a whole number (0.48608ms)
✔ assets are typed or skipped (1.667112ms)
✔ every poison update is deterministic bytes that apply to any deck (6.348428ms)
ℹ tests 158
ℹ suites 0
ℹ pass 158
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 326.559069
=== 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.0463180baf5aab9f.js (376416 bytes)
=== go: vet, gofmt, test, migration check
go vet ./...
go: downloading amadan.net/oficina/estilo.git v0.0.0-20260929165053-846b39772f3c
go test ./...
?   	amadan.net/oficina/slides/cmd/slides	[no test files]
ok  	amadan.net/oficina/slides/internal/slides	12.810s
ok  	amadan.net/oficina/slides/internal/slides/core	2.710s
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	(cached)
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	(cached)
ok  	amadan.net/oficina/slides/internal/slides/svg	(cached)
ok  	amadan.net/oficina/slides/internal/slidestest	38.662s
?   	amadan.net/oficina/slides/scripts/writeestilo	[no test files]
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/go.mod b/go.mod
index ce8e509..fe6d6d5 100644
--- a/go.mod
+++ b/go.mod
@@ -5,7 +5,7 @@ go 1.25.0
toolchain go1.26.5
require (
- amadan.net/oficina/estilo.git v0.0.0-20260929095838-0994675f496e
+ amadan.net/oficina/estilo.git v0.0.0-20260929165053-846b39772f3c
amadan.net/oficina/home/connect v0.0.0-20260923190447-01166fcfc0a9
amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489
amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd
diff --git a/go.sum b/go.sum
index d870287..80846f5 100644
--- a/go.sum
+++ b/go.sum
@@ -6,6 +6,8 @@ amadan.net/oficina/estilo.git v0.0.0-20260929095046-d0664ee49914 h1:V0AyxXYCxqGZ
amadan.net/oficina/estilo.git v0.0.0-20260929095046-d0664ee49914/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw=
amadan.net/oficina/estilo.git v0.0.0-20260929095838-0994675f496e h1:LTSmIWUS8RjPb6m94Ynf7moyFYeBXr08rdOr3nmNqvg=
amadan.net/oficina/estilo.git v0.0.0-20260929095838-0994675f496e/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw=
+amadan.net/oficina/estilo.git v0.0.0-20260929165053-846b39772f3c h1:YYocIz3r3b4oIA0K/58JkSsmyTOCzMT/2GaW/MFbbPo=
+amadan.net/oficina/estilo.git v0.0.0-20260929165053-846b39772f3c/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw=
amadan.net/oficina/home/connect v0.0.0-20260923190447-01166fcfc0a9 h1:K/PaOVdQqRMwifPD6ipiwLeGR5nbFSxm9FxjrDJlZcg=
amadan.net/oficina/home/connect v0.0.0-20260923190447-01166fcfc0a9/go.mod h1:YroavXe43Wietec+po8mtSk4a9Bir2h31Q3iAf4MWxI=
amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 h1:RPKtyry/71i06o8L+gjAaUyexbmG56kO1YPs601URAM=
diff --git a/internal/slides/credits/assets.json b/internal/slides/credits/assets.json
index 0a4ad3d..bf2182e 100644
--- a/internal/slides/credits/assets.json
+++ b/internal/slides/credits/assets.json
@@ -26,7 +26,7 @@
"internal/slides/fonts/files/LiberationSerif-Italic.ttf": "de18fe26337e03241952dca46398d5b66f3c5738d080bfd284f66e67b926f597",
"internal/slides/fonts/files/LiberationSerif-Regular.ttf": "9caef765d2e891c10dd73658894f01e660fe0c1c83e0bda7d6edf561d8f623d4",
"internal/slides/pdfcore/testdata/slides_pdf.wasm": "38c9c096a704eb93bf68f63ec1e2fdd3fa603474fe3c0ecfc1827d7d9fb91a3d",
- "internal/slides/static/busy.js": "5894d72db12e143ab569150794fdf7beacec8dda523623e8caeebd949f5c9c06",
+ "internal/slides/static/busy.js": "b5be5c1c7239b066ec5d13e0c9a21a06c7c7a448542dc7d664a989f4589bae02",
"internal/slides/static/calendar.js": "dfa06d84768b8e99d1e50d9dca81caadacf2412db74ac4e432d78c29680bbdd2",
"internal/slides/static/datetime.js": "fe2f7688409dafd94fc4b440097613266fd5fc153edc7fc40316581c6813e3bd",
"internal/slides/static/editor.0463180baf5aab9f.js": "0463180baf5aab9f4f7f2c99ca9d82d69d27d7facebd30831f1459bdeb912ab5",
diff --git a/internal/slides/static/busy.js b/internal/slides/static/busy.js
index 5d0a9f8..550a843 100644
--- a/internal/slides/static/busy.js
+++ b/internal/slides/static/busy.js
@@ -166,7 +166,7 @@
// back); so does the guard's own refusal of a second submit. Only a
// submit nobody else stopped is held, and only if the busy rule armed
// it (aria-busy on the form: not opted out, not a dialog, not a new
- // window).
+ // window) and a button is showing the spinner.
var held = new WeakSet(); // submit events this listener cancelled
var pending = new Map(); // form -> timer, for a hold still running
var releasing = null; // the form whose held submit is going now
@@ -175,9 +175,14 @@
if (!form || form.tagName !== "FORM") return;
if (form === releasing) { releasing = null; return; }
if (e.defaultPrevented || form.getAttribute("aria-busy") !== "true") return;
+ // Only a submit with a spinner to show is held: the hold is for the
+ // spinner. A script's requestSubmit() with no submitter, or a button
+ // that opted out, has nothing on screen to keep up, and holding it
+ // would only be a silent 650ms stall. The form is still guarded.
+ var btn = e.submitter || null;
+ if (!btn || btn.getAttribute("aria-busy") !== "true") return;
e.preventDefault();
held.add(e);
- var btn = e.submitter || null;
pending.set(form, setTimeout(function () { release(form, btn); }, HOLD_MS));
});
diff --git a/internal/slides/templates/present_controls.html b/internal/slides/templates/present_controls.html
index fa1a8ad..e5fa100 100644
--- a/internal/slides/templates/present_controls.html
+++ b/internal/slides/templates/present_controls.html
@@ -18,9 +18,13 @@
Buttons []{Op, Label, Slide} — one form each; Slide only for a
goto, which nothing renders yet and which the handler
accepts so a native remote has it. */}}
+{{/* data-busy="false": the busy rule's 650ms spinner is for a button
+ that changes something and wants a beat to say so; Next and
+ Previous during a live talk want to be instant. A deliberate
+ exception to the suite's rule, 2026-09-29. */}}
{{define "present-controls"}}
{{- range .Buttons}}
-<form method="post" action="{{$.Action}}">
+<form method="post" action="{{$.Action}}" data-busy="false">
<input type="hidden" name="op" value="{{.Op}}">
{{- if .Slide}}
<input type="hidden" name="slide" value="{{.Slide}}">