oficina / slides Public

Merge branch 'busy-rule' into main

The busy rule: submit buttons show a spinner for at least 650ms

rastrillo's busy.js via estilo's busy package (estilo 846b397): the label
gives way to a spinner for at least 650ms, the button disables, the form
refuses a second submit, and Back resets it. Reviewed by Claude Fable 5.1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 9e40156996f069cd813e0f71a3907386f762d88d parent 76d2080parent 913820e
11 files changed, +381 −161
  • go.mod +1 −1
  • go.sum +6 −0
  • internal/slides/credits/assets.json +2 −1
  • internal/slides/static/busy.css +47 −0
  • internal/slides/static/busy.js +252 −0
  • internal/slides/static/rastrillo.js +9 −135
  • internal/slides/templates/layout.html +2 −0
  • internal/slides/templates/present_controls.html +5 −1
  • internal/slidestest/vendored_test.go +27 −2
  • scripts/writeestilo/main.go +30 −0
  • scripts/writetheme/main.go +0 −21

CI passed — run details

CI log
=== gate ===
=== rust: fmt
=== rust: clippy
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.10s
=== wasm: the artifact the host embeds
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.03s
    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::heading_region_rect ... ok
test layout::tests::media_region_rect ... ok
test layout::tests::aside_region_rect ... ok
test layout::tests::default_region_by_kind ... ok
test layout::tests::body_region_rect_narrows_for_an_aside ... 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 layout::tests::to_flow_is_a_no_op_when_already_in_order ... 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_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 schema::tests::href_is_written_as_a_one_key_map_and_reads_back ... ok
test check::tests::what_the_ops_write_passes ... ok
test view::tests::malformed_box_is_null_and_phone_keeps_only_typed_fields ... ok
test check::tests::bare_text_at_fragment_level_is_reported ... ok
test view::tests::malformed_background_is_the_default ... ok
test check::tests::an_element_inside_a_paragraph_is_reported ... ok
test view::tests::assets_are_typed_or_skipped ... ok
test check::tests::a_non_paragraph_child_is_reported ... ok
test view::tests::malformed_fonts_are_null ... ok
test check::tests::y_prosemirror_shapes_pass ... 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 duplicate_slide_remaps_columns_children ... ok
test remove_block_drops_it_from_columns_children ... ok
test update_roundtrip ... ok
test columns_children_must_name_blocks_on_the_slide ... ok
test columns_children_have_one_parent_and_no_cycles ... ok
test move_slide_keeps_a_concurrent_text_edit ... ok
test move_block_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.17s
=== 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.12s
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.07s
     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 533ms

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 (18.971052ms)
✔ a record missing its numbers still makes an op the core accepts (1.664496ms)
✔ unusedAssets is what no slide refers to, and remove_asset drops one (6.021787ms)
✔ the Unused images list reconciles by id and names each Remove button (1.427394ms)
✔ an upload refusal is said in words the person can act on (0.370011ms)
✔ clampBox keeps the box inside the frame and no smaller than the minimum (2.0896ms)
✔ fixBox writes four decimals (0.355844ms)
✔ guides are the thirds plus every other block edge (0.318003ms)
✔ snapMove pulls the nearest edge onto a guide within one per cent, and leaves a far one (0.574021ms)
✔ snapResize moves only the edges the handle drags (0.287236ms)
✔ moveBox and resizeBox by handle (0.304478ms)
✔ nudge: arrows move by half a per cent, Shift by two, Alt resizes (0.250468ms)
✔ rectBox reads a block rectangle against its frame (0.295511ms)
✔ typing into a block is a text change on its slide only (12.18083ms)
✔ a block's props changing is structure on its slide (1.999442ms)
✔ adding, moving or removing a block is structure on its slide (3.178891ms)
✔ the notes are text on their slide (2.11182ms)
✔ a slide added, removed or moved is an order change (2.334435ms)
✔ one transaction touching several things reports each once (1.204328ms)
✔ what the ops write passes, and so does an empty document (10.26208ms)
✔ the shapes y-prosemirror and older documents write pass (2.043363ms)
✔ an unknown formatting attribute is reported with the core's message (2.718253ms)
✔ an element inside a paragraph, a non-paragraph child and bare text are reported (7.443229ms)
✔ the unknownmark poison shape is what the check refuses (2.19678ms)
✔ y-prosemirror 1.3.7 deletes the words under an unknown mark; sanitising first keeps them (5.374099ms)
✔ the vocabulary is the five marks and nothing else (0.179565ms)
✔ a reload reuses the id this tab stored for the deck (1.062633ms)
✔ two tabs, and two decks in one tab, get different ids (0.228636ms)
✔ a stored value that is not an id, or storage that throws, gives a fresh id (0.366624ms)
✔ a duplicated tab gives up the copied id; the original keeps it (9.448832ms)
✔ a reload finds no rival and keeps its id (3.683935ms)
✔ presence switches id: beats carry the new one and the old one is somebody else (2.349695ms)
✔ an empty body is no frames (4.62498ms)
✔ frames come back in order with their exact bytes (0.586054ms)
✔ a length prefix is big-endian (0.298607ms)
✔ a truncated frame throws rather than returning a short update (2.421889ms)
✔ deck controls are set_meta (26.041885ms)
✔ slide controls are set_slide, and background keeps its colour (3.403851ms)
✔ move up and down are move_slide with the right after (1.915054ms)
✔ block props: value kinds, region, a shape label (2.345577ms)
✔ box fields are set_block on the desktop and set_phone in the phone view (11.584967ms)
✔ move block and the layer buttons (2.148079ms)
✔ add block: region after the selection on flow, a default box on canvas, a heading level that fits (14.455444ms)
✔ a block is named by its type and its first words (1.465374ms)
✔ blockChange: a control element to its op (2.239599ms)
✔ the Blocks list reconciles by id and keeps its buttons (1.194119ms)
✔ open resolves to the button that closed it and the trimmed field (1.602942ms)
✔ a second open while the dialog is up joins the first (0.225882ms)
✔ a missing dialog mounts as nothing (0.132187ms)
✔ a tab advances its own cursor only when its POST is the very next row (3.285982ms)
✔ the cursor never moves backwards when a response is overtaken by this tab's own write (0.506786ms)
✔ a later response still carries the cursor forward (0.258363ms)
✔ a burst of notifications collapses into one fetch at a time (0.228045ms)
✔ an event naming a seq the tab already has fetches nothing (0.18757ms)
✔ a stopped subscriber drops a response that arrives late (0.387744ms)
✔ stop closes the stream, and start opens exactly one (0.274281ms)
✔ a hidden tab closes its stream on a shortened deadline, and activity reopens it (0.694355ms)
✔ switching away and back inside the grace never drops the stream, so nothing has to be caught up (0.55212ms)
✔ a visible tab that is only being READ closes when idle and comes back on any activity (0.998854ms)
✔ activity while streaming defers the idle close rather than reopening anything (0.237222ms)
✔ a stream failure is never reported as a save failure, and heals on activity (0.214711ms)
✔ an empty response advances the cursor without applying anything (0.326489ms)
✔ a failed fetch leaves the cursor alone so the next notification retries it (0.699516ms)
✔ a decode failure retries once through the raw log, then gives up (1.710803ms)
✔ updatesURL spells the query string exactly, in particular raw as the literal raw=1 (0.196026ms)
✔ empty_doc_views_as_defaults (6.51619ms)
✔ set_meta (2.859987ms)
✔ slides: add, move, remove, set_slide, set_notes (5.376693ms)
✔ duplicate_slide (3.93323ms)
✔ blocks: add, move, remove, set_block (4.335329ms)
✔ set_phone (1.61827ms)
✔ table cells follow rows and cols (2.074161ms)
✔ canvas blocks: box defaults from the region, z from the index (1.114519ms)
✔ assets: register and remove (1.455225ms)
✔ markdown: every mark, code blocks are plain lines (5.307303ms)
✔ batch_is_atomic (2.328706ms)
✔ a batch applies in one transaction with the given origin (0.730844ms)
✔ columns_children_validated (1.70428ms)
✔ layout_switch_round_trip (1.980976ms)
✔ view_matches_snapshot_fixtures (15.414905ms)
✔ columns children have one parent and no cycles (codex 7) (1.238491ms)
✔ canvas-to-flow keeps a concurrent text edit (codex 4) (4.192173ms)
✔ move_block keeps a concurrent text edit, and later inserts land where after says (codex 4) (3.26344ms)
✔ move_slide keeps a concurrent text edit, and later inserts land where after says (codex 4 at slide level) (2.3847ms)
✔ format: {name} and {n} interpolate; an unknown key is left as text (1.048427ms)
✔ a beat on connect, then one every twenty seconds (2.045186ms)
✔ a move posts once, 200ms after the last of a burst, and re-arms the beat (0.483923ms)
✔ a hidden tab stops beating; coming back posts at once (0.351907ms)
✔ applySet leaves this tab out and answers what changed (6.585958ms)
✔ a set with rubbish in it is read for what it does carry (0.310739ms)
✔ initials, and a client id per tab (5.493341ms)
✔ a resync that fails is retried with backoff until it succeeds, without ever running twice at once (2.193895ms)
✔ the browser's online event retries immediately, not just on the next backoff tick (0.361775ms)
✔ reportDivergence before any failure runs the first attempt without a caller ever calling online (1.413036ms)
✔ a permanent 4xx drops its batch instead of retrying it, and does not poison the next edit (83.56718ms)
✔ a network failure or a 5xx is retried, not dropped (60.365675ms)
✔ a 500 is retried the same way a network failure is, never dropped (61.232692ms)
✔ the core-written structure reads as ProseMirror marks (5.612192ms)
✔ a y-prosemirror round trip preserves bold and the link (2.374341ms)
✔ y-prosemirror writes href as the one-key map and marks as {} (0.72862ms)
✔ a plain-string href, written before the map shape, still reads (0.495816ms)
✔ textFragment writes the same shape as writeText (0.482952ms)
✔ safeHref is the server allowlist, and a disallowed href never draws an anchor (0.335757ms)
✔ parseHash splits the slide and the block, and survives a stray % (1.637375ms)
✔ destroy takes the listeners off, and a second mount leaves one of each (0.759617ms)
✔ formatHash is the inverse (2.003529ms)
✔ textBlocks finds the text-bearing blocks and skips the rest (2.329957ms)
✔ mountTarget is the element whose children are the paragraphs (0.379087ms)
✔ paragraphTag keeps the renderer's own markup per type (0.164877ms)
✔ blockFragment finds a block's text by slide and block id (5.86267ms)
✔ notesFragment finds the slide's notes (0.805924ms)
✔ blockIDs and slideIDs are the orders on the stage and the strip (0.854174ms)
✔ cellFragments are a table's cells, row-major (1.33454ms)
✔ a load that fails leaves the stage on the slide it was showing and says so (1.436159ms)
✔ slideAfter is the slide to show once one is gone (0.200474ms)
✔ the queue words map to savestatus states, failed staying failed (1.169673ms)
✔ a dropped batch outranks the queue, for the rest of the session (0.264394ms)
✔ the editor status: the save line and the load error are two lines (0.358098ms)
✔ mountSaveStatus loads estilo's module and mounts it on the line (1.126ms)
✔ a save-status module that will not load says so and refuses to mount (0.603787ms)
✔ dropTarget: before puts it ahead of the item, after behind it, null means first (1.158071ms)
✔ a drop that leaves the order as it is is no move (0.30553ms)
✔ dropOp is the move_slide (1.413788ms)
✔ labelFor is the slide's first heading, else the numbered fallback (6.934239ms)
✔ flush re-points every dirty thumbnail and relabels it (1.964455ms)
✔ destroy takes the item-menu listener off; a second mount leaves one (0.259184ms)
✔ plan says what to add, drop and where each item goes (0.710436ms)
✔ sync moves only the items out of place and keeps focus (1.068915ms)
✔ desktop view: box and size are the block; hidden and order are always the phone map (2.248866ms)
✔ phone view: everything goes to set_phone, carrying the rest of the map (0.518758ms)
✔ the ops it makes are ones the document takes (13.814939ms)
✔ phoneMap reads only the four keys, and only well-typed (0.152775ms)
✔ the stored view is read defensively (0.286635ms)
✔ linkAt reads the link under the cursor and nothing elsewhere (2.857231ms)
✔ setLink marks the selection (2.548265ms)
✔ setLink on a collapsed cursor inside a link replaces that link (0.686841ms)
✔ setLink with nothing selected and no link does nothing (0.257951ms)
✔ removeLink clears the link under the cursor (0.445201ms)
✔ the keymap binds every documented shortcut (0.277248ms)
✔ Mod-b toggles bold through the keymap (0.725213ms)
✔ setLink refuses an address outside the allowlist and writes nothing (0.629865ms)
✔ the state machine runs choose → sign → put → register → op (9.059305ms)
✔ a file the deck will not take never reaches the network (0.749849ms)
✔ alt text is required before the bytes move, unless the image is decorative (0.412389ms)
✔ a step that fails stops the ones after it and reports its own code (0.702011ms)
✔ a failure that is not ours still becomes an UploadError (0.392772ms)
✔ a second pick while one is in flight is refused, not raced (0.508329ms)
✔ an unreadable size is zero rather than a failure (0.303997ms)
✔ a per-call applyOp wins, so an upload lands on the block it was chosen for (0.29509ms)
✔ checkFile takes exactly the four types (0.478603ms)
✔ sha256Hex is the hex digest of the bytes (11.432504ms)
✔ refusal reads this app's JSON error shape, and survives one that is not (0.658289ms)
✔ malformed fonts are null (3.003645ms)
✔ a malformed background is the theme default (1.342917ms)
✔ a malformed box is null (0.657367ms)
✔ the phone layer keeps only its typed fields, order as a whole number (0.513649ms)
✔ assets are typed or skipped (0.371774ms)
✔ every poison update is deterministic bytes that apply to any deck (9.741509ms)
ℹ tests 158
ℹ suites 0
ℹ pass 158
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 313.077077
=== 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 test ./...
?   	amadan.net/oficina/slides/cmd/slides	[no test files]
ok  	amadan.net/oficina/slides/internal/slides	11.948s
ok  	amadan.net/oficina/slides/internal/slides/core	2.642s
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	37.563s
?   	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 4e5fcf2..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-20260929070530-5b2ce835e773
+ 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 7f17677..80846f5 100644
--- a/go.sum
+++ b/go.sum
@@ -2,6 +2,12 @@ amadan.net/oficina/estilo.git v0.0.0-20260924071126-894afdcb71a9 h1:vHOkWeJ7mRwL
amadan.net/oficina/estilo.git v0.0.0-20260924071126-894afdcb71a9/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw=
amadan.net/oficina/estilo.git v0.0.0-20260929070530-5b2ce835e773 h1:9DIAhG3TaGG+LE2+8LvU73ZEhfK+oNn2y2p4kFjAAi0=
amadan.net/oficina/estilo.git v0.0.0-20260929070530-5b2ce835e773/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw=
+amadan.net/oficina/estilo.git v0.0.0-20260929095046-d0664ee49914 h1:V0AyxXYCxqGZClOLqvppw+cCTXsn3b9NmRQ8CV5ZlXY=
+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 af52697..bf2182e 100644
--- a/internal/slides/credits/assets.json
+++ b/internal/slides/credits/assets.json
@@ -26,6 +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": "b5be5c1c7239b066ec5d13e0c9a21a06c7c7a448542dc7d664a989f4589bae02",
"internal/slides/static/calendar.js": "dfa06d84768b8e99d1e50d9dca81caadacf2412db74ac4e432d78c29680bbdd2",
"internal/slides/static/datetime.js": "fe2f7688409dafd94fc4b440097613266fd5fc153edc7fc40316581c6813e3bd",
"internal/slides/static/editor.0463180baf5aab9f.js": "0463180baf5aab9f4f7f2c99ca9d82d69d27d7facebd30831f1459bdeb912ab5",
@@ -43,7 +44,7 @@
"internal/slides/static/fonts/LiberationSerif-Italic.ttf": "de18fe26337e03241952dca46398d5b66f3c5738d080bfd284f66e67b926f597",
"internal/slides/static/fonts/LiberationSerif-Regular.ttf": "9caef765d2e891c10dd73658894f01e660fe0c1c83e0bda7d6edf561d8f623d4",
"internal/slides/static/present.js": "a28c9d039e5ca3ae09e4de3e1bc2ee2c7950458606962269f95477fd1f3a6424",
- "internal/slides/static/rastrillo.js": "3489c972007390ee955d3592583406a20ad8f57993a73d56f545ef06870b8c58",
+ "internal/slides/static/rastrillo.js": "b1eac44b25a98ca8144c59dc792ac6c5f66570b40b14f5b428c6838f88e137a3",
"internal/slides/static/select.js": "6a9548abfb177f65361fcfc4333d9b0a068cec6b9acf8d7eada05591409584a0",
"rust-toolchain.toml": "88bc3a3493bc98cad9f5bc5b4f8948739e1a418d8367373bb9b854fecaf5edc6"
},
diff --git a/internal/slides/static/busy.css b/internal/slides/static/busy.css
new file mode 100644
index 0000000..fc355e6
--- /dev/null
+++ b/internal/slides/static/busy.css
@@ -0,0 +1,47 @@
+/* busy.css — the one part of rastrillo's busy rule that lives in tokens.css:
+ the spinner takes the label's place. Copied byte for byte from rastrillo's
+ ui/tokens.css (estilo busy/provenance.json), for apps whose vendored
+ tokens.css predates it. Link it after tokens.css. */
+
+/* With no busy label there are no words to show while it works, so the
+ spinner takes the label's place instead of joining it: joining widens
+ the button mid-click and shoves whatever sits beside it. The shim
+ marks a button that HAS a busy label with data-idle-label, which is
+ what the :not() keys on — those words were asked for, so they stay.
+
+ :has(> spinner) because aria-busy is ordinary ARIA an app may set on
+ its own fetch button with no shim and no spinner; keyed on aria-busy
+ alone, that button would lose its label and show nothing at all.
+
+ The label is unpainted, never removed: aria-busy plus the untouched
+ text is what a screen reader announces. -webkit-text-fill-color
+ rather than color, because the ring is drawn in currentColor — a
+ transparent color would take the spinner with it. Element children
+ (an icon) are not reached by the text fill and take opacity 0, which
+ unlike visibility keeps them in the accessibility tree.
+
+ The media query stands the swap down for two readers a lone ring
+ fails. Under reduced motion the ring is still and dimmed, and on its
+ own reads as a stray "C". Under forced colours the engine repaints
+ the text fill, so the label paints anyway and a centred ring would
+ land on top of it. Both keep the older spinner-beside-label look. */
+@media (prefers-reduced-motion: no-preference) and (forced-colors: none) {
+ .rst-btn[aria-busy="true"]:not([data-idle-label]):has(> .rst-spin), [rst-btn][aria-busy="true"]:not([data-idle-label]):has(> [rst-spin]) {
+ -webkit-text-fill-color: transparent;
+ }
+ .rst-btn[aria-busy="true"]:not([data-idle-label]):has(> .rst-spin) > :not(.rst-spin), [rst-btn][aria-busy="true"]:not([data-idle-label]):has(> [rst-spin]) > :not([rst-spin]) {
+ opacity: 0;
+ }
+ .rst-btn[aria-busy="true"]:not([data-idle-label]) > .rst-spin, [rst-btn][aria-busy="true"]:not([data-idle-label]) > [rst-spin] {
+ inset: 0;
+ margin: auto;
+ position: absolute;
+ }
+ /* The spinner's containing block. Zero specificity, so an app's own
+ position: fixed or sticky on a floating Save still wins — either
+ of those contains the spinner just as well, and losing it would
+ drop the button back into the page flow mid-click. */
+ :where(.rst-btn[aria-busy="true"]:not([data-idle-label])), :where([rst-btn][aria-busy="true"]:not([data-idle-label])) {
+ position: relative;
+ }
+}
diff --git a/internal/slides/static/busy.js b/internal/slides/static/busy.js
new file mode 100644
index 0000000..550a843
--- /dev/null
+++ b/internal/slides/static/busy.js
@@ -0,0 +1,252 @@
+/* busy.js — the busy rule. First-party, dependency-free, and on by
+ default: link it and every submit button in every form that goes
+ somewhere is covered, with no attribute to remember, because a button
+ that changes something should say so while it works, and a form that
+ double-submits on a second click is a bug on every screen. It is
+ app-owned from the moment it is vendored; leave it unlinked to opt the
+ whole app out.
+
+ What a submitted button does: its label gives way to a spinner (the
+ label stays, beside the spinner, under reduced motion or forced
+ colours — tokens.css), it goes aria-busy and then disabled, and its
+ form refuses a second submit. The spinner shows for at least
+ HOLD_MS: a response faster than that is held back until then, so a
+ quick save reads as a deliberate beat rather than a flicker. Going
+ Back to the page hands every busy form back, and a submit still being
+ held when the page is left is dropped rather than sent later.
+
+ Vocabulary:
+ data-busy="false" on a <form> or on one submit button: opt OUT
+ of the busy rule. The rule is the default, so
+ any other value — data-busy on its own
+ included — changes nothing
+ data-busy-label="…" on the form or on the button: replacement
+ button text while it works
+
+ A script that handles a submit itself (a fetch, say) cancels it in a
+ listener on the form or the document, as it would anyway: those run
+ before this file's window listener, which then sees the cancelled
+ submit and leaves it — and its feedback — to that script. Two edges
+ of the hold, on purpose: a handler that stops the submit event's
+ propagation keeps it from the window, so that submit goes at once,
+ unheld; and a form removed from the page during the hold (a polled
+ fragment replacing it) takes its held submit with it. */
+(function () {
+ "use strict";
+
+ // How long the spinner shows, at least. Long enough to read as an
+ // answer to the click, short enough never to feel like waiting.
+ var HOLD_MS = 650;
+
+ // ── The busy rule ────────────────────────────────────────────────
+ //
+ // A button that CHANGES something says so while it works; a button
+ // that only reveals something — a disclosure, a dropdown, a tab —
+ // does not. So every submit button in every form is covered by
+ // default, with no attribute to remember: the submitted button gets
+ // aria-busy, a spinner and — once the submission is under way —
+ // disabled, and its form gets aria-busy and a guard against a second
+ // submit. The data-busy vocabulary above opts out of either half.
+ //
+ // The guard is the substance, the spinner is the manners, and neither
+ // is a promise: with scripts off the form submits exactly as it
+ // always did, twice if the visitor clicks twice. Idempotency stays
+ // the server's job. One delegated capture-phase listener, for the
+ // reasons light dismiss gives below: fragments, and never double-bind.
+ //
+ // Two traps this shape exists to avoid:
+ //
+ // - A form the browser REFUSED must not sit there looking busy.
+ // Constraint validation fails before the submit event is fired at
+ // all, so an invalid form never reaches this code; a cancelled
+ // one is handed back in the tick below. (formnovalidate skips
+ // validation and really does submit, which should look like it.)
+ // - Nothing that could change the payload happens while the payload
+ // is being read. The entry list is built before submit fires, but
+ // engines have differed, so aria-busy, the spinner and a
+ // <button>'s TEXT (never submitted — a button submits its value
+ // attribute) go on synchronously, while disabled and an
+ // <input type="submit">'s value (which IS what it submits) wait.
+ //
+ // Every busy element in the DOCUMENT, then the ownership test: a
+ // form="id" submit button is owned by a form it does not live in.
+ function busyOff(form) {
+ form.removeAttribute("aria-busy");
+ document.querySelectorAll('[aria-busy="true"]').forEach(function (b) {
+ if (b.form !== form && !form.contains(b)) return;
+ b.disabled = false;
+ b.removeAttribute("aria-busy");
+ var spin = b.querySelector("[rst-spin]");
+ if (spin) spin.remove();
+ var idle = b.getAttribute("data-idle-label");
+ if (idle === null) return;
+ if (b.tagName === "INPUT") { b.value = idle; } else { b.textContent = idle; }
+ b.removeAttribute("data-idle-label");
+ });
+ }
+
+ function busySubmit(e) {
+ var form = e.target;
+ if (!form || form.tagName !== "FORM") return;
+ if (form.getAttribute("data-busy") === "false") return;
+ // The button the browser submitted with: the one clicked, or — for
+ // Enter in a field — the default one it implicitly clicked. Only
+ // that one goes busy; every other submit button in the form keeps
+ // its name and its value. An engine with no SubmitEvent.submitter
+ // leaves the buttons alone and keeps the guard, which is the half
+ // that matters.
+ var btn = e.submitter;
+ // A form that opens its result elsewhere, or a submit that closes a
+ // dialog and stays put, has nothing to be busy about and nothing to
+ // clear. The submitter's formmethod beats the form's.
+ //
+ // getAttribute, never the IDL properties. A form is
+ // [LegacyOverrideBuiltIns]: a control named "target" or "method" (a
+ // target date, a method column — ordinary field names) shadows the IDL
+ // attribute with the input itself, so the property form quietly
+ // switches the rule off and hands back the double submit. The busy flag
+ // below is the form's own aria-busy for the same reason: an expando is
+ // shadowable, and under strict mode assigning to a shadowed one throws.
+ var to = (btn && btn.getAttribute("formtarget")) || form.getAttribute("target");
+ if (to && to !== "_self") return;
+ if (/^dialog$/i.test(btn && btn.getAttribute("formmethod") ||
+ form.getAttribute("method"))) return;
+ if (form === releasing) return; // the hold sending the submit it held
+ if (form.getAttribute("aria-busy") === "true") { e.preventDefault(); return; }
+ form.setAttribute("aria-busy", "true");
+ if (!btn || btn.getAttribute("data-busy") === "false") btn = null;
+ var label = btn && (btn.getAttribute("data-busy-label") ||
+ form.getAttribute("data-busy-label"));
+ if (btn) {
+ btn.setAttribute("aria-busy", "true");
+ if (btn.tagName === "BUTTON") {
+ if (label) {
+ btn.setAttribute("data-idle-label", btn.textContent);
+ btn.textContent = label;
+ }
+ // A child element, not a pseudo-element: the shim has to be
+ // able to take it away again. An <input type="submit"> has no
+ // children, which is the one shape this cannot draw a spinner
+ // on. tokens.css stops it rotating under reduced motion.
+ var spin = document.createElement("span");
+ spin.setAttribute("rst-spin", "");
+ spin.setAttribute("aria-hidden", "true");
+ btn.insertBefore(spin, btn.firstChild);
+ }
+ }
+ setTimeout(function () {
+ // Someone downstream cancelled the submit — an app handler doing
+ // the work itself, most likely. Nothing is on its way anywhere,
+ // so hand the form back, guard included, and let whoever took the
+ // job own the feedback too.
+ if (e.defaultPrevented && !held.has(e)) { busyOff(form); return; }
+ if (!btn) return;
+ if (label && btn.tagName === "INPUT") {
+ btn.setAttribute("data-idle-label", btn.value);
+ btn.value = label;
+ }
+ btn.disabled = true;
+ }, 0);
+ }
+
+ document.addEventListener("submit", busySubmit, true);
+
+ // ── The hold ─────────────────────────────────────────────────────
+ //
+ // A spinner can only be promised a minimum time by holding the
+ // submit itself: once a fast response arrives the browser navigates
+ // and the spinner is gone. So the submit the busy rule just armed is
+ // cancelled here and sent again, by requestSubmit, once HOLD_MS have
+ // passed since the click.
+ //
+ // This listener is on the window, in the bubble phase — the last place
+ // a submit event reaches — so every handler on the form and the
+ // document has had its say first. One that cancelled the submit owns
+ // it (e.defaultPrevented, and the busy tick above hands the form
+ // 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) 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
+ window.addEventListener("submit", function (e) {
+ var form = e.target;
+ 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);
+ pending.set(form, setTimeout(function () { release(form, btn); }, HOLD_MS));
+ });
+
+ // Send the held submit. The submitter goes with it, so its name and
+ // value are in the payload exactly as a click would have sent them —
+ // which means, for the moment the entry list is built, it has to be
+ // enabled and an <input type="submit"> has to carry its own value
+ // again, not the busy label. The form is re-checked first: fields
+ // stay editable during the hold, and a form that is no longer valid
+ // must be handed back with its errors showing, not left spinning.
+ function release(form, btn) {
+ pending.delete(form);
+ if (!form.isConnected) return;
+ var skip = form.hasAttribute("novalidate") ||
+ (btn && btn.hasAttribute("formnovalidate"));
+ if (!skip && !form.checkValidity()) {
+ busyOff(form);
+ form.reportValidity();
+ return;
+ }
+ // The submitter may have left the form during the hold (moved,
+ // removed, its form= changed): requestSubmit would throw and leave
+ // the form guarded for good. Hand it back instead.
+ if (btn && (!btn.isConnected || btn.form !== form)) {
+ busyOff(form);
+ return;
+ }
+ var busyValue = null;
+ // Restored, not assumed: a button that opted out of the busy state
+ // (data-busy="false") was never disabled, and must not end up so.
+ var wasDisabled = btn ? btn.disabled : false;
+ if (btn) {
+ btn.disabled = false;
+ var idle = btn.getAttribute("data-idle-label");
+ if (btn.tagName === "INPUT" && idle !== null) {
+ busyValue = btn.value;
+ btn.value = idle;
+ }
+ }
+ releasing = form;
+ try {
+ form.requestSubmit(btn || undefined);
+ } finally {
+ releasing = null;
+ if (btn) {
+ btn.disabled = wasDisabled;
+ if (busyValue !== null) btn.value = busyValue;
+ }
+ }
+ }
+
+ // Leaving the page — a link, Back — while a submit is held drops it:
+ // the visitor has gone somewhere else, and a timer surviving into the
+ // back/forward cache would otherwise send it on their return.
+ window.addEventListener("pagehide", function () {
+ pending.forEach(function (timer) { clearTimeout(timer); });
+ pending.clear();
+ });
+
+ // The back/forward cache restores a page's DOM exactly as it was left
+ // — busy buttons still disabled, still wearing the busy label and the
+ // spinner — so a visitor who navigates back finds a dead form. Hand
+ // every busy form back.
+ window.addEventListener("pageshow", function (e) {
+ if (e.persisted) document.querySelectorAll("form[aria-busy]").forEach(busyOff);
+ });
+})();
diff --git a/internal/slides/static/rastrillo.js b/internal/slides/static/rastrillo.js
index dc4b344..2c9cd5a 100644
--- a/internal/slides/static/rastrillo.js
+++ b/internal/slides/static/rastrillo.js
@@ -5,15 +5,15 @@
app-owned from the moment it is scaffolded — edit it like any other
static file.
- Two sections are on by default instead, because neither has a
- per-instance decision to make. Light dismiss for the menu idioms keys
- off the rst-dropdown / rst-row-menu attributes: a menu that closes on
- an outside click on one screen and not the next is worse than either
- rule applied everywhere. The busy rule keys off nothing at all — every
- submit button in every form that goes somewhere is covered, because
- a button that changes something should say so while it works, and a
- form that double-submits on a second click is a bug on every screen.
- Delete either section to opt the whole app out of it.
+ One section is on by default instead, because it has no per-instance
+ decision to make: light dismiss for the menu idioms, keyed off the
+ rst-dropdown / rst-row-menu attributes — a menu that closes on an
+ outside click on one screen and not the next is worse than either
+ rule applied everywhere. Delete it to opt the whole app out.
+
+ The busy rule, which used to live here too, is busy.js: link it and
+ every submit button in every form is covered; leave it out and none
+ is.
Vocabulary:
data-poll="URL" fetch URL for an HTML fragment, replace this
@@ -27,12 +27,6 @@
element to timer polling for good (once,
no flapping); a browser without EventSource
never leaves the timer path
- data-busy="false" on a <form> or on one submit button: opt OUT
- of the busy rule below. The rule is the
- default, so any other value — data-busy on
- its own included — changes nothing
- data-busy-label="…" on the form or on the button: replacement
- button text while it works
select.js is a sibling file following exactly these rules, kept
separate so this one stays small enough to read in one sitting. It
@@ -135,118 +129,6 @@
schedule();
}
- // ── The busy rule ────────────────────────────────────────────────
- //
- // A button that CHANGES something says so while it works; a button
- // that only reveals something — a disclosure, a dropdown, a tab —
- // does not. So every submit button in every form is covered by
- // default, with no attribute to remember: the submitted button gets
- // aria-busy, a spinner and — once the submission is under way —
- // disabled, and its form gets aria-busy and a guard against a second
- // submit. The data-busy vocabulary above opts out of either half.
- //
- // The guard is the substance, the spinner is the manners, and neither
- // is a promise: with scripts off the form submits exactly as it
- // always did, twice if the visitor clicks twice. Idempotency stays
- // the server's job. One delegated capture-phase listener, for the
- // reasons light dismiss gives below: fragments, and never double-bind.
- //
- // Two traps this shape exists to avoid:
- //
- // - A form the browser REFUSED must not sit there looking busy.
- // Constraint validation fails before the submit event is fired at
- // all, so an invalid form never reaches this code; a cancelled
- // one is handed back in the tick below. (formnovalidate skips
- // validation and really does submit, which should look like it.)
- // - Nothing that could change the payload happens while the payload
- // is being read. The entry list is built before submit fires, but
- // engines have differed, so aria-busy, the spinner and a
- // <button>'s TEXT (never submitted — a button submits its value
- // attribute) go on synchronously, while disabled and an
- // <input type="submit">'s value (which IS what it submits) wait.
- //
- // Every busy element in the DOCUMENT, then the ownership test: a
- // form="id" submit button is owned by a form it does not live in.
- function busyOff(form) {
- form.removeAttribute("aria-busy");
- document.querySelectorAll('[aria-busy="true"]').forEach(function (b) {
- if (b.form !== form && !form.contains(b)) return;
- b.disabled = false;
- b.removeAttribute("aria-busy");
- var spin = b.querySelector("[rst-spin]");
- if (spin) spin.remove();
- var idle = b.getAttribute("data-idle-label");
- if (idle === null) return;
- if (b.tagName === "INPUT") { b.value = idle; } else { b.textContent = idle; }
- b.removeAttribute("data-idle-label");
- });
- }
-
- function busySubmit(e) {
- var form = e.target;
- if (!form || form.tagName !== "FORM") return;
- if (form.getAttribute("data-busy") === "false") return;
- // The button the browser submitted with: the one clicked, or — for
- // Enter in a field — the default one it implicitly clicked. Only
- // that one goes busy; every other submit button in the form keeps
- // its name and its value. An engine with no SubmitEvent.submitter
- // leaves the buttons alone and keeps the guard, which is the half
- // that matters.
- var btn = e.submitter;
- // A form that opens its result elsewhere, or a submit that closes a
- // dialog and stays put, has nothing to be busy about and nothing to
- // clear. The submitter's formmethod beats the form's.
- //
- // getAttribute, never the IDL properties. A form is
- // [LegacyOverrideBuiltIns]: a control named "target" or "method" (a
- // target date, a method column — ordinary field names) shadows the IDL
- // attribute with the input itself, so the property form quietly
- // switches the rule off and hands back the double submit. The busy flag
- // below is the form's own aria-busy for the same reason: an expando is
- // shadowable, and under strict mode assigning to a shadowed one throws.
- var to = form.getAttribute("target");
- if (to && to !== "_self") return;
- if (/^dialog$/i.test(btn && btn.getAttribute("formmethod") ||
- form.getAttribute("method"))) return;
- if (form.getAttribute("aria-busy") === "true") { e.preventDefault(); return; }
- form.setAttribute("aria-busy", "true");
- if (!btn || btn.getAttribute("data-busy") === "false") btn = null;
- var label = btn && (btn.getAttribute("data-busy-label") ||
- form.getAttribute("data-busy-label"));
- if (btn) {
- btn.setAttribute("aria-busy", "true");
- if (btn.tagName === "BUTTON") {
- if (label) {
- btn.setAttribute("data-idle-label", btn.textContent);
- btn.textContent = label;
- }
- // A child element, not a pseudo-element: the shim has to be
- // able to take it away again. An <input type="submit"> has no
- // children, which is the one shape this cannot draw a spinner
- // on. tokens.css stops it rotating under reduced motion.
- var spin = document.createElement("span");
- spin.setAttribute("rst-spin", "");
- spin.setAttribute("aria-hidden", "true");
- btn.insertBefore(spin, btn.firstChild);
- }
- }
- setTimeout(function () {
- // Someone downstream cancelled the submit — an app handler doing
- // the work itself, most likely. Nothing is on its way anywhere,
- // so hand the form back, guard included, and let whoever took the
- // job own the feedback too.
- if (e.defaultPrevented) { busyOff(form); return; }
- if (!btn) return;
- if (label && btn.tagName === "INPUT") {
- btn.setAttribute("data-idle-label", btn.value);
- btn.value = label;
- }
- btn.disabled = true;
- }, 0);
- }
-
- document.addEventListener("submit", busySubmit, true);
-
// Light dismiss — the one behaviour the native disclosure genuinely
// cannot do, which is the shim's whole admission rule. A <details>
// menu closes on a second click of its own summary and on nothing
@@ -321,14 +203,6 @@
document.querySelectorAll("[data-poll]").forEach(poll);
}
- // The back/forward cache restores a page's DOM exactly as it was left
- // — busy buttons still disabled, still wearing the busy label and the
- // spinner — so a visitor who navigates back finds a dead form. Hand
- // every busy form back.
- window.addEventListener("pageshow", function (e) {
- if (e.persisted) document.querySelectorAll("form[aria-busy]").forEach(busyOff);
- });
-
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", scan);
} else {
diff --git a/internal/slides/templates/layout.html b/internal/slides/templates/layout.html
index 384dd07..8990554 100644
--- a/internal/slides/templates/layout.html
+++ b/internal/slides/templates/layout.html
@@ -38,12 +38,14 @@
<link rel="apple-touch-icon" sizes="180x180" href="{{asset "static/brand/apple-touch-icon.png"}}">
<link rel="stylesheet" href="/static/estilo/fonts/v1/fonts.css">
<link rel="stylesheet" href="{{asset "static/tokens.css"}}">
+<link rel="stylesheet" href="{{asset "static/busy.css"}}">
<link rel="stylesheet" href="{{asset "static/theme.css"}}">
<link rel="stylesheet" href="{{asset "static/brand/brand.css"}}">
<link rel="stylesheet" href="{{asset "static/estilo/base.css"}}">
<link rel="stylesheet" href="{{asset "static/estilo/menu.css"}}">
<link rel="stylesheet" href="{{asset "static/estilo/chrome.css"}}">
<script defer src="{{asset "static/rastrillo.js"}}"></script>
+<script defer src="{{asset "static/busy.js"}}"></script>
<script defer src="{{asset "static/estilo/chrome.js"}}"></script>
{{iconAssets}}
<script defer src="{{asset "static/select.js"}}"></script>
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}}">
diff --git a/internal/slidestest/vendored_test.go b/internal/slidestest/vendored_test.go
index a591eea..abacdb3 100644
--- a/internal/slidestest/vendored_test.go
+++ b/internal/slidestest/vendored_test.go
@@ -1,6 +1,7 @@
package slidestest
import (
+ "amadan.net/oficina/estilo.git/busy"
"bytes"
"fmt"
"os"
@@ -24,6 +25,9 @@ const vendoredTheme = "day"
// record that the edit was on purpose, so the next reader (and the next
// upgrade) can tell it from drift.
var vendoredIsMine = map[string]bool{
+ // rastrillo's shim as estilo carries it: without the old busy rule,
+ // which would fight busy.js. Pinned to estilo by TestBusyFilesAreEstilos.
+ "rastrillo.js": true,
// Calma with Slides' accent, from estilo (estilo AGENTS.md, decisions
// table, row 8) rather than rastrillo's day. Pinned to estilo instead,
// by TestThemeIsEstilosForSlides below.
@@ -32,7 +36,7 @@ var vendoredIsMine = map[string]bool{
// TestThemeIsEstilosForSlides pins static/theme.css to the estilo this app
// is built against. After an estilo upgrade, re-write it with
-// `go run ./scripts/writetheme` — never by hand.
+// `go run ./scripts/writeestilo` — never by hand.
func TestThemeIsEstilosForSlides(t *testing.T) {
want, ok := theme.ForApp("slides")
if !ok {
@@ -43,7 +47,7 @@ func TestThemeIsEstilosForSlides(t *testing.T) {
t.Fatal(err)
}
if !bytes.Equal(got, want) {
- t.Error("static/theme.css differs from estilo's theme for slides; run go run ./scripts/writetheme")
+ t.Error("static/theme.css differs from estilo's theme for slides; run go run ./scripts/writeestilo")
}
}
@@ -128,3 +132,24 @@ func TestEveryRastrilloTokenSlidesUsesExists(t *testing.T) {
t.Error(fmt.Sprintf("Slides uses --%s, but tokens.css does not declare it", token))
}
}
+
+// rastrillo's busy rule (Paul, 2026-09-29): a submit button's label gives
+// way to a spinner for at least 650ms, the button disables, and Back
+// resets it. static/ carries it as estilo does — the shim without the old
+// rule, busy.js and busy.css — pinned here to the estilo this app builds
+// against. After an estilo upgrade: go run ./scripts/writeestilo.
+func TestBusyFilesAreEstilos(t *testing.T) {
+ for name, want := range map[string][]byte{
+ "rastrillo.js": busy.ShimJS(),
+ "busy.js": busy.JS(),
+ "busy.css": busy.CSS(),
+ } {
+ got, err := os.ReadFile(filepath.Join("..", "slides", "static", name))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !bytes.Equal(got, want) {
+ t.Errorf("static/%s differs from estilo's; run go run ./scripts/writeestilo", name)
+ }
+ }
+}
diff --git a/scripts/writeestilo/main.go b/scripts/writeestilo/main.go
new file mode 100644
index 0000000..2a80a67
--- /dev/null
+++ b/scripts/writeestilo/main.go
@@ -0,0 +1,30 @@
+// Command writeestilo re-writes the files internal/slides/static takes from
+// the estilo this module pins: theme.css (Calma, with Slides's accent),
+// and rastrillo's busy rule as estilo carries it — rastrillo.js without
+// the old rule, busy.js and busy.css. Run it from the repository root
+// after upgrading estilo; the pin tests in internal/slidestest fail until you do.
+package main
+
+import (
+ "os"
+
+ "amadan.net/oficina/estilo.git/busy"
+ "amadan.net/oficina/estilo.git/theme"
+)
+
+func main() {
+ css, ok := theme.ForApp("slides")
+ if !ok {
+ panic("estilo's theme ring has no slides")
+ }
+ for name, body := range map[string][]byte{
+ "theme.css": css,
+ "rastrillo.js": busy.ShimJS(),
+ "busy.js": busy.JS(),
+ "busy.css": busy.CSS(),
+ } {
+ if err := os.WriteFile("internal/slides/static/"+name, body, 0o644); err != nil {
+ panic(err)
+ }
+ }
+}
diff --git a/scripts/writetheme/main.go b/scripts/writetheme/main.go
deleted file mode 100644
index 489e108..0000000
--- a/scripts/writetheme/main.go
+++ /dev/null
@@ -1,21 +0,0 @@
-// Command writetheme re-writes internal/slides/static/theme.css from the
-// estilo this module pins: Calma, with Slides' accent. Run it from the
-// repository root after upgrading estilo; TestThemeIsEstilosForSlides
-// fails until you do.
-package main
-
-import (
- "os"
-
- "amadan.net/oficina/estilo.git/theme"
-)
-
-func main() {
- css, ok := theme.ForApp("slides")
- if !ok {
- panic("estilo's theme ring has no slides")
- }
- if err := os.WriteFile("internal/slides/static/theme.css", css, 0o644); err != nil {
- panic(err)
- }
-}