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>
11 files changed,
+381
−161
go.mod+1 −1go.sum+6 −0internal/slides/credits/assets.json+2 −1internal/slides/static/busy.css+47 −0internal/slides/static/busy.js+252 −0internal/slides/static/rastrillo.js+9 −135internal/slides/templates/layout.html+2 −0internal/slides/templates/present_controls.html+5 −1internal/slidestest/vendored_test.go+27 −2scripts/writeestilo/main.go+30 −0scripts/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) |
| - } |
| -} |