oficina / slides Public

Slides moves to rastrillo fb806a2: links confirm before they are spent

Opening a sign-in link shows Slides' own door with one Sign in button;
the POST spends it, so mail scanners and other browsers no longer burn
links. A refused identity gets the door through auth's Refused hook,
replacing the interim wrapper. Per-deck slide styles keep inline styles
allowed, every other directive at rastrillo's default, and the browser
rig now runs under the production CSP. Reviewed by GPT-6-Astra via Codex (#21).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Squashed from chore/rastrillo-confirm into main.
claude agent, operated by paul@keymail.dev 16b762d016739d9f85f7519baa19de2510589028 parent cda997f
24 files changed, +2729 −494
  • AGENTS.md +7 −2
  • cmd/slides/main.go +3 −11
  • go.mod +3 −1
  • go.sum +4 −2
  • internal/slides/app.go +21 −9
  • internal/slides/auth.go +102 −105
  • internal/slides/credits/assets.json +3 −2
  • internal/slides/credits/notices.json +12 −1
  • internal/slides/home_test.go +26 −15
  • internal/slides/locales/en.toml +4 −1
  • internal/slides/serving.go +66 −0
  • internal/slides/static/datetime.js +17 −6
  • internal/slides/static/select.js +1085 −256
  • internal/slides/static/shell.css +51 −0
  • internal/slides/static/shell.js +143 −0
  • internal/slides/static/tokens.css +653 −65
  • internal/slides/templates/signin.html +21 −4
  • internal/slidestest/browser_csp_test.go +88 −0
  • internal/slidestest/browser_test.go +51 −4
  • internal/slidestest/harness_test.go +51 −8
  • internal/slidestest/keymail_signin_test.go +5 −2
  • internal/slidestest/serving_test.go +130 −0
  • internal/slidestest/signin_confirm_test.go +178 −0
  • internal/slidestest/vendored_test.go +5 −0

CI passed — run details

CI log
=== gate ===
=== rust: fmt
=== rust: clippy
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.16s
=== wasm: the artifact the host embeds
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.03s
    Finished `release` profile [optimized] target(s) in 0.10s
core/target/wasm32-unknown-unknown/release/slides_core.wasm (1228229 bytes)
=== the embedded wasm must match a fresh build
=== rust: test
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.05s
     Running unittests src/lib.rs (core/target/debug/deps/slides_core-929bfceb2edd4a94)

running 30 tests
test layout::tests::aside_region_rect ... ok
test layout::tests::heading_region_rect ... ok
test layout::tests::body_region_rect_narrows_for_an_aside ... ok
test layout::tests::media_region_rect ... ok
test layout::tests::default_region_by_kind ... ok
test markdown::tests::empty_input_is_no_paragraphs_and_lines_keep_blanks ... ok
test markdown::tests::inline_marks ... ok
test markdown::tests::marks_nest_and_links_carry_marks ... ok
test markdown::tests::paragraphs_split_on_blank_lines_and_list_markers ... ok
test check::tests::the_empty_document_passes ... ok
test schema::tests::an_empty_map_reads_as_a_set_mark ... ok
test schema::tests::an_href_map_without_a_string_url_reads_as_no_link ... ok
test schema::tests::href_is_written_as_a_one_key_map_and_reads_back ... ok
test schema::tests::href_written_as_a_plain_string_still_reads_back ... ok
test layout::tests::to_flow_is_a_no_op_when_already_in_order ... ok
test layout::tests::to_canvas_narrows_body_when_an_aside_is_present ... 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 check::tests::an_element_inside_a_paragraph_is_reported ... ok
test check::tests::what_the_ops_write_passes ... ok
test check::tests::a_non_paragraph_child_is_reported ... ok
test check::tests::y_prosemirror_shapes_pass ... ok
test check::tests::bare_text_at_fragment_level_is_reported ... ok
test check::tests::an_unknown_formatting_attribute_is_reported ... ok
test view::tests::malformed_box_is_null_and_phone_keeps_only_typed_fields ... ok
test view::tests::assets_are_typed_or_skipped ... ok
test view::tests::malformed_background_is_the_default ... ok
test view::tests::malformed_fonts_are_null ... 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 remove_block_drops_it_from_columns_children ... ok
test layout_switch_assigns_boxes ... ok
test columns_children_must_name_blocks_on_the_slide ... ok
test update_roundtrip ... ok
test duplicate_slide_remaps_columns_children ... 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.12s
=== wasm: the pdf artifact the host embeds
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.03s
    Finished `release` profile [optimized] target(s) in 0.11s
core/pdf/target/wasm32-unknown-unknown/release/slides_pdf.wasm (5301002 bytes)
=== the embedded pdf wasm must match a fresh build
=== rust: test (pdf)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.06s
     Running unittests src/lib.rs (core/pdf/target/debug/deps/slides_pdf-891fafaa44f9fd64)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running tests/no_host_imports.rs (core/pdf/target/debug/deps/no_host_imports-17ccbb1d62120093)

running 1 test
test no_host_imports ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s

     Running tests/render.rs (core/pdf/target/debug/deps/render-2e1d7e8b19d78595)

running 6 tests
test a_null_zero_length_pair_is_no_frames_not_a_dereference ... ok
test 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 522ms

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 (16.191168ms)
✔ a record missing its numbers still makes an op the core accepts (1.658509ms)
✔ unusedAssets is what no slide refers to, and remove_asset drops one (4.140217ms)
✔ the Unused images list reconciles by id and names each Remove button (1.188677ms)
✔ an upload refusal is said in words the person can act on (0.24251ms)
✔ clampBox keeps the box inside the frame and no smaller than the minimum (1.500726ms)
✔ fixBox writes four decimals (0.240136ms)
✔ guides are the thirds plus every other block edge (0.273187ms)
✔ snapMove pulls the nearest edge onto a guide within one per cent, and leaves a far one (0.454052ms)
✔ snapResize moves only the edges the handle drags (0.289517ms)
✔ moveBox and resizeBox by handle (0.289708ms)
✔ nudge: arrows move by half a per cent, Shift by two, Alt resizes (0.265883ms)
✔ rectBox reads a block rectangle against its frame (0.218776ms)
✔ typing into a block is a text change on its slide only (13.234981ms)
✔ a block's props changing is structure on its slide (1.500145ms)
✔ adding, moving or removing a block is structure on its slide (2.670489ms)
✔ the notes are text on their slide (1.102216ms)
✔ a slide added, removed or moved is an order change (1.602665ms)
✔ one transaction touching several things reports each once (2.104076ms)
✔ what the ops write passes, and so does an empty document (9.509935ms)
✔ the shapes y-prosemirror and older documents write pass (1.334838ms)
✔ an unknown formatting attribute is reported with the core's message (1.505445ms)
✔ an element inside a paragraph, a non-paragraph child and bare text are reported (2.334032ms)
✔ the unknownmark poison shape is what the check refuses (1.275147ms)
✔ y-prosemirror 1.3.7 deletes the words under an unknown mark; sanitising first keeps them (3.783645ms)
✔ the vocabulary is the five marks and nothing else (0.206774ms)
✔ a reload reuses the id this tab stored for the deck (1.315472ms)
✔ two tabs, and two decks in one tab, get different ids (0.249583ms)
✔ a stored value that is not an id, or storage that throws, gives a fresh id (0.399051ms)
✔ a duplicated tab gives up the copied id; the original keeps it (9.161097ms)
✔ a reload finds no rival and keeps its id (2.670298ms)
✔ presence switches id: beats carry the new one and the old one is somebody else (1.853712ms)
✔ an empty body is no frames (1.441526ms)
✔ frames come back in order with their exact bytes (0.379394ms)
✔ a length prefix is big-endian (0.207405ms)
✔ a truncated frame throws rather than returning a short update (1.839585ms)
✔ deck controls are set_meta (13.858629ms)
✔ slide controls are set_slide, and background keeps its colour (2.936893ms)
✔ move up and down are move_slide with the right after (1.687653ms)
✔ block props: value kinds, region, a shape label (2.068049ms)
✔ box fields are set_block on the desktop and set_phone in the phone view (8.991894ms)
✔ move block and the layer buttons (2.204783ms)
✔ add block: region after the selection on flow, a default box on canvas, a heading level that fits (15.669821ms)
✔ a block is named by its type and its first words (1.476861ms)
✔ blockChange: a control element to its op (1.275879ms)
✔ the Blocks list reconciles by id and keeps its buttons (4.140307ms)
✔ open resolves to the button that closed it and the trimmed field (1.813887ms)
✔ a second open while the dialog is up joins the first (0.221611ms)
✔ a missing dialog mounts as nothing (5.437886ms)
✔ a tab advances its own cursor only when its POST is the very next row (2.259034ms)
✔ the cursor never moves backwards when a response is overtaken by this tab's own write (0.510217ms)
✔ a later response still carries the cursor forward (0.273247ms)
✔ a burst of notifications collapses into one fetch at a time (0.273909ms)
✔ an event naming a seq the tab already has fetches nothing (0.202667ms)
✔ a stopped subscriber drops a response that arrives late (0.318571ms)
✔ stop closes the stream, and start opens exactly one (0.20477ms)
✔ a hidden tab closes its stream on a shortened deadline, and activity reopens it (1.034821ms)
✔ switching away and back inside the grace never drops the stream, so nothing has to be caught up (0.271113ms)
✔ a visible tab that is only being READ closes when idle and comes back on any activity (0.348557ms)
✔ activity while streaming defers the idle close rather than reopening anything (0.190013ms)
✔ a stream failure is never reported as a save failure, and heals on activity (0.71079ms)
✔ an empty response advances the cursor without applying anything (0.172981ms)
✔ a failed fetch leaves the cursor alone so the next notification retries it (0.620041ms)
✔ a decode failure retries once through the raw log, then gives up (1.914084ms)
✔ updatesURL spells the query string exactly, in particular raw as the literal raw=1 (0.148896ms)
✔ empty_doc_views_as_defaults (9.017071ms)
✔ set_meta (3.912293ms)
✔ slides: add, move, remove, set_slide, set_notes (9.213255ms)
✔ duplicate_slide (4.944439ms)
✔ blocks: add, move, remove, set_block (6.16237ms)
✔ set_phone (1.614417ms)
✔ table cells follow rows and cols (1.919203ms)
✔ canvas blocks: box defaults from the region, z from the index (2.020331ms)
✔ assets: register and remove (1.112926ms)
✔ markdown: every mark, code blocks are plain lines (5.316529ms)
✔ batch_is_atomic (1.975417ms)
✔ a batch applies in one transaction with the given origin (0.426863ms)
✔ columns_children_validated (1.143163ms)
✔ layout_switch_round_trip (1.713892ms)
✔ view_matches_snapshot_fixtures (14.458431ms)
✔ columns children have one parent and no cycles (codex 7) (1.016367ms)
✔ canvas-to-flow keeps a concurrent text edit (codex 4) (3.214428ms)
✔ move_block keeps a concurrent text edit, and later inserts land where after says (codex 4) (2.285502ms)
✔ move_slide keeps a concurrent text edit, and later inserts land where after says (codex 4 at slide level) (2.376792ms)
✔ format: {name} and {n} interpolate; an unknown key is left as text (0.667059ms)
✔ a beat on connect, then one every twenty seconds (2.220132ms)
✔ a move posts once, 200ms after the last of a burst, and re-arms the beat (0.327317ms)
✔ a hidden tab stops beating; coming back posts at once (0.335152ms)
✔ applySet leaves this tab out and answers what changed (0.584205ms)
✔ a set with rubbish in it is read for what it does carry (0.266475ms)
✔ initials, and a client id per tab (9.624537ms)
✔ a resync that fails is retried with backoff until it succeeds, without ever running twice at once (2.075494ms)
✔ the browser's online event retries immediately, not just on the next backoff tick (0.847003ms)
✔ reportDivergence before any failure runs the first attempt without a caller ever calling online (1.475008ms)
✔ a permanent 4xx drops its batch instead of retrying it, and does not poison the next edit (84.63236ms)
✔ a network failure or a 5xx is retried, not dropped (62.096882ms)
✔ a 500 is retried the same way a network failure is, never dropped (59.548081ms)
✔ the core-written structure reads as ProseMirror marks (9.685452ms)
✔ a y-prosemirror round trip preserves bold and the link (3.050452ms)
✔ y-prosemirror writes href as the one-key map and marks as {} (0.858274ms)
✔ a plain-string href, written before the map shape, still reads (0.563256ms)
✔ textFragment writes the same shape as writeText (0.612226ms)
✔ safeHref is the server allowlist, and a disallowed href never draws an anchor (0.491282ms)
✔ parseHash splits the slide and the block, and survives a stray % (1.873539ms)
✔ destroy takes the listeners off, and a second mount leaves one of each (1.65374ms)
✔ formatHash is the inverse (1.559515ms)
✔ textBlocks finds the text-bearing blocks and skips the rest (1.89605ms)
✔ mountTarget is the element whose children are the paragraphs (0.263089ms)
✔ paragraphTag keeps the renderer's own markup per type (0.17215ms)
✔ blockFragment finds a block's text by slide and block id (6.552045ms)
✔ notesFragment finds the slide's notes (0.749321ms)
✔ blockIDs and slideIDs are the orders on the stage and the strip (0.853745ms)
✔ cellFragments are a table's cells, row-major (1.138704ms)
✔ a load that fails leaves the stage on the slide it was showing and says so (1.306044ms)
✔ slideAfter is the slide to show once one is gone (0.187508ms)
✔ the queue words map to savestatus states, failed staying failed (2.029048ms)
✔ a dropped batch outranks the queue, for the rest of the session (0.549941ms)
✔ the editor status: the save line and the load error are two lines (0.574467ms)
✔ mountSaveStatus loads estilo's module and mounts it on the line (1.593518ms)
✔ a save-status module that will not load says so and refuses to mount (0.621274ms)
✔ dropTarget: before puts it ahead of the item, after behind it, null means first (0.967627ms)
✔ a drop that leaves the order as it is is no move (0.222513ms)
✔ dropOp is the move_slide (5.100079ms)
✔ labelFor is the slide's first heading, else the numbered fallback (4.956251ms)
✔ flush re-points every dirty thumbnail and relabels it (1.720314ms)
✔ destroy takes the item-menu listener off; a second mount leaves one (0.488948ms)
✔ plan says what to add, drop and where each item goes (0.692445ms)
✔ sync moves only the items out of place and keeps focus (1.240713ms)
✔ desktop view: box and size are the block; hidden and order are always the phone map (1.596234ms)
✔ phone view: everything goes to set_phone, carrying the rest of the map (0.433144ms)
✔ the ops it makes are ones the document takes (9.567221ms)
✔ phoneMap reads only the four keys, and only well-typed (0.219167ms)
✔ the stored view is read defensively (0.36125ms)
✔ linkAt reads the link under the cursor and nothing elsewhere (2.693921ms)
✔ setLink marks the selection (2.367265ms)
✔ setLink on a collapsed cursor inside a link replaces that link (0.843246ms)
✔ setLink with nothing selected and no link does nothing (0.267476ms)
✔ removeLink clears the link under the cursor (0.475954ms)
✔ the keymap binds every documented shortcut (0.264551ms)
✔ Mod-b toggles bold through the keymap (0.751455ms)
✔ setLink refuses an address outside the allowlist and writes nothing (0.639968ms)
✔ the state machine runs choose → sign → put → register → op (3.701712ms)
✔ a file the deck will not take never reaches the network (0.91008ms)
✔ alt text is required before the bytes move, unless the image is decorative (0.374355ms)
✔ a step that fails stops the ones after it and reports its own code (0.712743ms)
✔ a failure that is not ours still becomes an UploadError (0.327779ms)
✔ a second pick while one is in flight is refused, not raced (0.445517ms)
✔ an unreadable size is zero rather than a failure (0.246367ms)
✔ a per-call applyOp wins, so an upload lands on the block it was chosen for (0.248922ms)
✔ checkFile takes exactly the four types (0.402678ms)
✔ sha256Hex is the hex digest of the bytes (9.822606ms)
✔ refusal reads this app's JSON error shape, and survives one that is not (0.300097ms)
✔ malformed fonts are null (3.300778ms)
✔ a malformed background is the theme default (1.59449ms)
✔ a malformed box is null (0.78623ms)
✔ the phone layer keeps only its typed fields, order as a whole number (0.613859ms)
✔ assets are typed or skipped (0.370898ms)
✔ every poison update is deterministic bytes that apply to any deck (7.520172ms)
ℹ tests 158
ℹ suites 0
ℹ pass 158
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 290.743915
=== 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	12.381s
ok  	amadan.net/oficina/slides/internal/slides/core	2.570s
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]
?   	amadan.net/oficina/slides/internal/slides/keymailtest	[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	40.518s
?   	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/AGENTS.md b/AGENTS.md
index 58547a9..6f15278 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -53,8 +53,13 @@ seeded on first sign-in — and comes back out as a PDF.
`form-action` to it); any other gets `303 ?sent=1`, and only a
permitted one is mailed, by a worker after the response (`mailQueue`), so not even the response time differs. Admission is
rastrillo/auth's `Authorize`, after verification, and its refusal is
- the door, rendered in the refusal's own 403 (`doorOnRefusal`; never a query anyone could type), not rastrillo's bare
- text. `?force=1&err=keymail` (a failed Keymail exchange) makes the
+ the door, rendered in the refusal's own 403 by auth's `Refused` hook
+ (`refused`; never a query anyone could type), not rastrillo's bare
+ text. An emailed link opens on a confirm step (`renderConfirm`, auth's
+ `RenderConfirm`): the door with one Sign in button, which POSTs the
+ token back to `/auth/verify` — the GET spends nothing, so a mail
+ scanner fetching the link cannot burn it. Both methods of
+ `/auth/verify` are mounted in both modes. `?force=1&err=keymail` (a failed Keymail exchange) makes the
form ask for a link instead. The list is also **rechecked on
every request** that resolves a principal, so taking an address off it
signs that person out of every device and refuses their agents' tokens
diff --git a/cmd/slides/main.go b/cmd/slides/main.go
index 31b10f7..ac74ca0 100644
--- a/cmd/slides/main.go
+++ b/cmd/slides/main.go
@@ -255,18 +255,10 @@ func main() {
// as d.Close() above.
defer a.Close()
- opts.Mux = mux
+ // The router, the locales, the error page and the CSP
+ // (slides.ServeOptions, serving.go).
+ opts = a.ServeOptions(opts, mux)
opts.DBPath = ""
- // Declaring locales is what installs the middleware rastrillo.T
- // reads through; without these three every catalog lookup would
- // return its own key onto the page.
- opts.Locales = slides.Locales
- opts.DefaultLocale = slides.DefaultLocale
- opts.LocaleFS = slides.LocaleFS
- // The same page a handler's own error path renders (errors.go), so a
- // panic and a handled failure look identical, with the viewer's own
- // chrome.
- opts.ErrorPage = a.ErrorPage
if err := rastrillo.Serve(opts); err != nil {
logger.Error("serve failed", "err", err)
os.Exit(1)
diff --git a/go.mod b/go.mod
index c1de2d9..67f5bab 100644
--- a/go.mod
+++ b/go.mod
@@ -8,7 +8,7 @@ require (
amadan.net/oficina/estilo.git v0.0.0-20261001084202-0b4d92c3dc16
amadan.net/oficina/home/connect v0.0.0-20261001093757-2bec942ddd0c
amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489
- amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd
+ amadan.net/rastrillo/rastrillo v0.27.1-0.20261006204347-fb806a287060
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327
github.com/chromedp/chromedp v0.14.2
github.com/go-chi/chi/v5 v5.3.2
@@ -19,6 +19,7 @@ require (
)
require (
+ amadan.net/rastrillo/rastrillo/money v0.1.0 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/carlosframework/rastrillo v0.23.0 // indirect
github.com/chromedp/sysutil v1.1.0 // indirect
@@ -34,6 +35,7 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ golang.org/x/mod v0.37.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.40.0 // indirect
gorm.io/plugin/dbresolver v1.6.2 // indirect
diff --git a/go.sum b/go.sum
index 345ec50..5373acd 100644
--- a/go.sum
+++ b/go.sum
@@ -4,8 +4,10 @@ amadan.net/oficina/home/connect v0.0.0-20261001093757-2bec942ddd0c h1:3yxO2D8BxD
amadan.net/oficina/home/connect v0.0.0-20261001093757-2bec942ddd0c/go.mod h1:YroavXe43Wietec+po8mtSk4a9Bir2h31Q3iAf4MWxI=
amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 h1:RPKtyry/71i06o8L+gjAaUyexbmG56kO1YPs601URAM=
amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489/go.mod h1:YISpzFJ+iMgrM0rUL032OSzsAQbP4/nozvXLCCOcn1k=
-amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd h1:jlFSt95M1ontOg+VePU+kIJpChtWqDQAbnmlxqCnbgc=
-amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd/go.mod h1:RpyHVPD0udcSfHJroY0KZE/FUjgw6Tvfo8SJm2qeZGE=
+amadan.net/rastrillo/rastrillo v0.27.1-0.20261006204347-fb806a287060 h1:K2PXcCKfm+AYrqgH1ea6cNsFs4sN7MKLvq+uBY11NZE=
+amadan.net/rastrillo/rastrillo v0.27.1-0.20261006204347-fb806a287060/go.mod h1:WmC8TAIfqjAkSwmD+FCTqr7VuqAO5FaX03HlV49K608=
+amadan.net/rastrillo/rastrillo/money v0.1.0 h1:MxU4oqNrW8abxRTkgZBv24OYNpFnvonajNqeLIzmmaM=
+amadan.net/rastrillo/rastrillo/money v0.1.0/go.mod h1:cmgkjBEIQh1pFSYvQk0UU6oPYS8kSDypdT9+t4Flx0U=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/carlosframework/rastrillo v0.23.0 h1:tKAAlwPt4Pg/rkqk7o6jvV12bbZ0r34oEVpuOlKfX7w=
diff --git a/internal/slides/app.go b/internal/slides/app.go
index f81d272..6f08bc2 100644
--- a/internal/slides/app.go
+++ b/internal/slides/app.go
@@ -74,14 +74,21 @@ func New(d *db.DB, cfg Config) (*http.ServeMux, *app, error) {
// that expired mid-page must not turn the click into a sign-in loop.
r.Post("/appearance", chrome.HandleAppearance)
- // Unauthenticated: the front door and rauth's four handlers. The
- // sign-in page has to be reachable signed out or nobody could ever
- // get in.
+ // Unauthenticated: the front door and rauth's handlers. The sign-in
+ // page has to be reachable signed out or nobody could ever get in.
+ //
+ // Verify answers GET with the confirm door (renderConfirm), which
+ // spends nothing, and POST with the redemption, so it is mounted for
+ // both methods in both modes, wrapped alike: without the POST, the
+ // door's Sign in button gets a 405 and nobody signs in. A verified
+ // address that is not admitted is answered by rauth's Refused hook
+ // (refused), inside Callback and Verify.
if a.homeRoutes == nil {
r.Get(signinPath, a.signin)
r.Post(signinPath, a.newBegin(authPlugin.Begin))
- r.Get("/auth/callback", doorOnRefusal(authPlugin.Callback, a.signin))
- r.Get("/auth/verify", doorOnRefusal(authPlugin.Verify, a.signin))
+ r.Get("/auth/callback", authPlugin.Callback)
+ r.Get("/auth/verify", authPlugin.Verify)
+ r.Post("/auth/verify", authPlugin.Verify)
r.Get(landedPath, a.landed)
r.Post("/signout", signedOutAfter(authPlugin.Signout))
r.Get("/signed-out", a.signedOut)
@@ -90,8 +97,9 @@ func New(d *db.DB, cfg Config) (*http.ServeMux, *app, error) {
r.Get(signinPath, a.homeSignin)
r.Get("/signin/local", a.signin)
r.Post(signinPath, h.LocalGuard(a.newBegin(authPlugin.Begin)))
- r.Get("/auth/callback", h.LocalGuard(doorOnRefusal(authPlugin.Callback, a.signin)))
- r.Get("/auth/verify", h.LocalGuard(doorOnRefusal(authPlugin.Verify, a.signin)))
+ r.Get("/auth/callback", h.LocalGuard(authPlugin.Callback))
+ r.Get("/auth/verify", h.LocalGuard(authPlugin.Verify))
+ r.Post("/auth/verify", h.LocalGuard(authPlugin.Verify))
r.Post("/signout", h.Signout)
r.Get("/signed-out", h.SignedOut)
r.Get(connect.LocalCompletePath, h.LocalComplete)
@@ -305,7 +313,11 @@ func newApp(d *db.DB, cfg Config) (*app, *rauth.Auth, error) {
// The sign-in mailer: links are queued and sent after the response,
// to permitted addresses only (mailqueue.go). Closed by app.Close.
mailer := newMailQueue(cfg.Mailer, list.allows, nil, logger)
- authPlugin, err := newAuthPlugin(cfg, d.Writer(), list, mailer)
+ // a exists before the plugin so rauth can hold its Refused and
+ // RenderConfirm pages; it is filled in below, once everything it
+ // carries has been built.
+ a := &app{}
+ authPlugin, err := newAuthPlugin(cfg, d.Writer(), list, mailer, a)
if err != nil {
mailer.close()
return nil, nil, err
@@ -329,7 +341,7 @@ func newApp(d *db.DB, cfg Config) (*app, *rauth.Auth, error) {
return nil, nil, err
}
- a := &app{
+ *a = app{
db: d,
logger: logger,
cfg: cfg,
diff --git a/internal/slides/auth.go b/internal/slides/auth.go
index 45772e7..8d5a0a1 100644
--- a/internal/slides/auth.go
+++ b/internal/slides/auth.go
@@ -165,8 +165,8 @@ func resolveAllowlist(cfg Config) (allowlist, error) {
// whole redirect chain of a form POST, and ours is 'self'). Whether
// the address may sign in here is asked only after Keymail has
// verified it, by rauth's Authorize, and a refusal is the sign-in
-// door saying so (doorOnRefusal) — told only to somebody who has
-// just proved they hold the address.
+// door saying so (refused, rauth's Refused hook) — told only to
+// somebody who has just proved they hold the address.
// - Any other address is answered `303 /signin?sent=1`, and a link is
// mailed only if it is permitted: mailQueue drops the rest, after
// the response, so not even its timing differs (mailqueue.go).
@@ -224,101 +224,83 @@ func (a *app) renderKeymailContinuation(w http.ResponseWriter, r *http.Request,
})
}
-// rauthRefusal is the bare-text body rastrillo/auth's admit() writes,
-// with a 403, when a verified address is not admitted (Authorize said
-// no). rastrillo at the version pinned here has no hook to render
-// anything else; doorOnRefusal recognises this exact answer instead.
-// The Keymail refusal test pins it, so a rastrillo bump that changes
-// the sentence fails loudly rather than letting the bare page back.
-const rauthRefusal = "This address is verified but not admitted here."
-
-// doorOnRefusal wraps rauth's Callback and Verify, the two handlers
-// that end in admit(), and turns its bare-text refusal into the sign-in
-// door, rendered right there in the refusal's own response: the form,
-// with "That address can't sign in to Slides" above it, and a 403. Every
-// other answer, cookies included, passes through untouched.
+// refused is rauth's Refused hook: Keymail or an emailed link verified
+// the address and the allowlist does not hold it. It answers with the
+// sign-in door, "That address can't sign in to Slides" above the form,
+// in a 403. No session is minted, and rauth has already ended the
+// attempt.
//
// Rendered, not redirected to ?err=refused: a query parameter is
// something anybody can type, and then the sentence that means "you
-// proved this address and it is not admitted" could be shown to
-// anybody about nothing (Astra on #20, round 3). The refusal reaches
-// door through the request context (refusedHere), which only this
-// wrapper sets. It is told only to somebody who has just verified the
-// address, so it is no oracle. Remove it when rastrillo grows a refusal
-// hook (feat/auth-refused-hook) and the pin is bumped.
-func doorOnRefusal(next, door http.HandlerFunc) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- rec := &bufferedResponse{header: make(http.Header)}
- next(rec, r)
- if rec.status == http.StatusForbidden && strings.TrimSpace(rec.body.String()) == rauthRefusal {
- for _, c := range rec.header.Values("Set-Cookie") {
- w.Header().Add("Set-Cookie", c)
- }
- door(&forcedStatus{ResponseWriter: w, status: http.StatusForbidden},
- r.WithContext(context.WithValue(r.Context(), refusedKey{}, true)))
- return
- }
- for name, values := range rec.header {
- for _, v := range values {
- w.Header().Add(name, v)
- }
- }
- if rec.status == 0 {
- rec.status = http.StatusOK
- }
- w.WriteHeader(rec.status)
- _, _ = w.Write(rec.body.Bytes())
- }
+// proved this address and it is not admitted" could be shown to anybody
+// about nothing (Astra on #20, round 3). Only this hook can say it, and
+// rauth calls it only after the address is proved, so it is no oracle.
+// It replaced doorOnRefusal, which buffered Callback and Verify and
+// matched rastrillo's bare 403 sentence until the hook existed (#63).
+func (a *app) refused(w http.ResponseWriter, r *http.Request, _ rauth.Identity) {
+ w.Header().Set("Cache-Control", "no-store")
+ form := a.door(r)
+ form.Action = signinPath
+ form.Error = rastrillo.T(r, "slides.signin.error.refused")
+ a.renderSignin(w, r, http.StatusForbidden, signinView{
+ shell: a.publicShell(r, frame{parts: []string{form.Page()}, signinPage: true}),
+ SignIn: form,
+ })
}
-// refusedKey marks a request doorOnRefusal is answering with the door.
-type refusedKey struct{}
-
-// refusedHere reports whether r is a refusal doorOnRefusal is rendering.
-func refusedHere(r *http.Request) bool {
- v, _ := r.Context().Value(refusedKey{}).(bool)
- return v
+// renderConfirm is rauth's RenderConfirm: the page an emailed link opens
+// on. GET /auth/verify spends nothing, so a mail-security scanner that
+// fetches every link in an inbound message cannot spend this one; the
+// door's single Sign in button POSTs the token back to d.Action, and
+// that POST signs in (#64).
+//
+// It is the door with the address field taken away — the same mark,
+// sky, heading and Sign in label — so the person who clicked the link
+// sees Slides, not rastrillo's plain English fallback. estilo has no
+// confirm variant, so the form is signin.html's own. rauth has already
+// set no-store, Referrer-Policy: strict-origin (the URL is a live
+// credential) and the framing refusals; nothing here replaces them.
+// d.Token is untrusted query input and reaches the page only through
+// html/template.
+func (a *app) renderConfirm(w http.ResponseWriter, r *http.Request, d rauth.ConfirmPageData) {
+ c := signinConfirm{Door: a.door(r), Action: d.Action, Token: d.Token}
+ c.Door.Lede = rastrillo.T(r, "slides.signin.confirm.lede")
+ a.renderSignin(w, r, http.StatusOK, signinView{
+ shell: a.publicShell(r, frame{parts: []string{c.Door.Page()}, signinPage: true}),
+ Confirm: &c,
+ })
}
-// forcedStatus answers with status whatever status the page writes.
-type forcedStatus struct {
- http.ResponseWriter
- status int
- wrote bool
+// signinConfirm is the confirm step's door: the chrome, and the form
+// that posts the emailed token back.
+type signinConfirm struct {
+ Door chrome.SignIn
+ Action, Token string
}
-func (f *forcedStatus) WriteHeader(int) {
- if !f.wrote {
- f.wrote = true
- f.ResponseWriter.WriteHeader(f.status)
+// door is the sign-in door's chrome for this instance: Slides' mark and
+// name on its sky.
+func (a *app) door(r *http.Request) chrome.SignIn {
+ return chrome.SignIn{
+ Icon: brand.Path("slides"),
+ App: "Slides",
+ Sky: sky.For(r.Host),
+ OpenSource: &chrome.Link{Href: "/help/open-source", Label: rastrillo.T(r, "slides.credits.title")},
}
}
-func (f *forcedStatus) Write(p []byte) (int, error) {
- f.WriteHeader(f.status)
- return f.ResponseWriter.Write(p)
-}
-
-// bufferedResponse holds a whole answer so doorOnRefusal can decide
-// what to send after seeing it. Callback and Verify answer with a
-// redirect or a line of text, never anything large.
-type bufferedResponse struct {
- header http.Header
- status int
- body bytes.Buffer
-}
-
-func (b *bufferedResponse) Header() http.Header { return b.header }
-func (b *bufferedResponse) WriteHeader(status int) {
- if b.status == 0 {
- b.status = status
- }
-}
-func (b *bufferedResponse) Write(p []byte) (int, error) {
- if b.status == 0 {
- b.status = http.StatusOK
+// renderSignin renders the signin page with status. render alone always
+// answers 200, which the refusal must not.
+func (a *app) renderSignin(w http.ResponseWriter, r *http.Request, status int, v signinView) {
+ var buf strings.Builder
+ if err := pages["signin"].ExecuteTemplate(&buf, "layout", v); err != nil {
+ a.logger.Error("slides: render sign-in page", "err", err)
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ return
}
- return b.body.Write(p)
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(status)
+ _, _ = w.Write([]byte(buf.String()))
}
// sentLocation is the answer POST /signin gives every address that is
@@ -377,7 +359,12 @@ func (s *sameAnswer) WriteHeader(status int) {
// copy of that literal and app.go mounts the page at ours; saying it
// once, out loud, is what keeps the two from drifting silently if
// either default ever changes.
-func newAuthPlugin(cfg Config, writer *sql.DB, list allowlist, mailer *mailQueue) (*rauth.Auth, error) {
+//
+// a supplies the two pages rauth asks of the app: the door a verified
+// but refused address is shown (app.refused) and the door an emailed
+// link opens on (app.renderConfirm). Only their method values are taken
+// here; newApp fills the rest of *a in after this returns.
+func newAuthPlugin(cfg Config, writer *sql.DB, list allowlist, mailer *mailQueue, a *app) (*rauth.Auth, error) {
signedInPath := landedPath
if cfg.Home != nil {
signedInPath = connect.LocalCompletePath
@@ -390,8 +377,23 @@ func newAuthPlugin(cfg Config, writer *sql.DB, list allowlist, mailer *mailQueue
Mailer: mailer,
Subject: chrome.EmailSubject("Slides"),
Authorize: list.allows,
- SigninPath: signinPath,
- Logger: cfg.Logger,
+ // A verified address the allowlist does not hold gets the door,
+ // with the refusal on it, in rauth's own 403 (app.refused).
+ Refused: a.refused,
+ // GET /auth/verify spends nothing: it opens the door with one
+ // Sign in button that POSTs the link back, so a mail scanner's
+ // fetch cannot spend it (app.renderConfirm).
+ RenderConfirm: a.renderConfirm,
+ // No proof-of-work on the sign-in form. Slides never had one:
+ // rauth's per-IP and per-address budgets and mailQueue's
+ // allowlist check are what stand between a stranger and the
+ // mailer, and the door is estilo's form, which renders no pow
+ // challenge. ProofOff keeps today's behaviour; wiring a pow.Guard
+ // is a decision of its own (schema, assets, a form that needs
+ // JavaScript, which the door works without).
+ ProofOff: true,
+ SigninPath: signinPath,
+ Logger: cfg.Logger,
})
}
@@ -651,7 +653,8 @@ func (a *app) endPresentationsOf(r *http.Request, actor string) {
}
// signinView is the sign-in page's data: estilo's sign-in form, or, once
-// a link is on its way, the check-your-email state that replaces it.
+// a link is on its way, the check-your-email state that replaces it, or
+// the confirm step an emailed link opens on.
type signinView struct {
shell
SignIn chrome.SignIn
@@ -660,13 +663,15 @@ type signinView struct {
// (renderKeymailContinuation): its meta refresh goes on to
// Keymail, and Sent holds the door's card saying so.
Continue *connect.OAuthData
+ // Confirm is set on the page an emailed link opens on
+ // (renderConfirm): the door with one Sign in button.
+ Confirm *signinConfirm
}
// signin serves GET /signin: the form, and the landing spot for every
// outcome rauth redirects back to (?sent=1, ?err=rate|address|expired|
-// keymail|removed|1, ?force=1), and the door doorOnRefusal renders in a
-// refusal's own response (refusedHere). rate, address, expired, removed
-// and a refusal each get their own sentence; keymail and a bare failure
+// keymail|removed|1, ?force=1). rate, address, expired and removed each
+// get their own sentence (a refusal is refused's, never the query's); keymail and a bare failure
// share the default, because both mean the same operational thing —
// sign-in did not complete, ask for a new link — and rauth's Verify
// deliberately refuses to be more specific than that. ?force=1 (with
@@ -687,7 +692,7 @@ type signinView struct {
func (a *app) signin(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
ret, hasReturn := safeReturn(q)
- if (a.home == nil || r.URL.Path == "/signin/local") && !refusedHere(r) {
+ if a.home == nil || r.URL.Path == "/signin/local" {
if s, ok := a.sessions.From(r); ok {
_, outcome := a.currentUser(w, sessions.WithSession(r, s))
switch outcome {
@@ -732,13 +737,8 @@ func (a *app) signin(w http.ResponseWriter, r *http.Request) {
})
return
}
- form := chrome.SignIn{
- Icon: brand.Path("slides"),
- App: "Slides",
- Sky: sky.For(r.Host),
- Action: signinPath,
- OpenSource: &chrome.Link{Href: "/help/open-source", Label: rastrillo.T(r, "slides.credits.title")},
- }
+ form := a.door(r)
+ form.Action = signinPath
if q.Get("force") != "" {
// A failed Keymail exchange lands here as ?force=1&err=keymail.
// Posting force back asks for an emailed link instead, so the
@@ -749,13 +749,10 @@ func (a *app) signin(w http.ResponseWriter, r *http.Request) {
form.Return = ret
}
switch err := q.Get("err"); {
- case refusedHere(r):
- // Keymail or a link verified the address and the allowlist does
- // not hold it (doorOnRefusal). The form is the way on. Never
- // from the query: ?err=refused is the unknown-error default.
- form.Error = rastrillo.T(r, "slides.signin.error.refused")
case err == "":
- // nothing to report
+ // nothing to report. (?err=refused is the unknown-error default
+ // below: the refusal's own sentence is refused's, told only in
+ // the refusal's response.)
case err == "rate":
form.Error = rastrillo.T(r, "slides.signin.error.rate")
case err == "address":
diff --git a/internal/slides/credits/assets.json b/internal/slides/credits/assets.json
index bf2182e..d8f1b18 100644
--- a/internal/slides/credits/assets.json
+++ b/internal/slides/credits/assets.json
@@ -28,7 +28,7 @@
"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/datetime.js": "5aea0521b14f9164c14b75fdcc1ff9a5429d2f51d8d78161a4eebba0e41a1ea1",
"internal/slides/static/editor.0463180baf5aab9f.js": "0463180baf5aab9f4f7f2c99ca9d82d69d27d7facebd30831f1459bdeb912ab5",
"internal/slides/static/fonts/LICENSE-OFL-1.1.txt": "d231d22fcd7cdaca7f7ea074dead9dbc4155004a7142bd4044404ad350d33f68",
"internal/slides/static/fonts/LiberationMono-Bold.ttf": "ddce09ee3483b2d5faf61f0baba8592e76e0ac6dd4ab36683e5334c288bf5eef",
@@ -45,7 +45,8 @@
"internal/slides/static/fonts/LiberationSerif-Regular.ttf": "9caef765d2e891c10dd73658894f01e660fe0c1c83e0bda7d6edf561d8f623d4",
"internal/slides/static/present.js": "a28c9d039e5ca3ae09e4de3e1bc2ee2c7950458606962269f95477fd1f3a6424",
"internal/slides/static/rastrillo.js": "b1eac44b25a98ca8144c59dc792ac6c5f66570b40b14f5b428c6838f88e137a3",
- "internal/slides/static/select.js": "6a9548abfb177f65361fcfc4333d9b0a068cec6b9acf8d7eada05591409584a0",
+ "internal/slides/static/select.js": "e231bad4b964464addfacd52d2260d4bdc0a02938fc033a25fdd9cbeb3671be1",
+ "internal/slides/static/shell.js": "bdc4d635fb8f14ef7089f7f692290baea645b13543be36328e7667fe8aed2caf",
"rust-toolchain.toml": "88bc3a3493bc98cad9f5bc5b4f8948739e1a418d8367373bb9b854fecaf5edc6"
},
"components": [
diff --git a/internal/slides/credits/notices.json b/internal/slides/credits/notices.json
index c50457d..ed4da1d 100644
--- a/internal/slides/credits/notices.json
+++ b/internal/slides/credits/notices.json
@@ -90,7 +90,7 @@
},
{
"Name": "amadan.net/rastrillo/rastrillo",
- "Version": "v0.26.1-0.20260908075707-29e4491631bd",
+ "Version": "v0.27.1-0.20261006204347-fb806a287060",
"SourceURL": "https://amadan.net/rastrillo/rastrillo",
"Notices": [
{
@@ -103,6 +103,17 @@
}
]
},
+ {
+ "Name": "amadan.net/rastrillo/rastrillo/money",
+ "Version": "v0.1.0",
+ "SourceURL": "https://amadan.net/rastrillo/rastrillo",
+ "Notices": [
+ {
+ "Name": "LICENSE",
+ "Text": "Mozilla Public License Version 2.0\n==================================\n\n1. Definitions\n--------------\n\n1.1. \"Contributor\"\n means each individual or legal entity that creates, contributes to\n the creation of, or owns Covered Software.\n\n1.2. \"Contributor Version\"\n means the combination of the Contributions of others (if any) used\n by a Contributor and that particular Contributor's Contribution.\n\n1.3. \"Contribution\"\n means Covered Software of a particular Contributor.\n\n1.4. \"Covered Software\"\n means Source Code Form to which the initial Contributor has attached\n the notice in Exhibit A, the Executable Form of such Source Code\n Form, and Modifications of such Source Code Form, in each case\n including portions thereof.\n\n1.5. \"Incompatible With Secondary Licenses\"\n means\n\n (a) that the initial Contributor has attached the notice described\n in Exhibit B to the Covered Software; or\n\n (b) that the Covered Software was made available under the terms of\n version 1.1 or earlier of the License, but not also under the\n terms of a Secondary License.\n\n1.6. \"Executable Form\"\n means any form of the work other than Source Code Form.\n\n1.7. \"Larger Work\"\n means a work that combines Covered Software with other material, in\n a separate file or files, that is not Covered Software.\n\n1.8. \"License\"\n means this document.\n\n1.9. \"Licensable\"\n means having the right to grant, to the maximum extent possible,\n whether at the time of the initial grant or subsequently, any and\n all of the rights conveyed by this License.\n\n1.10. \"Modifications\"\n means any of the following:\n\n (a) any file in Source Code Form that results from an addition to,\n deletion from, or modification of the contents of Covered\n Software; or\n\n (b) any new file in Source Code Form that contains any Covered\n Software.\n\n1.11. \"Patent Claims\" of a Contributor\n means any patent claim(s), including without limitation, method,\n process, and apparatus claims, in any patent Licensable by such\n Contributor that would be infringed, but for the grant of the\n License, by the making, using, selling, offering for sale, having\n made, import, or transfer of either its Contributions or its\n Contributor Version.\n\n1.12. \"Secondary License\"\n means either the GNU General Public License, Version 2.0, the GNU\n Lesser General Public License, Version 2.1, the GNU Affero General\n Public License, Version 3.0, or any later versions of those\n licenses.\n\n1.13. \"Source Code Form\"\n means the form of the work preferred for making modifications.\n\n1.14. \"You\" (or \"Your\")\n means an individual or a legal entity exercising rights under this\n License. For legal entities, \"You\" includes any entity that\n controls, is controlled by, or is under common control with You. For\n purposes of this definition, \"control\" means (a) the power, direct\n or indirect, to cause the direction or management of such entity,\n whether by contract or otherwise, or (b) ownership of more than\n fifty percent (50%) of the outstanding shares or beneficial\n ownership of such entity.\n\n2. License Grants and Conditions\n--------------------------------\n\n2.1. Grants\n\nEach Contributor hereby grants You a world-wide, royalty-free,\nnon-exclusive license:\n\n(a) under intellectual property rights (other than patent or trademark)\n Licensable by such Contributor to use, reproduce, make available,\n modify, display, perform, distribute, and otherwise exploit its\n Contributions, either on an unmodified basis, with Modifications, or\n as part of a Larger Work; and\n\n(b) under Patent Claims of such Contributor to make, use, sell, offer\n for sale, have made, import, and otherwise transfer either its\n Contributions or its Contributor Version.\n\n2.2. Effective Date\n\nThe licenses granted in Section 2.1 with respect to any Contribution\nbecome effective for each Contribution on the date the Contributor first\ndistributes such Contribution.\n\n2.3. Limitations on Grant Scope\n\nThe licenses granted in this Section 2 are the only rights granted under\nthis License. No additional rights or licenses will be implied from the\ndistribution or licensing of Covered Software under this License.\nNotwithstanding Section 2.1(b) above, no patent license is granted by a\nContributor:\n\n(a) for any code that a Contributor has removed from Covered Software;\n or\n\n(b) for infringements caused by: (i) Your and any other third party's\n modifications of Covered Software, or (ii) the combination of its\n Contributions with other software (except as part of its Contributor\n Version); or\n\n(c) under Patent Claims infringed by Covered Software in the absence of\n its Contributions.\n\nThis License does not grant any rights in the trademarks, service marks,\nor logos of any Contributor (except as may be necessary to comply with\nthe notice requirements in Section 3.4).\n\n2.4. Subsequent Licenses\n\nNo Contributor makes additional grants as a result of Your choice to\ndistribute the Covered Software under a subsequent version of this\nLicense (see Section 10.2) or under the terms of a Secondary License (if\npermitted under the terms of Section 3.3).\n\n2.5. Representation\n\nEach Contributor represents that the Contributor believes its\nContributions are its original creation(s) or it has sufficient rights\nto grant the rights to its Contributions conveyed by this License.\n\n2.6. Fair Use\n\nThis License is not intended to limit any rights You have under\napplicable copyright doctrines of fair use, fair dealing, or other\nequivalents.\n\n2.7. Conditions\n\nSections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted\nin Section 2.1.\n\n3. Responsibilities\n-------------------\n\n3.1. Distribution of Source Form\n\nAll distribution of Covered Software in Source Code Form, including any\nModifications that You create or to which You contribute, must be under\nthe terms of this License. You must inform recipients that the Source\nCode Form of the Covered Software is governed by the terms of this\nLicense, and how they can obtain a copy of this License. You may not\nattempt to alter or restrict the recipients' rights in the Source Code\nForm.\n\n3.2. Distribution of Executable Form\n\nIf You distribute Covered Software in Executable Form then:\n\n(a) such Covered Software must also be made available in Source Code\n Form, as described in Section 3.1, and You must inform recipients of\n the Executable Form how they can obtain a copy of such Source Code\n Form by reasonable means in a timely manner, at a charge no more\n than the cost of distribution to the recipient; and\n\n(b) You may distribute such Executable Form under the terms of this\n License, or sublicense it under different terms, provided that the\n license for the Executable Form does not attempt to limit or alter\n the recipients' rights in the Source Code Form under this License.\n\n3.3. Distribution of a Larger Work\n\nYou may create and distribute a Larger Work under terms of Your choice,\nprovided that You also comply with the requirements of this License for\nthe Covered Software. If the Larger Work is a combination of Covered\nSoftware with a work governed by one or more Secondary Licenses, and the\nCovered Software is not Incompatible With Secondary Licenses, this\nLicense permits You to additionally distribute such Covered Software\nunder the terms of such Secondary License(s), so that the recipient of\nthe Larger Work may, at their option, further distribute the Covered\nSoftware under the terms of either this License or such Secondary\nLicense(s).\n\n3.4. Notices\n\nYou may not remove or alter the substance of any license notices\n(including copyright notices, patent notices, disclaimers of warranty,\nor limitations of liability) contained within the Source Code Form of\nthe Covered Software, except that You may alter any license notices to\nthe extent required to remedy known factual inaccuracies.\n\n3.5. Application of Additional Terms\n\nYou may choose to offer, and to charge a fee for, warranty, support,\nindemnity or liability obligations to one or more recipients of Covered\nSoftware. However, You may do so only on Your own behalf, and not on\nbehalf of any Contributor. You must make it absolutely clear that any\nsuch warranty, support, indemnity, or liability obligation is offered by\nYou alone, and You hereby agree to indemnify every Contributor for any\nliability incurred by such Contributor as a result of warranty, support,\nindemnity or liability terms You offer. You may include additional\ndisclaimers of warranty and limitations of liability specific to any\njurisdiction.\n\n4. Inability to Comply Due to Statute or Regulation\n---------------------------------------------------\n\nIf it is impossible for You to comply with any of the terms of this\nLicense with respect to some or all of the Covered Software due to\nstatute, judicial order, or regulation then You must: (a) comply with\nthe terms of this License to the maximum extent possible; and (b)\ndescribe the limitations and the code they affect. Such description must\nbe placed in a text file included with all distributions of the Covered\nSoftware under this License. Except to the extent prohibited by statute\nor regulation, such description must be sufficiently detailed for a\nrecipient of ordinary skill to be able to understand it.\n\n5. Termination\n--------------\n\n5.1. The rights granted under this License will terminate automatically\nif You fail to comply with any of its terms. However, if You become\ncompliant, then the rights granted under this License from a particular\nContributor are reinstated (a) provisionally, unless and until such\nContributor explicitly and finally terminates Your grants, and (b) on an\nongoing basis, if such Contributor fails to notify You of the\nnon-compliance by some reasonable means prior to 60 days after You have\ncome back into compliance. Moreover, Your grants from a particular\nContributor are reinstated on an ongoing basis if such Contributor\nnotifies You of the non-compliance by some reasonable means, this is the\nfirst time You have received notice of non-compliance with this License\nfrom such Contributor, and You become compliant prior to 30 days after\nYour receipt of the notice.\n\n5.2. If You initiate litigation against any entity by asserting a patent\ninfringement claim (excluding declaratory judgment actions,\ncounter-claims, and cross-claims) alleging that a Contributor Version\ndirectly or indirectly infringes any patent, then the rights granted to\nYou by any and all Contributors for the Covered Software under Section\n2.1 of this License shall terminate.\n\n5.3. In the event of termination under Sections 5.1 or 5.2 above, all\nend user license agreements (excluding distributors and resellers) which\nhave been validly granted by You or Your distributors under this License\nprior to termination shall survive termination.\n\n************************************************************************\n* *\n* 6. Disclaimer of Warranty *\n* ------------------------- *\n* *\n* Covered Software is provided under this License on an \"as is\" *\n* basis, without warranty of any kind, either expressed, implied, or *\n* statutory, including, without limitation, warranties that the *\n* Covered Software is free of defects, merchantable, fit for a *\n* particular purpose or non-infringing. The entire risk as to the *\n* quality and performance of the Covered Software is with You. *\n* Should any Covered Software prove defective in any respect, You *\n* (not any Contributor) assume the cost of any necessary servicing, *\n* repair, or correction. This disclaimer of warranty constitutes an *\n* essential part of this License. No use of any Covered Software is *\n* authorized under this License except under this disclaimer. *\n* *\n************************************************************************\n\n************************************************************************\n* *\n* 7. Limitation of Liability *\n* -------------------------- *\n* *\n* Under no circumstances and under no legal theory, whether tort *\n* (including negligence), contract, or otherwise, shall any *\n* Contributor, or anyone who distributes Covered Software as *\n* permitted above, be liable to You for any direct, indirect, *\n* special, incidental, or consequential damages of any character *\n* including, without limitation, damages for lost profits, loss of *\n* goodwill, work stoppage, computer failure or malfunction, or any *\n* and all other commercial damages or losses, even if such party *\n* shall have been informed of the possibility of such damages. This *\n* limitation of liability shall not apply to liability for death or *\n* personal injury resulting from such party's negligence to the *\n* extent applicable law prohibits such limitation. Some *\n* jurisdictions do not allow the exclusion or limitation of *\n* incidental or consequential damages, so this exclusion and *\n* limitation may not apply to You. *\n* *\n************************************************************************\n\n8. Litigation\n-------------\n\nAny litigation relating to this License may be brought only in the\ncourts of a jurisdiction where the defendant maintains its principal\nplace of business and such litigation shall be governed by laws of that\njurisdiction, without reference to its conflict-of-law provisions.\nNothing in this Section shall prevent a party's ability to bring\ncross-claims or counter-claims.\n\n9. Miscellaneous\n----------------\n\nThis License represents the complete agreement concerning the subject\nmatter hereof. If any provision of this License is held to be\nunenforceable, such provision shall be reformed only to the extent\nnecessary to make it enforceable. Any law or regulation which provides\nthat the language of a contract shall be construed against the drafter\nshall not be used to construe this License against a Contributor.\n\n10. Versions of the License\n---------------------------\n\n10.1. New Versions\n\nMozilla Foundation is the license steward. Except as provided in Section\n10.3, no one other than the license steward has the right to modify or\npublish new versions of this License. Each version will be given a\ndistinguishing version number.\n\n10.2. Effect of New Versions\n\nYou may distribute the Covered Software under the terms of the version\nof the License under which You originally received the Covered Software,\nor under the terms of any subsequent version published by the license\nsteward.\n\n10.3. Modified Versions\n\nIf you create software not governed by this License, and you want to\ncreate a new license for such software, you may create and use a\nmodified version of this License if you rename the license and remove\nany references to the name of the license steward (except to note that\nsuch modified license differs from this License).\n\n10.4. Distributing Source Code Form that is Incompatible With Secondary\nLicenses\n\nIf You choose to distribute Source Code Form that is Incompatible With\nSecondary Licenses under the terms of this version of the License, the\nnotice described in Exhibit B of this License must be attached.\n\nExhibit A - Source Code Form License Notice\n-------------------------------------------\n\n This Source Code Form is subject to the terms of the Mozilla Public\n License, v. 2.0. If a copy of the MPL was not distributed with this\n file, You can obtain one at https://mozilla.org/MPL/2.0/.\n\nIf it is not possible or desirable to put the notice in a particular\nfile, then You may include the notice in a location (such as a LICENSE\nfile in a relevant directory) where a recipient would be likely to look\nfor such a notice.\n\nYou may add additional accurate notices of copyright ownership.\n\nExhibit B - \"Incompatible With Secondary Licenses\" Notice\n---------------------------------------------------------\n\n This Source Code Form is \"Incompatible With Secondary Licenses\", as\n defined by the Mozilla Public License, v. 2.0.\n"
+ }
+ ]
+ },
{
"Name": "arc-swap",
"Version": "1.9.2",
diff --git a/internal/slides/home_test.go b/internal/slides/home_test.go
index 700de7b..026a375 100644
--- a/internal/slides/home_test.go
+++ b/internal/slides/home_test.go
@@ -6,6 +6,7 @@ import (
"encoding/base64"
"encoding/json"
"errors"
+ "html"
"net/http"
"net/http/httptest"
"net/url"
@@ -26,6 +27,10 @@ import (
const homeTestOrigin = "https://slides.example.com"
+// confirmForm is the confirm door's one form, as a browser would read it
+// (signin.html's Confirm branch, renderConfirm).
+var confirmForm = regexp.MustCompile(`<form[^>]*method="post" action="([^"]+)">\s*<input type="hidden" name="token" value="([^"]+)">`)
+
func homeFixture(t *testing.T) (*http.ServeMux, *app, User) {
mux, a, u, _ := homeFixtureKey(t)
return mux, a, u
@@ -388,23 +393,21 @@ func TestHomeDeletedLocalIDCannotBeReused(t *testing.T) {
t.Fatal("deleted user's link reassigned to replacement")
}
}
+
+// TestHomePolicyTracksActualFrameworkAndStandalone: with Home, every
+// page's policy is connect.FormPolicy — Slides' own policy with the
+// issuer added to form-action, and nothing else changed — and standalone
+// it is Slides' own policy, through the same ServeOptions main uses.
+// Slides' policy is rastrillo's default bar style-src
+// (TestProductionCSPIsTheDefaultBarStyles in serving_test.go).
func TestHomePolicyTracksActualFrameworkAndStandalone(t *testing.T) {
- bare := http.NewServeMux()
- bare.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })
- baseline, closeBase, err := rastrillo.Handler(rastrillo.Options{Mux: bare})
- if err != nil {
- t.Fatal(err)
- }
- defer closeBase()
- w := httptest.NewRecorder()
- baseline.ServeHTTP(w, httptest.NewRequest("GET", homeTestOrigin+"/", nil))
- policy := w.Header().Get("Content-Security-Policy")
+ policy := ContentSecurityPolicy
mux, a, _ := homeFixture(t)
want := strings.Replace(policy, "form-action 'self'", "form-action 'self' "+a.cfg.Home.Issuer, 1)
if want == policy {
t.Fatal("framework form policy changed")
}
- wrapped, closeHome, err := rastrillo.Handler(rastrillo.Options{Mux: mux})
+ wrapped, closeHome, err := rastrillo.Handler(a.ServeOptions(rastrillo.Options{}, mux))
if err != nil {
t.Fatal(err)
}
@@ -421,15 +424,15 @@ func TestHomePolicyTracksActualFrameworkAndStandalone(t *testing.T) {
t.Fatal(err)
}
defer app.Close()
- standalone, closePlain, err := rastrillo.Handler(rastrillo.Options{Mux: plain})
+ standalone, closePlain, err := rastrillo.Handler(app.ServeOptions(rastrillo.Options{}, plain))
if err != nil {
t.Fatal(err)
}
defer closePlain()
rec := httptest.NewRecorder()
standalone.ServeHTTP(rec, httptest.NewRequest("GET", homeTestOrigin+"/signin", nil))
- if rec.Header().Get("Content-Security-Policy") != policy {
- t.Fatal("standalone CSP changed")
+ if got := rec.Header().Values("Content-Security-Policy"); len(got) != 1 || got[0] != policy {
+ t.Fatal("standalone CSP changed", got)
}
}
@@ -476,7 +479,15 @@ func TestHomeLocalPluginRecoveryRotatesThenReturnsToLink(t *testing.T) {
t.Fatal("local fixture link missing")
}
parsed, _ := url.Parse(link)
- verify := run("GET", parsed.RequestURI(), "")
+ // Opening the link draws the confirm door under LocalGuard and
+ // spends nothing; its Sign in button POSTs the token back, and that
+ // POST is the local verification.
+ open := run("GET", parsed.RequestURI(), "")
+ m := confirmForm.FindStringSubmatch(open.Body.String())
+ if open.Code != 200 || m == nil || !strings.Contains(open.Body.String(), "estilo-door") || len(open.Result().Cookies()) != 0 {
+ t.Fatal("Home-local link did not open the confirm door", open.Code, open.Body.String())
+ }
+ verify := run("POST", html.UnescapeString(m[1]), url.Values{"token": {html.UnescapeString(m[2])}}.Encode())
if verify.Code != 303 || verify.Header().Get("Location") != connect.LocalCompletePath {
t.Fatal("local verify did not reach continuation", verify.Code, verify.Header())
}
diff --git a/internal/slides/locales/en.toml b/internal/slides/locales/en.toml
index 1afb195..4c48c12 100644
--- a/internal/slides/locales/en.toml
+++ b/internal/slides/locales/en.toml
@@ -11,7 +11,9 @@
# estilo's sign-in door shows, on the way back from rastrillo/auth, for
# whichever outcome it was redirected here with
# (?err=rate|address|expired|keymail|removed|refused|1), and the card
-# between the form and Keymail (slides.signin.keymail.*). The door's own words
+# between the form and Keymail (slides.signin.keymail.*), and the line on the
+# confirm step an emailed link opens on (slides.signin.confirm.lede, under
+# the door's own "Sign in" heading and button). The door's own words
# (form, check-your-email, signed out) are estilo's chrome.English, not
# keys here, so no app can drift from the others (shared chrome spec §9.2).
#
@@ -26,6 +28,7 @@ slides.signin.error.expired = "That link has expired or was already used. Enter
slides.signin.error.unknown = "We couldn’t sign you in. Enter your email to get a new link."
slides.signin.error.removed = "Your access has been removed, so you’ve been signed out. Ask whoever runs this team if that’s wrong."
slides.signin.error.refused = "That address can’t sign in to Slides. Use a different address, or ask whoever runs this team to add it."
+slides.signin.confirm.lede = "Select Sign in to finish signing in."
slides.signin.keymail.title = "Continue signing in"
slides.signin.keymail.body = "Taking you to Keymail to finish signing in."
slides.signin.keymail.action = "Continue to Keymail"
diff --git a/internal/slides/serving.go b/internal/slides/serving.go
new file mode 100644
index 0000000..7183323
--- /dev/null
+++ b/internal/slides/serving.go
@@ -0,0 +1,66 @@
+package slides
+
+import (
+ "net/http"
+
+ "amadan.net/rastrillo/rastrillo"
+)
+
+// ContentSecurityPolicy is the policy Slides serves standalone. It is
+// rastrillo's default policy with one change: style-src keeps
+// 'unsafe-inline', which was rastrillo's own default until the bump to
+// fb806a2.
+//
+// The new default (style-src 'self' 'unsafe-hashes' plus pow's honeypot
+// hash) refuses every style attribute and every <style> element, and a
+// deck is drawn with them:
+//
+// - render.Slide, the one renderer behind the viewer, the projector,
+// the presenter, the share link, the editor's stage and the API's
+// .html twin, writes each slide's theme as custom properties on the
+// <section> (colours, faces, the background picture) and each canvas
+// block's box as left/top/width/height/z-index on the block, with
+// its phone overrides beside them. The values are the deck's own,
+// unbounded, so they cannot be hashed or moved into slides.css, and
+// a per-deck stylesheet would be a new route and cache on four
+// surfaces authenticated four different ways;
+// - the editor puts that same server HTML on its stage with innerHTML
+// (fixtures/stage.mjs), which is a style attribute parsed like any
+// other;
+// - the viewer, the projector, the presenter's notes and the remote
+// carry their page layout in a <style> element of their own. Those
+// could move to slides.css, but every one of those pages (bar the
+// remote and the notes, which show pictures) also renders a slide,
+// so moving them alone would buy nothing while the slide needs
+// 'unsafe-inline'.
+//
+// The editor's drag preview, the presence marks and ProseMirror write
+// element.style through the CSSOM, which CSP does not govern.
+//
+// Every other directive is unchanged, so scripts stay same-origin only
+// and framing, base and form targets stay locked. Home mode serves
+// connect.FormPolicy, which carries the same style-src.
+// TestProductionCSPIsTheDefaultBarStyles keeps every other directive in
+// step with rastrillo's default.
+const ContentSecurityPolicy = "default-src 'self'; style-src 'self' 'unsafe-inline'; " +
+ "img-src 'self' data:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
+
+// ServeOptions completes opts with what Slides serves: mux (New's), the
+// locales, its error page and its CSP. main passes the result to
+// rastrillo.Serve, and the CSP tests pass it to rastrillo.Handler, so
+// the policy they check is the one production runs under.
+func (a *app) ServeOptions(opts rastrillo.Options, mux *http.ServeMux) rastrillo.Options {
+ opts.Mux = mux
+ // Declaring locales is what installs the middleware rastrillo.T
+ // reads through; without these three every catalog lookup would
+ // return its own key onto the page.
+ opts.Locales = Locales
+ opts.DefaultLocale = DefaultLocale
+ opts.LocaleFS = LocaleFS
+ // The same page a handler's own error path renders (errors.go), so a
+ // panic and a handled failure look identical, with the viewer's own
+ // chrome.
+ opts.ErrorPage = a.ErrorPage
+ opts.CSP = ContentSecurityPolicy
+ return opts
+}
diff --git a/internal/slides/static/datetime.js b/internal/slides/static/datetime.js
index 285d8ea..1442fc8 100644
--- a/internal/slides/static/datetime.js
+++ b/internal/slides/static/datetime.js
@@ -1365,13 +1365,24 @@
}
var pick = null;
+ var ownsPopup = !!cal || kind === "time";
+ function pickExpanded(open) {
+ if (pick && ownsPopup) pick.setAttribute("aria-expanded", open ? "true" : "false");
+ }
if (cal || kind === "time" || canPick(native)) {
pick = document.createElement("button");
pick.type = "button";
pick.setAttribute("rst-dtp-pick", "");
pick.setAttribute("aria-label", kind === "time" ? clockLabel : pickLabel);
- pick.setAttribute("aria-expanded", "false");
- pick.setAttribute("aria-haspopup", cal ? "dialog" : "listbox");
+ // Popup attributes only where the popup is ours: the grid is a
+ // dialog and the clock is a listbox. Without either, the button
+ // hands over to the browser's own panel, which is not a
+ // listbox and whose open state this page never learns, so it
+ // is announced as the plain button it is.
+ if (ownsPopup) {
+ pick.setAttribute("aria-expanded", "false");
+ pick.setAttribute("aria-haspopup", cal ? "dialog" : "listbox");
+ }
pick.innerHTML = kind === "time" ? CLOCK_SVG : CAL_SVG;
wrap.insertBefore(pick, list);
}
@@ -1401,7 +1412,7 @@
input.setAttribute("aria-controls", listId);
input.removeAttribute("aria-activedescendant");
if (cal) cal.close();
- if (pick) pick.setAttribute("aria-expanded", "false");
+ pickExpanded(false);
wrap.classList.remove("is-above");
}
@@ -1426,7 +1437,7 @@
list.hidden = false;
input.setAttribute("aria-expanded", "true");
input.setAttribute("aria-controls", listId);
- if (pick) pick.setAttribute("aria-expanded", "false");
+ pickExpanded(false);
rebuild(input.value);
place();
}
@@ -1442,7 +1453,7 @@
cal.open(previewOf(input.value));
input.setAttribute("aria-expanded", "true");
input.setAttribute("aria-controls", cal.el.id);
- if (pick) pick.setAttribute("aria-expanded", "true");
+ pickExpanded(true);
place();
}
@@ -1469,7 +1480,7 @@
}
input.setAttribute("aria-expanded", "true");
input.setAttribute("aria-controls", listId);
- if (pick) pick.setAttribute("aria-expanded", "true");
+ pickExpanded(true);
status.textContent = manyFmt.replace("{n}", String(rows.length));
setActive(near);
place();
diff --git a/internal/slides/static/select.js b/internal/slides/static/select.js
index 127b501..39a975e 100644
--- a/internal/slides/static/select.js
+++ b/internal/slides/static/select.js
@@ -1,280 +1,1109 @@
-/* select.js — the searchable-select enhancement. A sibling of
- rastrillo.js, following exactly the same rules: first-party,
- dependency-free, and inert by default. Only a <select> that opts in
- with data-rst-select gets behavior, and what it enhances works with
- scripts disabled, because the thing it enhances is a real <select>
- that never leaves the page.
-
- It is a separate file rather than more of rastrillo.js so that both
- stay small enough for an app owner to read in one sitting — the shim
- holds itself to 8KB and a full ARIA combobox would have doubled it;
- this one holds itself to 12KB.
- Both are app-owned from the moment they are scaffolded.
-
- Vocabulary:
- data-rst-select on a <select>: mirror a filterable
- ARIA 1.2 combobox onto it
- data-rst-select="false" never; stay a native select at any
- size
- data-rst-select-filter placeholder for the filter input
+/* select.js — the searchable-select enhancement, rastrillo.js's sibling
+ on the same rules: first-party, dependency-free, inert by default. Only
+ a <select> opting in with data-rst-select is enhanced, and it stays a
+ real <select> in the page, so everything works with scripts off.
+
+ The PURE half (ranking a query, what leaving the box commits, which
+ blanks are prompts) has no DOM or state and is exported to Node behind
+ a guard, so ui/select_node.mjs pins it without a browser. The rest sits
+ behind the `document` guard. App-owned once scaffolded. Converged with
+ Tito Go's searchselect, whose country picker taught most of it.
+
+ Vocabulary, on the <select>:
+ data-rst-select mirror a filterable ARIA 1.2 combobox
+ onto it
+ data-rst-select="false" never; stay a native select
+ data-rst-select-filter the search box's placeholder
data-rst-select-results live-region text, {n} substituted
data-rst-select-result-one live-region text for exactly one
+ data-rst-select-no-matches the row shown when nothing matches
+ On an <option>, all optional:
+ data-rst-name / data-rst-desc a name and a quieter description, when
+ the option's text is "Name (desc)"
+ data-rst-short what the CLOSED box shows once this is
+ picked ("+44"), for a narrow box
+ data-rst-lead a decorative glyph before the row and
+ the box (a flag); never the only thing
+ saying which option this is
+ data-rst-terms more words the search matches (an ISO
+ code, "+971") that the row does not print
+ data-rst-first leads its ties in an exact search
+ data-rst-prompt a blank that asks rather than answers
+ ("Country")
+ An <hr> between two options is drawn as a divider, hidden while
+ searching. An <optgroup> becomes a labelled group; a search ranks
+ across groups and hides their headings, and clearing it puts every
+ row back in its own group.
- ui/partials/field-select.html emits all four past ten options. The
- strings come from the framework's base catalog (rastrillo.ui.select_*)
- and ride out on attributes, because this markup is built in the
- browser where the catalog is out of reach. */
+ Content added after load is enhanced when the page dispatches
+ rst:select-scan on document ({detail: {root}} to look only there).
+
+ field-select.html emits the strings from the base catalog
+ (rastrillo.ui.select_*) on attributes: the browser has no catalog. */
(function () {
"use strict";
-// A <select> with too many options to scan by eye becomes a filterable
-// combobox — but the <select> itself never leaves the DOM. It is
-// visually hidden and mirrored, so form submission, required
-// validation, reset and browser autofill all keep working on the
-// element they always did. Nothing here invents state: the select is
-// the single source of truth, and if this function never runs the user
-// gets a perfectly ordinary native select.
-//
-// Strings arrive on data attributes because this markup is built in
-// the browser, after the catalog is out of reach. See
-// rastrillo.ui.select_* in the framework's base catalog.
-function combo(native) {
- if (native.dataset.rstEnhanced) return; // idempotent: safe to re-scan
- native.dataset.rstEnhanced = "true";
-
- // The list mirrors the select's own shape rather than flattening it:
- // an <optgroup> the author wrote to make a long list readable becomes
- // a group in the popup, and loose <option> children sit at the top
- // level as a group with no label. native.options would have been
- // shorter and would have silently thrown the headings away.
- var groups = [];
- Array.prototype.forEach.call(native.children, function (el) {
- if (el.tagName === "OPTGROUP") {
- groups.push({ label: el.label, options: Array.prototype.slice.call(el.querySelectorAll("option")) });
- } else if (el.tagName === "OPTION") {
- groups.push({ label: "", options: [el] });
+ // fold is what a query and an option are compared as: lower case, with the
+ // accents taken off and a curly apostrophe read as a straight one, so "cote"
+ // and "cote d'ivoire" find "Côte d’Ivoire" and "osterreich" finds
+ // "Österreich" — someone on a keyboard without the letter still gets there.
+ const fold = (s) =>
+ String(s || "").normalize("NFD").replace(/\p{M}/gu, "").replace(/[\u2018\u2019]/g, "'").toLowerCase().trim();
+
+ // rank says how well one option answers a query, lower being better, or -1
+ // for no match at all. An option is {name, text, terms}: name is the words
+ // the row leads with, text the whole string (name and desc), terms the extra
+ // words it answers to. 0 is an exact hit (typing "gb" or "+44" or the whole
+ // name); 1 is the start of the name; 2 the start of one of its later words,
+ // or of a term; 3 is anywhere in the text. The start of the name outranks the
+ // start of a later word, so "sudan" is Sudan before South Sudan and "guinea"
+ // is Guinea before Equatorial Guinea. A term is matched from its start with
+ // or without a leading "+", so "971" finds +971 as surely as "+971" does.
+ // folded is an option's words read once, as rank compares them: an option
+ // may carry them precomputed (select.js does, so a keystroke over 240
+ // countries folds only the query), or they are read here. Everything rank
+ // derives from an option's own words is read here too — the name without
+ // its trailing bracket, the words it splits into, the terms without their
+ // "+" — so a keystroke does no string work on an option at all.
+ const plain = (t) => t.replace(/^\+/, "");
+ const folded = (o) => {
+ if (o.folded) return o.folded;
+ const name = fold(o.name);
+ const terms = (o.terms || []).map(fold).filter(Boolean);
+ return {
+ name,
+ text: fold(o.text),
+ terms,
+ // A trailing bracket is a gloss on the name, not part of it: somebody who
+ // types "india" whole has named "India (+91)" exactly.
+ bareName: name.replace(/\s*\([^()]*\)$/, ""),
+ words: name.split(/[\s\-’'(),.]+/).filter(Boolean),
+ plainTerms: terms.map(plain),
+ };
+ };
+
+ // ranked is rank for a query already folded, which is how order asks: once
+ // per keystroke, not once per option.
+ const ranked = (o, q) => {
+ if (!q) return 0;
+ const bare = q.replace(/^\+/, "");
+ const f = folded(o);
+ const name = f.name;
+ const terms = f.terms;
+ const plains = f.plainTerms;
+ // A term typed whole, with or without its "+": "44" is +44 exactly.
+ if (name === q || f.bareName === q || terms.includes(q) || (bare && plains.includes(bare))) return 0;
+ if (name.startsWith(q)) return 1;
+ if (f.words.some((w) => w.startsWith(q))) return 2;
+ if (bare && plains.some((t) => t.startsWith(bare))) return 2;
+ if (f.text.includes(q)) return 3;
+ return -1;
+ };
+
+ const rank = (o, query) => ranked(o, fold(query));
+
+ // order returns the options a query matches, best first. Among EXACT matches
+ // (rank 0 — a calling code typed whole) an option marked `first` (data-rst-first) leads — the country that owns
+ // a shared calling code, so "+1" opens on the United States rather than on
+ // American Samoa — and after that ties keep the options' own order, which is
+ // the page's own (or the alphabet's).
+ const order = (opts, query) => {
+ const q = fold(query);
+ return opts
+ .map((o, i) => ({ o, i, r: ranked(o, q) }))
+ .filter((x) => x.r >= 0)
+ .sort((a, b) => a.r - b.r || (a.r === 0 ? (b.o.first ? 1 : 0) - (a.o.first ? 1 : 0) : 0) || a.i - b.i)
+ .map((x) => x.o);
+ };
+
+ // settle is the match that LEAVING the box after a search commits, or null:
+ // the one row a search can only mean. hits are order()'s answer.
+ // - a single match settles;
+ // - a calling code typed whole whose best row owns it settles, with or
+ // without its "+" ("+44" and "44" are the United Kingdom, "1" the US);
+ // - on three or more characters, the best row settles when it is the ONLY
+ // row that good: an exact hit ("germany"), or a name that starts with
+ // the query when nothing else does ("ger" is Germany; Algeria and Niger
+ // only contain it). "ind" settles nothing: India and Indonesia tie, and
+ // neither does an exact hit two rows share.
+ // Two letters alone never settle: "ge" is Georgia's code and the start of
+ // Germany, and a wrong calling code under a phone number is worse than an
+ // unpicked box the form then asks about.
+ const settle = (hits, query) => {
+ if (hits.length === 1) return hits[0];
+ if (!hits.length) return null;
+ const q = fold(query);
+ const bare = q.replace(/^\+/, "");
+ const r0 = rank(hits[0], query);
+ if (r0 === 0 && /^\d+$/.test(bare) && hits[0].first) return hits[0];
+ if (bare.length < 3 || /^\d+$/.test(bare)) return null;
+ // An exact hit settles only when it is the single best row: nothing else
+ // is exact, or of the exact ties it alone is marked first. Two places
+ // named alike ("springfield" is exactly both "Springfield (IL)" and
+ // "Springfield (MA)", the bracket being a gloss) are two answers, and
+ // leaving the box commits neither. Kept byte for byte with Tito Go's.
+ if (r0 === 0) {
+ const ties = hits.filter((o) => ranked(o, q) === 0);
+ if (ties.length === 1) return hits[0];
+ return hits[0].first && ties.filter((o) => o.first).length === 1 ? hits[0] : null;
}
- });
- var id = native.id;
- var listId = id + "-listbox";
- var filterLabel = native.getAttribute("data-rst-select-filter") || "Type to filter";
- var manyFmt = native.getAttribute("data-rst-select-results") || "{n} results";
- var oneFmt = native.getAttribute("data-rst-select-result-one") || "1 result";
-
- var wrap = document.createElement("div");
- wrap.setAttribute("rst-combo", "");
-
- var input = document.createElement("input");
- input.type = "text";
- // rst-input and nothing else: rst-combo-input never had a rule, so
- // the flip dropped it rather than renaming a name into the
- // vocabulary that styles nothing.
- input.setAttribute("rst-input", "");
- input.id = id + "-combo";
- input.autocomplete = "off";
- input.setAttribute("role", "combobox");
- input.setAttribute("aria-expanded", "false");
- input.setAttribute("aria-controls", listId);
- input.setAttribute("aria-autocomplete", "list");
- input.setAttribute("placeholder", filterLabel);
-
- var list = document.createElement("ul");
- list.setAttribute("rst-combo-list", "");
- list.id = listId;
- list.setAttribute("role", "listbox");
- list.hidden = true;
-
- var status = document.createElement("p");
- status.className = "rst-sr-only";
- status.setAttribute("role", "status");
- status.setAttribute("aria-live", "polite");
-
- native.parentNode.insertBefore(wrap, native);
- wrap.appendChild(input);
- wrap.appendChild(list);
- wrap.appendChild(status);
-
- // The label named the select; it now names the control the user
- // actually types into. Without this the combobox has no accessible
- // name at all.
- var label = native.id && document.querySelector('label[for="' + native.id + '"]');
- if (label) label.setAttribute("for", input.id);
- // Drop [rst-input] too: both are single class selectors, so its
- // width:100% wins on source order and would leave the hidden select a
- // full-width box held out of sight by clip-path alone.
- native.removeAttribute("rst-input");
- native.classList.add("rst-sr-only");
- native.setAttribute("tabindex", "-1");
- native.setAttribute("aria-hidden", "true");
-
- var shown = [];
- var active = -1;
-
- function chosenLabel() {
- var o = native.options[native.selectedIndex];
- return o && o.value !== "" ? o.text : "";
+ return r0 < rank(hits[1], query) ? hits[0] : null;
+ };
+
+ // isPrompt says whether an option is a PROMPT rather than an answer: one the
+ // page marked data-rst-prompt (a phone number's "Country"), or the blank of a
+ // REQUIRED select ("Choose one" over the country question). A prompt is never
+ // shown as the pick — the box stays empty so its placeholder ("Search
+ // countries") says what is wanted — and a required select never offers its
+ // blank as a row: choosing it would only leave the question unanswered. An optional select's blank
+ // ("No answer") is a real answer and is neither.
+ // A DISABLED blank is never an answer either, required or not: a page that
+ // renders "Choose one" disabled means it, and a select that stops being
+ // required (a sibling script unticking a box) must not bring it back as a
+ // pick.
+ const isPrompt = (value, marked, required, disabled) =>
+ marked || (value === "" && (required || !!disabled));
+
+ // offered says whether a blank option is listed. A MARKED prompt (a phone
+ // number's "Country") is listed while nothing is typed and drops out once
+ // something is — required or not, exactly as before. An unmarked blank on a
+ // required select is never listed. Any other blank ("No answer") always is.
+ const offered = (value, marked, required, searching, disabled) =>
+ value !== "" || (marked ? !searching : !(required || !!disabled));
+
+ // The pure half, for ui/select_node.mjs. A browser has no `module` and
+ // never takes this branch.
+ if (typeof module !== "undefined" && module && module.exports) {
+ module.exports = { fold: fold, rank: rank, order: order, settle: settle, isPrompt: isPrompt, offered: offered };
}
- input.value = chosenLabel();
+ if (typeof document === "undefined") return;
- function close() {
- list.hidden = true;
+ // The band of this document the reader can see, when a host says so:
+ // {top, bottom} in viewport coordinates. A frame's visual viewport never
+ // shrinks for the top page's keyboard, so a host that can see it tells
+ // the frame; null forgets it. Registered before any box opens, so a box
+ // re-placing on the same event reads the new band.
+ let told = null;
+ document.addEventListener("rst:select-viewport", (e) => {
+ const d = e.detail;
+ told = d && isFinite(d.top) && isFinite(d.bottom) ? { top: +d.top, bottom: +d.bottom } : null;
+ });
+
+ const ACTS = new Set(["ArrowDown", "ArrowUp", "Home", "End", "Enter", "Tab"]);
+
+ const el = (tag, attr) => {
+ const e = document.createElement(tag);
+ if (attr) e.setAttribute(attr, "");
+ return e;
+ };
+
+ // A <select> with too many options to scan by eye becomes a filterable
+ // combobox — but the <select> itself never leaves the DOM. It is
+ // visually hidden and mirrored, so form submission, required
+ // validation, reset and autofill all keep working on the element they
+ // always did. The select is the single source of truth; if this never
+ // runs, the user gets an ordinary native select.
+ // resume, when a box is rebuilt in place, is where the old one was:
+ // {focused, open, typed}. It is restored quietly — no input event for a
+ // page to answer by changing the options again, and no reopening a list
+ // somebody had closed.
+ const enhanced = new WeakSet();
+ function combo(native, resume) {
+ // Idempotent, by a live record rather than the attribute: a page that
+ // replaces a select with a clone of it copies data-rst-enhanced too,
+ // and a clone trusted on that would never be enhanced at all.
+ if (enhanced.has(native)) return;
+ enhanced.add(native);
+ native.dataset.rstEnhanced = "true";
+
+ const id = native.id || "rst-select-" + Math.random().toString(36).slice(2);
+ const listId = id + "-listbox";
+ const say = (key, fallback) => native.getAttribute("data-rst-select-" + key) || fallback;
+ const manyFmt = say("results", "{n} results");
+ const oneFmt = say("result-one", "1 result");
+
+ // The option model mirrors the live select, index for index, so a row
+ // and select.selectedIndex always agree. Groups are kept: each option
+ // knows the list it belongs in, and a search that lifts it out puts
+ // it back there.
+ const groups = [];
+ const opts = Array.from(native.options).map((o, i) => {
+ const d = o.dataset;
+ const g = o.parentElement.tagName === "OPTGROUP" ? o.parentElement : null;
+ if (g && groups[groups.length - 1] !== g) groups.push(g);
+ return {
+ text: o.text,
+ name: d.rstName || o.text,
+ desc: d.rstDesc || "",
+ short: d.rstShort || "",
+ lead: d.rstLead || "",
+ terms: (d.rstTerms || "").split(/\s+/).filter(Boolean),
+ marked: d.rstPrompt !== undefined,
+ // Whether it is a prompt can change: a sibling script may stop the
+ // select being required, so the observer below re-derives it.
+ prompt: isPrompt(o.value, d.rstPrompt !== undefined, native.required, o.disabled),
+ first: d.rstFirst !== undefined,
+ sepBefore: !!(o.previousElementSibling && o.previousElementSibling.tagName === "HR"),
+ disabled: o.disabled,
+ blank: o.value === "",
+ index: i,
+ group: g,
+ // The native option itself, read live: an app that removes or
+ // reorders options after enhancement moves every index, and a
+ // cached one would submit a different option from the row chosen.
+ el: o,
+ li: null,
+ };
+ });
+ for (const o of opts) o.folded = folded(o);
+ const compact = opts.some((o) => o.short);
+
+ const wrap = el("div", "rst-combo");
+ if (compact) wrap.setAttribute("rst-combo-compact", "");
+ // The pick's glyph, drawn before the box. Decorative: the description
+ // below is what says which option it is.
+ const lead = el("span", "rst-combo-lead");
+ lead.setAttribute("aria-hidden", "true");
+ // The whole name of a compact pick, for a screen reader only: a box
+ // showing "+44" is described as "United Kingdom +44".
+ const said = el("span");
+ said.id = id + "-said";
+ said.hidden = true;
+
+ const input = el("input", "rst-input");
+ input.type = "text";
+ input.id = id + "-combo";
+ input.autocomplete = "off";
+ input.spellcheck = false;
+ // A compact box is too narrow for a search sentence, so it says what
+ // it is for in the one word it is labelled with.
+ input.placeholder = compact && native.getAttribute("aria-label")
+ ? native.getAttribute("aria-label")
+ : say("filter", "Type to filter");
+ input.setAttribute("role", "combobox");
input.setAttribute("aria-expanded", "false");
- input.removeAttribute("aria-activedescendant");
- active = -1;
- }
+ input.setAttribute("aria-controls", listId);
+ input.setAttribute("aria-autocomplete", "list");
+ const described = native.getAttribute("aria-describedby");
+ if (described) input.setAttribute("aria-describedby", described);
+ if (compact) input.setAttribute("aria-describedby", described ? described + " " + said.id : said.id);
- function commit(option) {
- native.value = option.value;
- input.value = option.text;
- // Mirror onto the real control, so a listener the app attached to
- // the select still fires.
- native.dispatchEvent(new Event("change", { bubbles: true }));
- close();
- }
+ const list = el("ul", "rst-combo-list");
+ list.id = listId;
+ list.hidden = true;
+ list.setAttribute("role", "listbox");
- // One pickable row. Its index is its position in `shown`, which is
- // the keyboard order — headings never enter it, so arrowing down
- // steps option to option straight across a group boundary.
- function row(o) {
- var li = document.createElement("li");
- li.id = listId + "-" + shown.length;
- li.setAttribute("rst-combo-option", "");
- li.setAttribute("role", "option");
- li.setAttribute("aria-selected", o.value === native.value ? "true" : "false");
- li.textContent = o.text;
- li.addEventListener("mousedown", function (e) {
- e.preventDefault(); // keep focus; blur would undo the pick
- commit(o);
+ const status = el("p");
+ status.className = "rst-sr-only";
+ status.setAttribute("role", "status");
+ status.setAttribute("aria-live", "polite");
+
+ // The label named the select; it now names the control the user
+ // actually types into, and the list too.
+ const label = native.id && document.querySelector('label[for="' + CSS.escape(native.id) + '"]');
+ if (label) {
+ if (!label.id) label.id = id + "-label";
+ label.htmlFor = input.id;
+ list.setAttribute("aria-labelledby", label.id);
+ } else if (native.getAttribute("aria-label")) {
+ input.setAttribute("aria-label", native.getAttribute("aria-label"));
+ list.setAttribute("aria-label", native.getAttribute("aria-label"));
+ }
+
+ // Each group is a labelled box in the list. The group's name lives on
+ // aria-label, so the heading inside is furniture — hidden from the
+ // accessibility tree and out of the keyboard order. <li> cannot hold
+ // <li>, so its rows nest in a list of their own with role=none.
+ const boxes = new Map();
+ for (const g of groups) {
+ const box = el("li");
+ box.setAttribute("role", "group");
+ box.setAttribute("aria-label", g.label);
+ const rows = el("ul", "rst-combo-rows");
+ rows.setAttribute("role", "none");
+ const head = el("li", "rst-select-group");
+ head.setAttribute("aria-hidden", "true");
+ head.textContent = g.label;
+ rows.appendChild(head);
+ box.appendChild(rows);
+ boxes.set(g, { box, rows });
+ }
+ const home = (o) => (o.group ? boxes.get(o.group).rows : list);
+
+ for (const o of opts) {
+ const li = el("li", "rst-combo-option");
+ li.id = listId + "-" + o.index;
+ li.setAttribute("role", "option");
+ li.setAttribute("aria-selected", "false");
+ if (o.disabled) li.setAttribute("aria-disabled", "true");
+ if (o.lead) {
+ const glyph = el("span", "rst-combo-lead");
+ glyph.setAttribute("aria-hidden", "true");
+ glyph.textContent = o.lead;
+ li.append(glyph);
+ }
+ if (o.desc) {
+ // Two spans rather than the option's one string: the brackets are
+ // for a reader without JavaScript, and here a quieter colour does
+ // that job with no punctuation for a locale to argue with.
+ const name = el("span", "rst-combo-name");
+ name.textContent = o.name;
+ const desc = el("span", "rst-combo-desc");
+ desc.textContent = o.desc;
+ li.append(name, desc);
+ } else {
+ li.append(o.text);
+ }
+ o.li = li;
+ if (o.group && !list.contains(boxes.get(o.group).box)) list.appendChild(boxes.get(o.group).box);
+ if (o.sepBefore) {
+ o.sep = el("li", "rst-combo-sep");
+ o.sep.setAttribute("aria-hidden", "true");
+ home(o).appendChild(o.sep);
+ }
+ home(o).appendChild(li);
+ }
+ const empty = el("li", "rst-combo-empty");
+ empty.style.display = "none";
+ empty.textContent = say("no-matches", "No matches");
+ list.appendChild(empty);
+
+ native.parentNode.insertBefore(wrap, native);
+ wrap.append(lead, input, said, list, status);
+ // Drop [rst-input] too: its width:100% would otherwise leave the hidden
+ // select a full-width box held out of sight by clip-path alone.
+ const inputAttr = native.getAttribute("rst-input");
+ native.removeAttribute("rst-input");
+ native.classList.add("rst-sr-only");
+ native.setAttribute("tabindex", "-1");
+ native.setAttribute("aria-hidden", "true");
+
+ let open = false;
+ // The highlighted row itself, not an index: an extra row is in `opts`
+ // but has no index in the select.
+ let active = null;
+ let extras = 0;
+
+ // The selection as the select itself reports it: an option removed after
+ // enhancement keeps `selected` set, so it is never asked directly.
+ const selectedEl = () => native.options[native.selectedIndex] || null;
+ const picked = () => opts.find((o) => o.el && o.el === selectedEl()) || null;
+ // A prompt is not an answer, so the closed box shows nothing for it and
+ // its placeholder says what is wanted.
+ const currentText = () => {
+ const o = picked();
+ return o && !o.prompt ? o.short || o.name : "";
+ };
+ // Whether the box shows the pick or words somebody typed: an open
+ // list refreshed from outside (a required flip, new extras) searches
+ // for the typed words, never for the pick's own text.
+ let showingPick = true;
+ const showText = () => {
+ input.value = currentText();
+ showingPick = true;
+ };
+ const query = () => (showingPick ? "" : input.value);
+ const showPick = () => {
+ const o = picked();
+ lead.textContent = (o && o.lead) || "";
+ lead.hidden = !lead.textContent;
+ if (wrap.hasAttribute("rst-combo-has-lead") === lead.hidden) wrap.toggleAttribute("rst-combo-has-lead", !lead.hidden);
+ said.textContent = o && o.short ? [o.name, o.desc].filter(Boolean).join(" ") : "";
+ };
+ showText();
+ showPick();
+
+ // The row that LEAVING the box after typing takes (Tab, or a tap
+ // elsewhere), or null: settle() says which searches can only mean one
+ // row. A row somebody ARROWED to is their choice, as on a native select.
+ let steered = false;
+ const settledPick = () => {
+ if (!open || !active || active.run) return null;
+ if (steered) return active.el === selectedEl() ? null : active;
+ if (input.value === currentText()) return null;
+ const pick = settle(lastHits, input.value);
+ return pick && pick === (lastHits.length === 1 ? pick : active) ? pick : null;
+ };
+
+ // The rows in the order they are on screen: best match first while
+ // searching, the select's own order otherwise; the page's extras last.
+ let shown = opts;
+ const isShown = (e) => e.style.display !== "none";
+ const visible = () => shown.filter((o) => isShown(o.li) && !o.disabled);
+ // What a list falls back to highlighting: one of the select's own
+ // answers — never a row the page added (it owns Enter), and never a
+ // prompt (a bare Enter would "answer" with the question).
+ const firstReal = () => visible().find((o) => !o.run && !o.prompt) || null;
+ const unanswered = () => {
+ const sel = picked();
+ // Nothing selected at all (selectedIndex -1) is unanswered too.
+ return !sel || sel.prompt;
+ };
+ // The row an idle list highlights: the pick on an answered box, nothing
+ // on an unanswered one — highlighting the first row would let a bare
+ // Enter answer the question with whatever happens to sort first. A
+ // cleared search lands where opening does, so an idle list reads one
+ // way however it got there.
+ const opening = () => {
+ if (unanswered()) return null;
+ const sel = picked();
+ return sel && isShown(sel.li) && !sel.disabled ? sel : firstReal();
+ };
+ // The rows the arrows, Home and End step through: what is on screen,
+ // less a prompt, which is still there to tap.
+ const steps = () => visible().filter((o) => !o.prompt);
+
+ // Everything that reads layout — where the box sits, how much room is
+ // above and below it, scrolling the highlight into view — happens at
+ // most once a frame, in one callback. A keystroke that searched twice
+ // in a frame used to force layout twice, on a long form with several
+ // boxes, on a phone: the "slow typing" was reflow.
+ let layout = 0;
+ let placeDue = false;
+ let scrollTo = null;
+ let scrollTop = false;
+ const onLayout = () => {
+ layout = 0;
+ if (!open) return;
+ if (placeDue) place();
+ placeDue = false;
+ if (scrollTop) list.scrollTop = 0;
+ else if (scrollTo && isShown(scrollTo.li)) scrollTo.li.scrollIntoView({ block: "nearest" });
+ scrollTo = null;
+ scrollTop = false;
+ };
+ const soon = () => {
+ if (!layout) layout = requestAnimationFrame(onLayout);
+ };
+ // Down, unless the room below cannot hold the list's own height (its
+ // stylesheet max-height) and there is more room above; then up, so a box
+ // at the foot of a sheet does not open under a phone's keyboard. Held to
+ // the room on its side. Room is the visual viewport, cut to every
+ // ancestor that scrolls. Only placement flips, never keyboard order or
+ // ARIA. The same rule as Tito Go's searchselect. Worked out when the list opens and when the room
+ // changes — never on a keystroke: the room is the same whatever is
+ // typed, and a keystroke's one layout read is the highlight's.
+ // Measured with nothing of ours on the list — once per open, and again
+ // after a window resize, which may be text made bigger: its stylesheet
+ // height and its gap off the box.
+ let preferred = 0;
+ let gap = 0;
+ let chrome = 0; // padding and borders outside a content-box max-height
+ let scrollers = [];
+ let floors = [];
+ const place = () => {
+ if (!preferred) {
+ // Read with nothing of ours in the way: an upward list has no top
+ // margin, and the gap would read as 0.
+ if (wrap.hasAttribute("rst-combo-up")) wrap.removeAttribute("rst-combo-up");
+ list.style.maxBlockSize = "";
+ const cs = getComputedStyle(list);
+ preferred = parseFloat(cs.maxBlockSize) || 240;
+ gap = parseFloat(cs.marginTop) || 0;
+ chrome = cs.boxSizing === "border-box" ? 0 : ["paddingTop", "paddingBottom", "borderTopWidth", "borderBottomWidth"].reduce((n, k) => n + (parseFloat(cs[k]) || 0), 0);
+ }
+ const vv = window.visualViewport;
+ let top = vv ? vv.offsetTop : 0;
+ let bottom = vv ? vv.offsetTop + vv.height : window.innerHeight;
+ if (told) {
+ top = Math.max(top, told.top);
+ bottom = Math.min(bottom, told.bottom);
+ }
+ for (const a of scrollers) {
+ const r = a.getBoundingClientRect();
+ top = Math.max(top, r.top);
+ bottom = Math.min(bottom, r.bottom);
+ }
+ const box = wrap.getBoundingClientRect();
+ // A bar pinned below the box (data-rst-select-floor) is not room;
+ // the highest wins, one above the box is ignored.
+ for (const f of floors) {
+ const r = f.getBoundingClientRect();
+ if (r.height && r.top >= box.bottom - 1) bottom = Math.min(bottom, r.top);
+ }
+ const below = bottom - box.bottom - gap;
+ const above = box.top - top - gap;
+ // Up only when the list does not fit below and there is more room
+ // above; a tie goes down. Neither fitting, the bigger side wins and
+ // the list is clamped to it, never past the edge (Tito Go #3146).
+ const up = below < preferred + chrome && above > below;
+ const h = Math.floor(Math.max(0, Math.min(preferred + chrome, up ? above : below) - chrome)) + "px";
+ if (list.style.maxBlockSize !== h) list.style.maxBlockSize = h;
+ if (up !== wrap.hasAttribute("rst-combo-up")) wrap.toggleAttribute("rst-combo-up", up);
+ };
+ const replace = (e) => {
+ // A box taken off the page with its whole fragment hears no mutation
+ // and may get no blur; the first event after drops the page-wide
+ // listeners that would otherwise keep it alive.
+ if (!wrap.isConnected) return closeList(false);
+ if (e && e.type === "resize" && e.currentTarget === window) preferred = 0;
+ placeDue = true;
+ soon();
+ };
+ // A panel or sheet around the box scrolling moves it as surely as the
+ // window does; the list's own scrolling does not.
+ const scrolled = (e) => {
+ if (e.target !== list) replace();
+ };
+ const setActive = (o, atTop) => {
+ // Only the row losing the highlight and the row gaining it are touched.
+ // Every write here is guarded: rewriting an attribute to the value it
+ // has is still a mutation the page's stylesheet is asked about.
+ if (active && active !== o) active.li.classList.remove("is-active");
+ if (o && !o.li.classList.contains("is-active")) o.li.classList.add("is-active");
+ active = o || null;
+ if (o) {
+ if (input.getAttribute("aria-activedescendant") !== o.li.id) input.setAttribute("aria-activedescendant", o.li.id);
+ scrollTop = !!(atTop && visible()[0] === o);
+ scrollTo = o;
+ soon();
+ } else if (input.hasAttribute("aria-activedescendant")) {
+ input.removeAttribute("aria-activedescendant");
+ }
+ };
+
+ // A prompt is never selected: a listbox announcing a selected option
+ // says somebody answered the question.
+ const markSelected = () => {
+ for (const o of opts) {
+ const v = o.el && o.el === selectedEl() && !o.prompt ? "true" : "false";
+ if (o.li.getAttribute("aria-selected") !== v) o.li.setAttribute("aria-selected", v);
+ }
+ };
+
+ // A burst of typing is searched twice a frame at most, however many
+ // input events it fires: a phone keyboard can fire several inside one
+ // frame (predictive text, autocorrect) and only the last is ever seen.
+ // The FIRST input of a frame is searched at once, so a single keystroke
+ // reads exactly as it always did; any more only mark the search stale,
+ // and it runs once before the frame is drawn. Anything that ACTS on the
+ // list (Enter, the arrows, Tab, leaving) catches it up first (flush),
+ // so typing then pressing Enter takes the row for the words typed.
+ let pending = false;
+ let frame = 0;
+ let lastQuery = null;
+ let lastHits = [];
+ // Rows a search has lifted to the top of the list, out of their own
+ // place. Only these ever go back when the search is cleared.
+ const lifted = new Set();
+ // Where a row stands with no search: before its successor in the same
+ // list, or last in its group, or before the next group or the empty row.
+ const anchorAfter = (o) => {
+ const next = opts[o.index + 1];
+ if (next && !next.run && next.group === o.group) return next.sep || next.li;
+ if (o.group) return null;
+ if (next && !next.run) return next.group ? boxes.get(next.group).box : next.sep || next.li;
+ return empty;
+ };
+ // A row's shown state, written only when it changes, as an inline
+ // `display`, never the `hidden` attribute: a page with [hidden] inside a
+ // :has() makes each such write a style invalidation WebKit works out on
+ // the spot, and one keystroke hides hundreds of rows (3 to 6 seconds on
+ // an iPhone, on Tito's checkout). No page's CSS asks about inline style.
+ const show = (e, on) => {
+ if (isShown(e) !== on) e.style.display = on ? "" : "none";
+ };
+ const filter = (query) => {
+ pending = false;
+ const own = opts.filter((o) => !o.run);
+ const searching = fold(query) !== "";
+ // A blank that is a prompt is never an answer: a required select never
+ // lists its blank, and a marked prompt drops out once something is
+ // typed. An optional select's "No answer" is a real answer.
+ const answerable = (o) =>
+ !o.blank || offered(o.el.value, o.marked, native.required, searching, o.disabled);
+ const hits = order(own.filter(answerable), query);
+ lastHits = hits.filter((o) => !o.disabled);
+ const hit = new Set(hits);
+ const extra = opts.filter((o) => o.run);
+ shown = searching ? [...hits, ...own.filter((o) => !hit.has(o)), ...extra] : opts;
+ const tail = extra.length ? extra[0].li : null;
+ if (empty.nextSibling !== tail || empty.parentNode !== list) list.insertBefore(empty, tail);
+ // What a keystroke costs is what it MOVES. A search lays out only its
+ // hits, best first, at the top of the list; a row it hides stays where
+ // it is, because a hidden row's place is never seen. A hit already
+ // next in line is not moved, so narrowing a search moves almost
+ // nothing. Clearing puts back only the rows a search lifted.
+ if (searching) {
+ const hitRow = new Set(hits.map((o) => o.li));
+ let ref = list.firstChild;
+ for (const o of hits) {
+ // Never past the empty row (before the extras): a grouped hit is
+ // not a list child, and would land after the extras.
+ while (ref && ref !== empty && ref !== o.li && !hitRow.has(ref)) ref = ref.nextSibling;
+ if (ref === o.li) {
+ ref = ref.nextSibling;
+ } else {
+ list.insertBefore(o.li, ref);
+ lifted.add(o);
+ }
+ }
+ } else if (lifted.size) {
+ // Highest index first, so each row's successor is home before it.
+ for (const o of [...lifted].sort((a, b) => b.index - a.index)) home(o).insertBefore(o.li, anchorAfter(o));
+ lifted.clear();
+ }
+ for (const o of own) {
+ show(o.li, hit.has(o));
+ if (o.sep) show(o.sep, !searching);
+ }
+ for (const { box } of boxes.values()) show(box, !searching);
+ for (const o of extra) show(o.li, true);
+ const found = hits.filter((o) => !o.disabled).length;
+ show(empty, !found);
+ const text = found === 1 ? oneFmt : manyFmt.replace("{n}", String(found));
+ if (status.textContent !== text) status.textContent = text;
+ // A NEW query re-ranks the list, so its best match is the one Enter
+ // takes. The same query run again (the page handing over fresh
+ // extras) keeps a row somebody arrowed to, if it is still there.
+ const requeried = query !== lastQuery;
+ if (requeried) steered = false;
+ lastQuery = query;
+ if (!searching && !steered && (requeried || unanswered())) setActive(opening());
+ else if ((searching && requeried) || !active || !shown.includes(active) || !isShown(active.li) || active.disabled) {
+ setActive(firstReal(), true);
+ }
+ };
+ const flush = () => {
+ if (pending && open) filter(input.value);
+ };
+ const onFrame = () => {
+ frame = 0;
+ flush();
+ };
+ const searchTyping = () => {
+ if (frame) {
+ pending = true;
+ return;
+ }
+ frame = requestAnimationFrame(onFrame);
+ filter(input.value);
+ };
+
+ const openList = (showAll) => {
+ if (open) return;
+ open = true;
+ list.hidden = false;
+ input.setAttribute("aria-expanded", "true");
+ // The ancestors that scroll, read once a list opens: which they are
+ // does not change while it is open.
+ scrollers = [];
+ for (let a = wrap.parentElement; a && a !== document.body; a = a.parentElement) {
+ if (/auto|scroll|hidden|clip/.test(getComputedStyle(a).overflowY)) scrollers.push(a);
+ }
+ floors = [...document.querySelectorAll("[data-rst-select-floor]")];
+ preferred = 0;
+ replace();
+ window.addEventListener("resize", replace);
+ document.addEventListener("rst:select-viewport", replace);
+ document.addEventListener("scroll", scrolled, true);
+ if (window.visualViewport) {
+ window.visualViewport.addEventListener("resize", replace);
+ window.visualViewport.addEventListener("scroll", replace);
+ }
+ if (showAll) filter("");
+ markSelected();
+ setActive(opening());
+ };
+
+ const closeList = (revert) => {
+ if (!open) return;
+ open = false;
+ list.hidden = true;
+ window.removeEventListener("resize", replace);
+ document.removeEventListener("rst:select-viewport", replace);
+ document.removeEventListener("scroll", scrolled, true);
+ if (window.visualViewport) {
+ window.visualViewport.removeEventListener("resize", replace);
+ window.visualViewport.removeEventListener("scroll", replace);
+ }
+ input.setAttribute("aria-expanded", "false");
+ input.removeAttribute("aria-activedescendant");
+ // Typing not yet searched dies with the list, and so does the frame
+ // that would have searched it — or the next keystroke, reopening the
+ // list, would count as the second of that frame and show the old
+ // search's rows against the new words.
+ pending = false;
+ cancelAnimationFrame(frame);
+ frame = 0;
+ // A search closed is a search over: the next one, even with the same
+ // words, is a new query, so it re-ranks (Enter takes its best match,
+ // not a row the old one left highlighted) and clears steering, or
+ // leaving would commit a row somebody arrowed to in a search they
+ // cancelled. Every way back into the list runs filter, so this one
+ // reset carries both; a separate `steered = false` here could never
+ // be observed, and a line no test can see go wrong is not kept.
+ lastQuery = null;
+ if (active) active.li.classList.remove("is-active");
+ if (revert) {
+ showText();
+ showPick();
+ }
+ };
+
+ const choose = (o) => {
+ if (!o || o.disabled) return;
+ // An extra row does something instead of BEING something: there is
+ // nothing to select, and the page decides what happens to the text.
+ if (o.run) {
+ o.run();
+ return;
+ }
+ // A row whose option the page has since removed selects nothing.
+ if (!native.contains(o.el)) {
+ closeList(true);
+ return;
+ }
+ o.el.selected = true;
+ // Mirror onto the real control, so a listener the app attached to the
+ // select still fires.
+ native.dispatchEvent(new Event("change", { bubbles: true }));
+ showText();
+ showPick();
+ markSelected();
+ closeList(false);
+ };
+
+ let resuming = false;
+ input.addEventListener("focus", () => {
+ if (resuming) return;
+ // Select the pick so the first keystroke replaces it; otherwise typing
+ // into "Option 1" searches for "Option 1O".
+ input.select();
+ openList(true);
+ });
+ input.addEventListener("click", () => openList(true));
+ input.addEventListener("input", () => {
+ showingPick = false;
+ // Once somebody types, the box holds their words, not the pick.
+ // Written once, not per keystroke (see show, below).
+ if (!lead.hidden) {
+ lead.hidden = true;
+ wrap.removeAttribute("rst-combo-has-lead");
+ }
+ openList(false);
+ searchTyping();
});
- shown.push(o);
- return li;
- }
- function draw(filter) {
- var needle = filter.trim().toLowerCase();
- shown = [];
- list.innerHTML = "";
- groups.forEach(function (g) {
- var matched = g.options.filter(function (o) {
- return o.value !== "" && o.text.toLowerCase().indexOf(needle) !== -1;
- });
- // A group filtered down to nothing takes its heading with it: a
- // heading over no rows is a lie about what is there.
- if (!matched.length) return;
- var into = list;
- if (g.label) {
- // The group's name lives on aria-label, so the heading itself is
- // furniture — hidden from the accessibility tree, out of the
- // keyboard order, announced once as the group it names.
- var box = document.createElement("li");
- box.setAttribute("role", "group");
- box.setAttribute("aria-label", g.label);
- // <li> cannot hold <li>, so the rows nest in a list of their
- // own; role=none keeps it out of the accessibility tree, which
- // leaves the options owned by the group.
- into = document.createElement("ul");
- into.setAttribute("rst-combo-rows", "");
- into.setAttribute("role", "none");
- var head = document.createElement("li");
- head.setAttribute("rst-select-group", "");
- head.setAttribute("aria-hidden", "true");
- head.textContent = g.label;
- into.appendChild(head);
- box.appendChild(into);
- list.appendChild(box);
+ // Whether this keystroke's keydown took Enter for the list: see the
+ // keypress guard below.
+ let tookEnter = false;
+ input.addEventListener("keydown", (e) => {
+ tookEnter = false;
+ if (ACTS.has(e.key)) flush();
+ switch (e.key) {
+ case "ArrowDown": {
+ e.preventDefault();
+ if (!open) return openList(true);
+ const v = steps();
+ steered = true;
+ setActive(v[Math.min(v.indexOf(active) + 1, v.length - 1)] || v[0] || null);
+ break;
+ }
+ case "ArrowUp": {
+ e.preventDefault();
+ if (!open) return openList(true);
+ const v = steps();
+ steered = true;
+ // From nothing highlighted, Up goes to the LAST row, as a native
+ // select and the APG combobox pattern do.
+ setActive((active ? v[Math.max(v.indexOf(active) - 1, 0)] : v[v.length - 1]) || null);
+ break;
+ }
+ case "Home":
+ case "End":
+ if (open) {
+ e.preventDefault();
+ steered = true;
+ const v = steps();
+ setActive((e.key === "Home" ? v[0] : v[v.length - 1]) || null);
+ }
+ break;
+ case "Enter":
+ // With the list open, Enter belongs to the list: it picks the
+ // highlighted row, and with none it does nothing. It never submits
+ // the form from under an open list, which would post whatever the
+ // select held before the person chose.
+ if (open) {
+ e.preventDefault();
+ tookEnter = true;
+ if (active) choose(active);
+ }
+ break;
+ case "Escape":
+ if (open) {
+ // This Escape was for the list; something further up (a dialog)
+ // must not take it as its own and close with the choice half made.
+ e.preventDefault();
+ e.stopPropagation();
+ closeList(true);
+ }
+ break;
+ case "Tab":
+ // Somebody who typed "irel", sees Ireland highlighted and tabs on
+ // means Ireland, as a native select commits its type-ahead on Tab.
+ // With nothing typed, Tab only leaves. Escape reverts.
+ if (settledPick()) choose(settledPick());
+ else closeList(true);
+ break;
}
- matched.forEach(function (o) { into.appendChild(row(o)); });
});
- list.hidden = shown.length === 0;
- input.setAttribute("aria-expanded", shown.length ? "true" : "false");
- status.textContent = shown.length === 1
- ? oneFmt
- : manyFmt.replace("{n}", String(shown.length));
- active = -1;
- input.removeAttribute("aria-activedescendant");
- }
- function move(delta) {
- if (list.hidden) draw(input.value);
- if (!shown.length) return;
- active = (active + delta + shown.length) % shown.length;
- // The rows, not list.children: a grouped list's children are the
- // groups, and the headings inside them are not stops.
- list.querySelectorAll('[role="option"]').forEach(function (li, i) {
- li.classList.toggle("is-active", i === active);
+ // A synthesised key can arrive as a keydown and a separate character
+ // event, and the second carries the \r implicit form submission listens
+ // for with no memory of the first one's preventDefault. Measured, not
+ // guessed (issue #86): without this a browser drive submitted the form
+ // on every Enter and passed only by outrunning the navigation. Keyed on
+ // what the keydown did rather than on `open`, because taking a row has
+ // closed the list by the time the character arrives.
+ input.addEventListener("keypress", (e) => {
+ if (e.key === "Enter" && tookEnter) e.preventDefault();
+ tookEnter = false;
});
- input.setAttribute("aria-activedescendant", listId + "-" + active);
- }
- input.addEventListener("input", function () { draw(input.value); });
- input.addEventListener("focus", function () {
- // Select the committed label so the first keystroke replaces it.
- // Otherwise typing into a box holding "Option 1" filters for
- // "Option 1Option 12" and silently matches nothing.
- input.select();
- draw("");
- });
- input.addEventListener("blur", function () {
- // A half-typed filter is not a value: restore what is committed.
- window.setTimeout(function () { input.value = chosenLabel(); close(); }, 0);
- });
- input.addEventListener("keydown", function (e) {
- switch (e.key) {
- case "ArrowDown": e.preventDefault(); move(1); break;
- case "ArrowUp": e.preventDefault(); move(-1); break;
- case "Home": if (!list.hidden) { e.preventDefault(); active = -1; move(1); } break;
- case "End": if (!list.hidden) { e.preventDefault(); active = shown.length - 1; move(0); } break;
- case "Enter":
- // Enter acts on the filter, never on the form: submitting
- // mid-filter discards the edit and silently keeps the previous
- // selection. Take the highlight, or the only match, or close.
- e.preventDefault();
- if (active >= 0) {
- commit(shown[active]);
- } else if (shown.length === 1) {
- commit(shown[0]);
- } else {
- close();
+ // mousedown, not click, so focus never leaves the input mid-pick.
+ list.addEventListener("mousedown", (e) => {
+ const li = e.target.closest("[rst-combo-option]");
+ if (!li) return;
+ e.preventDefault();
+ choose(opts.find((o) => o.li === li));
+ });
+
+ // Leaving the box after typing takes the settled row, as Tab does: on a
+ // phone there is no Tab. With nothing typed, leaving only leaves.
+ wrap.addEventListener("focusout", (e) => {
+ if (gone) return;
+ if (wrap.contains(e.relatedTarget)) return;
+ flush();
+ if (settledPick()) choose(settledPick());
+ else closeList(true);
+ });
+
+ // Rows the page adds after the select's own, replacing whatever it added
+ // last: suggestions for what to do with the typed text. Each is
+ // {label, meta, run}; an empty list takes them all away.
+ // The rows last handed over, so a rebuild can hand them to the new box.
+ let given = [];
+ wrap.rstExtras = (rows) => {
+ given = rows || [];
+ for (let i = opts.length - 1; i >= 0; i--) {
+ if (!opts[i].run) break;
+ opts[i].li.remove();
+ opts.splice(i, 1);
+ }
+ for (const row of rows || []) {
+ const li = el("li", "rst-combo-option");
+ li.id = listId + "-extra-" + ++extras;
+ li.setAttribute("role", "option");
+ li.setAttribute("aria-selected", "false");
+ const name = el("span", "rst-combo-name");
+ name.textContent = row.label;
+ li.append(name);
+ if (row.meta) {
+ const meta = el("span", "rst-combo-desc");
+ meta.textContent = row.meta;
+ li.append(meta);
+ }
+ list.appendChild(li);
+ // index -1, so markSelected can never call one selected.
+ opts.push({ text: row.label, name: row.label, desc: row.meta || "", short: "", lead: "",
+ terms: [], first: false, disabled: false, index: -1, group: null, el: null, li, run: row.run });
+ }
+ shown = opts;
+ if (open) filter(query());
+ };
+
+ // A write from outside (reset, autofill, a sibling script) dispatches
+ // "change", and the display follows. choose()'s own lands as a no-op.
+ // Listeners this box puts on the select, which outlives it: removed in
+ // one go when the box steps aside, or every rebuild would leave one
+ // more set answering the select's events for a box no longer there.
+ const mine = new AbortController();
+ native.addEventListener("change", () => {
+ showText();
+ showPick();
+ markSelected();
+ input.setCustomValidity("");
+ settleValidity();
+ }, { signal: mine.signal });
+
+ // A REQUIRED select still validates — it is still what the form posts —
+ // but it is out of sight and aria-hidden, so the browser's "please pick
+ // one" would focus a control nobody can see. The focus it gives the
+ // select is handed to the box, which BORROWS the select's message for
+ // the moment it is shown and gives it straight back: left on the box, it
+ // would keep the form refusing after the select stopped being required.
+ const settleValidity = () => {
+ if (!native.validity.valid && native.willValidate) return;
+ // Valid now, or no longer validated at all (a sibling script
+ // dropped required, or disabled it): the borrowed message goes too,
+ // or it would keep the form refusing a select that no longer objects.
+ input.removeAttribute("aria-invalid");
+ input.setCustomValidity("");
+ };
+ native.addEventListener("invalid", () => input.setAttribute("aria-invalid", "true"), { signal: mine.signal });
+ // "true", not an empty value: an ARIA boolean is a string.
+ const settleRequired = () => {
+ if (native.required) input.setAttribute("aria-required", "true");
+ else input.removeAttribute("aria-required");
+ };
+ settleRequired();
+ // A sibling script may stop the select being required (or start it)
+ // without touching it otherwise. That changes which blanks are prompts,
+ // and so what the box shows and which row is selected.
+ const flips = new MutationObserver(() => {
+ settleRequired();
+ settleValidity();
+ for (const o of opts) {
+ if (!o.run) o.prompt = isPrompt(o.el.value, o.marked, native.required, o.el.disabled);
+ }
+ markSelected();
+ // An open list re-reads its rows: a blank that just became a prompt
+ // must leave it, and must not stay the highlight.
+ if (open) {
+ if (showingPick) showText();
+ filter(query());
+ } else {
+ showText();
+ showPick();
+ }
+ });
+ flips.observe(native, { attributes: true, attributeFilter: ["required", "disabled"] });
+
+ // Stepping aside: the box gives the page its select back as it found
+ // it. Used when the page changes the options (the box is rebuilt from
+ // what is there now) and when it replaces the select outright (the box
+ // goes, and whatever took the select's place is enhanced afresh). Every
+ // observer is disconnected first, so nothing of the old box reacts to
+ // the new one's work; and `gone` quiets its focus handlers, which a
+ // browser may fire as the box leaves the page.
+ let gone = false;
+ const stepAside = () => {
+ gone = true;
+ mine.abort();
+ flips.disconnect();
+ options.disconnect();
+ swapped.disconnect();
+ closeList(false);
+ cancelAnimationFrame(frame);
+ cancelAnimationFrame(layout);
+ native.classList.remove("rst-sr-only");
+ native.removeAttribute("tabindex");
+ native.removeAttribute("aria-hidden");
+ if (inputAttr !== null) native.setAttribute("rst-input", inputAttr);
+ delete native.dataset.rstEnhanced;
+ enhanced.delete(native);
+ if (label) label.htmlFor = native.id;
+ wrap.remove();
+ };
+ // The page changed the options themselves (rebuilt a list, retitled a
+ // row): every row must be an option the select really has, so the box
+ // is built again from what is there now. Somebody mid-search keeps
+ // their focus and their words.
+ const options = new MutationObserver((records) => {
+ if (!records.some((r) => r.target !== native || r.type !== "attributes")) return;
+ // Changed and then moved or replaced in the same task: that is a
+ // handover, not a rebuild (this observer runs first).
+ if (!native.isConnected || native.parentNode !== wrap.parentNode) {
+ stepAside();
+ queueMicrotask(() => scan());
+ return;
+ }
+ // Typing not yet searched is searched first, or the words and the
+ // highlight below would describe two different searches.
+ flush();
+ const resume = {
+ focused: document.activeElement === input, open, typed: showingPick ? null : input.value,
+ // The row somebody is on — by its option, or by its place among the
+ // page's extras — and whether they arrowed there: Enter and Tab must
+ // still mean that row after the rebuild.
+ at: active && active.el, extra: active && active.run ? opts.filter((o) => o.run).indexOf(active) : -1,
+ steered, given, tookEnter,
+ caret: [input.selectionStart, input.selectionEnd, input.selectionDirection],
+ };
+ stepAside();
+ combo(native, resume);
+ });
+ options.observe(native, { childList: true, subtree: true, characterData: true, attributes: true,
+ attributeFilter: ["value", "label", "disabled", "data-rst-prompt"] });
+ // The page replaced the select itself (a question flow swapping its
+ // answer control): this box would speak for an element no longer in
+ // the form, showing one pick while the form posts another. It steps
+ // aside, and the element that took the select's place is enhanced if
+ // it asks to be.
+ const swapped = new MutationObserver(() => {
+ if (native.isConnected && native.parentNode === wrap.parentNode) return;
+ stepAside();
+ // After every observer has run: two selects replaced in one task
+ // each retire their box first, so no new box is built while an old
+ // one still holds its label.
+ queueMicrotask(() => scan());
+ });
+ if (wrap.parentNode) swapped.observe(wrap.parentNode, { childList: true });
+ native.addEventListener("focus", () => {
+ input.focus();
+ if (!native.validity.valid) {
+ input.setCustomValidity(native.validationMessage);
+ input.reportValidity();
+ }
+ }, { signal: mine.signal });
+ input.addEventListener("input", () => input.setCustomValidity(""));
+ input.addEventListener("blur", () => {
+ if (gone) return;
+ input.setCustomValidity("");
+ settleValidity();
+ });
+ if (resume) {
+ resuming = true;
+ if (resume.focused) input.focus();
+ resuming = false;
+ tookEnter = resume.tookEnter;
+ if (resume.given.length) wrap.rstExtras(resume.given);
+ if (resume.open) {
+ openList(resume.typed === null);
+ if (resume.typed !== null) {
+ input.value = resume.typed;
+ showingPick = false;
+ lead.hidden = true;
+ wrap.removeAttribute("rst-combo-has-lead");
+ filter(resume.typed);
}
- break;
- case "Escape": e.preventDefault(); close(); input.value = chosenLabel(); break;
+ const was = resume.extra >= 0 ? opts.filter((o) => o.run)[resume.extra]
+ : resume.at && opts.find((o) => o.el === resume.at && isShown(o.li) && !o.disabled);
+ if (was) {
+ setActive(was);
+ steered = resume.steered;
+ }
+ }
+ if (resume.focused) input.setSelectionRange(...resume.caret);
}
- });
+ return input;
+ }
- // The same guard datetime.js carries, where the long version of this
- // note lives. Enter belongs to the combobox, never to the form: a
- // commit that also submits posts whatever the select held before the
- // user chose. On a key a person presses, the keydown handler's
- // preventDefault settles it — the engine derives the keypress from
- // the keydown it just cancelled. On a SYNTHESISED key it does not:
- // CDP, and every browser drive built on it, can deliver the keydown
- // and the character as two independent events, so the second arrives
- // with no memory of the first one's refusal, carrying the \r that
- // implicit form submission listens for. Measured, not guessed (issue
- // #86): without this line the select drive fired a submit on every
- // run and passed only by outrunning the navigation.
- input.addEventListener("keypress", function (e) {
- if (e.key === "Enter") e.preventDefault();
- });
-}
-
-// Idempotent, so re-scanning is safe if a future change re-scans after
-// the shim swaps in a polled fragment. Today nothing does: a select
-// arriving inside a replacement stays native — correct, not enhanced.
-function scan() {
- document.querySelectorAll("select[data-rst-select]").forEach(function (el) {
- // data-rst-select="false" is the markup-side opt-out: a select that
- // says no stays native at any size. It has to be checked rather
- // than selected against, because to CSS the attribute is simply
- // present. field-select opts out the other way, by never emitting
- // the attribute at all; this is for markup written by hand.
- if (el.dataset.rstSelect !== "false") combo(el);
- });
-}
+ // Idempotent, so re-scanning is safe. A select arriving later inside a
+ // polled fragment stays native — correct, not enhanced.
+ // Enhances every data-rst-select under root not already enhanced (the
+ // WeakSet in combo), so calling it again is harmless.
+ function scan(root) {
+ const within = root && root.querySelectorAll ? root : document;
+ const found = [...within.querySelectorAll("select[data-rst-select]")];
+ if (within.matches && within.matches("select[data-rst-select]")) found.push(within);
+ found.forEach((s) => {
+ // data-rst-select="false" is the markup-side opt-out; to CSS the
+ // attribute is simply present, so it is checked here.
+ if (s.dataset.rstSelect !== "false" && !s.multiple) combo(s);
+ });
+ }
-if (document.readyState === "loading") {
- document.addEventListener("DOMContentLoaded", scan);
-} else {
- scan();
-}
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", () => scan());
+ } else {
+ scan();
+ }
+ // Added content (a modal fetched in, a polled fragment) stays native
+ // until the page asks; running this file again instead would
+ // double-enhance everything already there.
+ // A microtask later, so a box the page replaced in the same task has
+ // retired first and handed back its label.
+ document.addEventListener("rst:select-scan", (e) => queueMicrotask(() => scan(e.detail && e.detail.root)));
})();
diff --git a/internal/slides/static/shell.css b/internal/slides/static/shell.css
new file mode 100644
index 0000000..b149ec3
--- /dev/null
+++ b/internal/slides/static/shell.css
@@ -0,0 +1,51 @@
+/* shell.css — the slide between the index and a page on a phone, for
+ the sidebar and console shells, the only layouts that link it.
+
+ It is a file of its own because the opt-in cannot be scoped: written
+ in tokens.css, @view-transition would opt every page of every shell
+ into cross-document transitions at narrow widths, the sign-in page
+ included. Both documents must opt in, and a stylesheet only these two
+ shells link is exactly that set. Delete it (and its <link>) and
+ navigation is instant; nothing else changes.
+
+ shell.js types each transition "forward" or "back" in pagereveal.
+ Each :active-view-transition-type() selector is a rule of its own: an
+ engine that does not know the pseudo-class drops the whole rule, and a
+ co-listed selector would take its neighbours with it (the trap the
+ console's rules in tokens.css record). The slide is a physical
+ translateX, so a right-to-left page has four rules of its own that
+ slide the other way. Under reduced motion nothing opts in and
+ navigation is instant; the block at the end sets every animation here
+ to none as well, which is the form the motion gate reads. */
+@media (max-width: 799.98px) and (prefers-reduced-motion: no-preference) {
+ @view-transition { navigation: auto; }
+ ::view-transition-group(root) { animation-duration: 0.32s; animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1); }
+ html:active-view-transition-type(forward)::view-transition-old(root) { animation: 0.32s cubic-bezier(0.2, 0.8, 0.2, 1) both rst-shell-out-start; }
+ html:active-view-transition-type(forward)::view-transition-new(root) { animation: 0.32s cubic-bezier(0.2, 0.8, 0.2, 1) both rst-shell-in-end; }
+ html:active-view-transition-type(back)::view-transition-old(root) { animation: 0.32s cubic-bezier(0.2, 0.8, 0.2, 1) both rst-shell-out-end; z-index: 1; }
+ html:active-view-transition-type(back)::view-transition-new(root) { animation: 0.32s cubic-bezier(0.2, 0.8, 0.2, 1) both rst-shell-in-start; }
+ html[dir="rtl"]:active-view-transition-type(forward)::view-transition-old(root) { animation: 0.32s cubic-bezier(0.2, 0.8, 0.2, 1) both rst-shell-out-start-rtl; }
+ html[dir="rtl"]:active-view-transition-type(forward)::view-transition-new(root) { animation: 0.32s cubic-bezier(0.2, 0.8, 0.2, 1) both rst-shell-in-end-rtl; }
+ html[dir="rtl"]:active-view-transition-type(back)::view-transition-old(root) { animation: 0.32s cubic-bezier(0.2, 0.8, 0.2, 1) both rst-shell-out-end-rtl; z-index: 1; }
+ html[dir="rtl"]:active-view-transition-type(back)::view-transition-new(root) { animation: 0.32s cubic-bezier(0.2, 0.8, 0.2, 1) both rst-shell-in-start-rtl; }
+ /* The console's bar holds still while the page under it slides. */
+ .rst-shell-console > .rst-shell__bar, [rst-shell-console] > [rst-shell-bar] { view-transition-name: rst-shell-bar; }
+}
+@keyframes rst-shell-in-end { from { transform: translateX(100%); } }
+@keyframes rst-shell-out-start { to { opacity: 0.7; transform: translateX(-25%); } }
+@keyframes rst-shell-in-start { from { opacity: 0.7; transform: translateX(-25%); } }
+@keyframes rst-shell-out-end { to { transform: translateX(100%); } }
+@keyframes rst-shell-in-end-rtl { from { transform: translateX(-100%); } }
+@keyframes rst-shell-out-start-rtl { to { opacity: 0.7; transform: translateX(25%); } }
+@keyframes rst-shell-in-start-rtl { from { opacity: 0.7; transform: translateX(25%); } }
+@keyframes rst-shell-out-end-rtl { to { transform: translateX(-100%); } }
+@media (prefers-reduced-motion: reduce) {
+ html:active-view-transition-type(forward)::view-transition-old(root) { animation: none; }
+ html:active-view-transition-type(forward)::view-transition-new(root) { animation: none; }
+ html:active-view-transition-type(back)::view-transition-old(root) { animation: none; }
+ html:active-view-transition-type(back)::view-transition-new(root) { animation: none; }
+ html[dir="rtl"]:active-view-transition-type(forward)::view-transition-old(root) { animation: none; }
+ html[dir="rtl"]:active-view-transition-type(forward)::view-transition-new(root) { animation: none; }
+ html[dir="rtl"]:active-view-transition-type(back)::view-transition-old(root) { animation: none; }
+ html[dir="rtl"]:active-view-transition-type(back)::view-transition-new(root) { animation: none; }
+}
diff --git a/internal/slides/static/shell.js b/internal/slides/static/shell.js
new file mode 100644
index 0000000..9394db0
--- /dev/null
+++ b/internal/slides/static/shell.js
@@ -0,0 +1,143 @@
+/* shell.js — phone navigation for the sidebar and console shells.
+ First-party, dependency-free, CSP-clean, and optional: delete it (and
+ its <script> tag) and every page still works, because the index and
+ the back control are ordinary server-rendered pages and links. It is
+ app-owned from the moment it is scaffolded.
+
+ What it reads, all written by the layouts, in either spelling:
+ [rst-shell-sidebar~="index"|"page"], [rst-shell-console~=…]
+ which view the server rendered (the "view"
+ block): .rst-shell-sidebar--index and so on
+ [rst-shell-back] a the back control; its href is the "up" block
+ [rst-shell-nav] a the index's rows
+
+ What it adds, three things:
+ 1. Direction. In pagereveal it types the view transition "back" or
+ "forward", which shell.css turns into a slide.
+ 2. Back reuses history. A plain click on the back control calls
+ history.back() when, and only when, the Navigation API proves the
+ entry behind this one is the up page, in another document; the
+ index then comes back from the back/forward cache as it was left.
+ 3. Focus returns to the section you left. Leaving a content page
+ records its path in sessionStorage; revealing the index at phone
+ width focuses the nav link to that path, or else the one the URL's
+ #fragment names.
+
+ The layouts load it with blocking="render": the direction is set in
+ pagereveal, at the first render, and a deferred script can run after
+ it. An engine that ignores blocking gets an untyped transition, which
+ shell.css draws as a cross-fade. */
+(function () {
+ "use strict";
+
+ var ROOT = "[rst-shell-sidebar],[rst-shell-console],.rst-shell-sidebar,.rst-shell-console";
+ var INDEX = '[rst-shell-sidebar~="index"],[rst-shell-console~="index"],.rst-shell-sidebar--index,.rst-shell-console--index';
+ var PAGE = '[rst-shell-sidebar~="page"],[rst-shell-console~="page"],.rst-shell-sidebar--page,.rst-shell-console--page';
+ var BACK = "[rst-shell-back] a[href],.rst-shell__back a[href]";
+ var NAV = "[rst-shell-nav] a[href],.rst-shell__nav a[href]";
+ var NARROW = "(max-width: 799.98px)";
+ var RETURN = "rst-shell-return", WENT_BACK = "rst-shell-back";
+
+ // sessionStorage can throw: storage disabled, a quota, a sandboxed
+ // frame. Any throw reads as "no record", so the fragment rule below
+ // still works.
+ function store(fn) {
+ try { return fn(window.sessionStorage); } catch (e) { return null; }
+ }
+ // One-shot: read and remove, so a record is consumed at most once.
+ function take(key) {
+ return store(function (s) { var v = s.getItem(key); s.removeItem(key); return v; });
+ }
+ // A URL as this script compares URLs: resolved against the page, path
+ // and query, fragment ignored; null for another origin.
+ function place(href) {
+ var u = new URL(href, location.href);
+ return u.origin === location.origin ? u.pathname + u.search : null;
+ }
+ function view(sel) {
+ var r = document.querySelector(ROOT);
+ return !!r && r.matches(sel);
+ }
+
+ // 3. Focus return, read when an index is revealed and never while
+ // prerendering: a prerendered index runs its first scripts with a copy
+ // of storage taken before the reader left the page, and pagereveal at
+ // activation is the first moment the fresh record is there. The record
+ // wins over the fragment: after going back to /#nav-invoices and then
+ // visiting Orders, a history return restores the old fragment. The
+ // record is taken before the width test: an index revealed wide would
+ // otherwise leave it behind, and a later phone-width index reached some
+ // other way would focus a link the reader left long ago.
+ function returnFocus() {
+ if (document.prerendering || !view(INDEX)) return;
+ var rec = take(RETURN), links, hit = null, i, id;
+ if (!matchMedia(NARROW).matches) return;
+ links = document.querySelectorAll(NAV);
+ for (i = 0; rec && !hit && i < links.length; i++) {
+ if (place(links[i].href) === rec) hit = links[i];
+ }
+ if (!hit && location.hash) {
+ try { id = decodeURIComponent(location.hash.slice(1)); } catch (e) { id = location.hash.slice(1); }
+ hit = document.getElementById(id);
+ if (hit && !hit.matches(NAV)) hit = null;
+ }
+ if (hit) hit.focus();
+ }
+
+ // 1. Direction. Back when the page being left recorded that its back
+ // control was used AND this page is where it pointed, or when the
+ // Navigation API reports a traverse to an earlier entry (the browser's
+ // own Back); forward otherwise. The record is written on the click,
+ // before anything commits, so a Back whose leave-page prompt was
+ // cancelled leaves it behind: scoped to its destination, the next link
+ // followed still slides forward. Taken on every reveal, matched or not,
+ // so a stale one never waits for its URL to come round. A URL pattern
+ // cannot decide it: the new page cannot know which URL is the index,
+ // and the pattern is the app's.
+ function reveal(e) {
+ var back = take(WENT_BACK) === location.pathname + location.search, a = window.navigation && navigation.activation;
+ if (!back && a && a.navigationType === "traverse" && a.from && a.entry) back = a.entry.index < a.from.index;
+ // Focus first, and the type only where there is a set to add it to:
+ // an engine with cross-document transitions but no types must not
+ // turn a cosmetic step into a throw that skips the focus return.
+ returnFocus();
+ if (e.viewTransition && e.viewTransition.types) e.viewTransition.types.add(back ? "back" : "forward");
+ }
+ if ("onpagereveal" in window) window.addEventListener("pagereveal", reveal);
+ else window.addEventListener("pageshow", returnFocus);
+
+ // The record is written when a content page is LEFT, which covers
+ // every way back: the back control, the browser's Back, a deep link.
+ // pageswap fires on the outgoing document for every navigation, the
+ // activation of a prerendered page included, before the new one is
+ // revealed; pagehide is the fallback where there is no pageswap.
+ // Never both: a pagehide landing after the index took the record
+ // would leave a stale one behind.
+ function leave() {
+ if (view(PAGE)) store(function (s) { s.setItem(RETURN, location.pathname + location.search); });
+ }
+ window.addEventListener("onpageswap" in window ? "pageswap" : "pagehide", leave);
+
+ // 2. Back reuses history, only when it can prove what is behind: a
+ // plain primary click, the Navigation API, and the entry before this
+ // one a DIFFERENT document whose path and query are the back link's
+ // (index, page, #section on the same page: Back must not stop at the
+ // fragment). A redirect behind "up" fails the test and the link is
+ // followed. Anything unproven follows the link and history grows by
+ // one, which is the scriptless behaviour, not a failure. The referrer
+ // is not consulted: it names the document's referrer, not the entry
+ // behind this one.
+ document.addEventListener("click", function (e) {
+ var a = e.target.closest && e.target.closest(BACK), nav = window.navigation, cur, prev, want;
+ if (!a || e.defaultPrevented || e.button !== 0 || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
+ want = place(a.href);
+ if (want !== null) store(function (s) { s.setItem(WENT_BACK, want); });
+ cur = nav && nav.currentEntry;
+ if (!cur || cur.index < 1) return;
+ prev = nav.entries()[cur.index - 1];
+ if (want !== null && prev && !prev.sameDocument && prev.url && place(prev.url) === want) {
+ e.preventDefault();
+ history.back();
+ }
+ });
+})();
diff --git a/internal/slides/static/tokens.css b/internal/slides/static/tokens.css
index 10f6da5..6886854 100644
--- a/internal/slides/static/tokens.css
+++ b/internal/slides/static/tokens.css
@@ -87,6 +87,20 @@
--rst-sp-5: 1.5rem;
--rst-sp-6: 2.5rem;
+ /* The tap floor on small or touch screens: 44px at the default root,
+ and it tracks a reader who raised their default size. Declared here
+ so an app's own CSS can use it; only rules inside the touch query
+ below read it, so desktop density never sees it. */
+ --rst-tap: 2.75rem;
+
+ /* The list grid's kebab column. A variable rather than the literal
+ 32px the samples used to write, so the column can grow to a 44px
+ kebab on touch screens (the touch block redefines it). An app that
+ keeps a literal 32px still works: the kebab is justify-self: end,
+ so it overflows 12px toward the inline start, into the grid's
+ 0.85rem gap, without reaching the cell beside it. */
+ --rst-col-menu: 32px;
+
/* The scrollbar's gutter, reserved whether or not there is a
scrollbar in it, so a short page and a long one are the same width
and clicking between them does not slide the layout sideways.
@@ -114,6 +128,34 @@
--rst-scrollbar-gutter: stable;
}
+/* ── Small or touch screens: the type scale moves up one step ───────
+ One query decides "a phone, a tablet, or a window too narrow to read
+ at desktop density", and every touch-only rule in this file sits in
+ it. pointer: coarse is the PRIMARY pointer, so a touchscreen laptop
+ driven by its trackpad keeps desktop density; any-pointer would
+ switch a mouse user's whole interface because a touchscreen exists.
+ max-width: 40rem catches a narrow desktop window and the design
+ gallery's 390px Mobile tab, a fine pointer showing what a phone
+ shows (a rem in a media query is the browser's initial size, not the
+ root's). The shells' 800px is a different axis: between 640 and
+ 800px under a mouse is a split window, which gets the narrow LAYOUT
+ and keeps mouse DENSITY.
+
+ The four tokens move and nothing else: the root size, the rem
+ spacing and every px geometry stay, and controls grow because their
+ text grows and because the touch block at the end of this file gives
+ them a minimum box. Display sizes (page titles, stat numbers, avatar
+ initials) stay literal on purpose, so a 22px title sits over 16px
+ body text. ─────────────────────────────────────────────────────── */
+@media (pointer: coarse), (max-width: 40rem) {
+ :root {
+ --rst-fs-lg: 1.1875rem; /* 19px */
+ --rst-fs-base: 1rem; /* 16px */
+ --rst-fs-sm: 0.875rem; /* 14px */
+ --rst-fs-xs: 0.8125rem; /* 13px */
+ }
+}
+
/* ── Base. Three rules, so a freshly scaffolded app looks finished
before it has any CSS of its own. Delete them if your app owns
its own base styles. ──────────────────────────────────────────── */
@@ -166,11 +208,16 @@ body {
/* ── Buttons: page-header's primary action, empty-state's CTA. ────── */
+/* border-box because rst-btn goes on <a> as often as on <button>, and
+ only <button> is border-box by the browser's own stylesheet: a block
+ link-button's width: 100% came out its padding and border wider than
+ its column, and poked past the sign-in card on a phone. */
.rst-btn, [rst-btn] {
align-items: center;
background: var(--rst-surface);
border: 1px solid var(--rst-line-strong);
border-radius: var(--rst-radius-sm);
+ box-sizing: border-box;
color: var(--rst-text);
cursor: pointer;
display: inline-flex;
@@ -297,6 +344,12 @@ body {
width: 100%;
}
+/* The UA's [hidden] { display: none } loses to the display this file
+ gives every button, so a button marked hidden would show anyway. The
+ sign-in screen's passkey door ships hidden and is revealed only where
+ a ceremony can work; without this it would show everywhere. */
+.rst-btn[hidden], [rst-btn][hidden] { display: none; }
+
/* busy — the loading state rastrillo.js gives every submit button on
its way out, and the one state of a button that is not a variant:
any .rst-btn can wear it. The spinner is a real child element
@@ -330,6 +383,48 @@ body {
align-self: center;
flex: none;
}
+/* 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;
+ }
+}
/* ── page-header ──────────────────────────────────────────────────── */
.rst-page-header, [rst-page-header] {
@@ -518,6 +613,15 @@ body {
min-inline-size: 0;
padding: 0.2rem 0;
}
+/* The zoom floor for the search input. Mobile Safari zooms a page that
+ focuses a text control under 16px, and this input is set to
+ --rst-fs-sm, which is 14px even after the touch step: this is the one
+ floor that fires inside the rastrillo idioms. It sits directly after
+ the rule above because at equal weight the later rule wins; see the
+ input floor below for the rest of the reasoning. */
+@media (pointer: coarse), (max-width: 40rem) {
+ .rst-search input[type="search"], [rst-search] input[type="search"] { font-size: max(1rem, 1em); }
+}
.rst-search input[type="search"]::placeholder, [rst-search] input[type="search"]::placeholder {
color: var(--rst-text-faint);
}
@@ -580,7 +684,9 @@ body {
.rst-lbar + .rst-row, [rst-lbar] + [rst-row] {
border-top: 0;
}
-.rst-row:hover, [rst-row]:hover {
+/* Only a row that is a link looks like one: the fill follows the
+ primary link, so a hand-written row with nothing to open stays flat. */
+.rst-row:has(> .rst-row__main > a):hover, [rst-row]:has(> [rst-row-main] > a):hover {
background: var(--rst-surface-2);
}
.rst-row__lead, [rst-row-lead] {
@@ -616,8 +722,13 @@ body {
flex: 1;
min-inline-size: 0;
}
-/* The name link's overlay makes the whole row clickable without nesting
- one anchor inside another. The action pill sits above it. */
+/* A row with a destination has ONE primary link, and its ::after covers
+ the row, so the row is the target on every screen: the
+ cursor, the status-bar URL, middle-click and "open in new tab" all
+ work across the whole width. Everything else in the row that can be
+ operated is lifted above the overlay by the :where() rule under the
+ list grid. The row is not a link, it contains one, so nested anchors
+ stay impossible. */
.rst-row__main > a, [rst-row-main] > a {
color: var(--rst-text);
font-weight: 600;
@@ -628,6 +739,13 @@ body {
inset: 0;
position: absolute;
}
+/* Focus draws the ring on the overlay, inset 2px so a card's corner
+ cannot cut it, so a keyboard user sees the whole row outlined. The
+ link also underlines: a check that reads the focused element and its
+ ancestors (the gallery's keyboard walk) never sees a pseudo-element,
+ and forced-colors mode keeps text decoration. */
+.rst-row__main > a:focus-visible, [rst-row-main] > a:focus-visible, .rst-lrow > a.rst-nm:focus-visible, [rst-lrow] > a.rst-nm:focus-visible, .rst-lrow > a.rst-person:focus-visible, [rst-lrow] > a[rst-person]:focus-visible { outline: none; text-decoration: underline; }
+.rst-row__main > a:focus-visible::after, [rst-row-main] > a:focus-visible::after, .rst-lrow > a.rst-nm:focus-visible::after, [rst-lrow] > a.rst-nm:focus-visible::after, .rst-lrow > a.rst-person:focus-visible::after, [rst-lrow] > a[rst-person]:focus-visible::after { border-radius: var(--rst-radius-sm); outline: 2px solid var(--rst-accent); outline-offset: -2px; }
.rst-row__main > a:hover, [rst-row-main] > a:hover {
text-decoration: underline;
}
@@ -654,12 +772,11 @@ body {
color: var(--rst-accent);
}
-/* A status pill inside a row sits above the name link's overlay, like
- the action pill does. */
+/* A status pill inside a row is part of the row's target, not a
+ control: a dead spot inside a row you can tap is a mis-tap on a
+ phone. It is not lifted, so a tap on it opens the row. */
.rst-row .rst-status, [rst-row] [rst-status] {
flex: none;
- position: relative;
- z-index: 1;
}
/* ── status-pill. The dot is decoration; the label is the state. ──── */
@@ -979,16 +1096,23 @@ body {
.rst-box > .rst-detail, [rst-box] > [rst-detail] { margin: 0; }
/* The list grid — the real data-table vocabulary. The card sets the
- columns once (--rst-cols, inline, trailing 32px for the kebab);
- rows only choose cells. Hover fill only on rows that contain the
- identity link — a display-only row must never look clickable.
+ columns once (--rst-cols, from a class in the app's stylesheet —
+ the default CSP blocks inline styles — trailing
+ var(--rst-col-menu) for the kebab); rows only choose cells. Hover
+ fill only on rows that contain the identity link — a display-only
+ row must never look clickable.
Like .rst-list, .rst-card has no padding: rows only. Anything that
is not a row belongs in .rst-box. */
.rst-card, [rst-card] { background: var(--rst-surface); border: 1px solid var(--rst-line); border-radius: var(--rst-radius); }
.rst-lrow, [rst-lrow] { align-items: center; border-bottom: 1px solid var(--rst-line); display: grid; gap: 0.85rem; grid-template-columns: var(--rst-cols, 1fr); padding: 0.68rem 1rem; position: relative; }
.rst-lrow:last-child, [rst-lrow]:last-child { border-bottom: 0; }
-.rst-lrow--head, [rst-lrow~="head"] { color: var(--rst-text-faint); font-size: 0.71875rem; font-weight: 550; letter-spacing: 0.05em; padding: 0.55rem 1rem; text-transform: uppercase; }
-.rst-lrow:not(.rst-lrow--head):has(> .rst-nm, > .rst-person):hover, [rst-lrow]:not([rst-lrow~="head"]):has(> .rst-nm, > [rst-person]):hover { background: var(--rst-accent-soft); }
+.rst-lrow--head, [rst-lrow~="head"] { color: var(--rst-text-faint); font-size: var(--rst-fs-xs); font-weight: 550; letter-spacing: 0.05em; padding: 0.55rem 1rem; text-transform: uppercase; }
+.rst-lrow:not(.rst-lrow--head):has(> a.rst-nm, > a.rst-person):hover, [rst-lrow]:not([rst-lrow~="head"]):has(> a.rst-nm, > a[rst-person]):hover { background: var(--rst-accent-soft); }
+/* The list grid's primary link is its identity cell: the rst-nm link,
+ or a person link when a person is who the row is. Its ::after covers
+ the row, as list-row-action's does. One identity cell per row: with
+ both, the later overlay wins and the earlier link is unreachable. */
+.rst-lrow > a.rst-nm::after, [rst-lrow] > a.rst-nm::after, .rst-lrow > a.rst-person::after, [rst-lrow] > a[rst-person]::after { content: ""; inset: 0; position: absolute; }
.rst-nm { border-radius: var(--rst-radius-sm); color: inherit; font-weight: 550; min-width: 0; text-align: start; text-decoration: none; }
a.rst-nm:hover { color: var(--rst-accent); }
.rst-nm small { color: var(--rst-text-muted); display: block; font-size: var(--rst-fs-xs); font-weight: 400; }
@@ -996,9 +1120,35 @@ a.rst-nm:hover { color: var(--rst-accent); }
.rst-no-match, [rst-no-match] { color: var(--rst-text-muted); font-size: var(--rst-fs-sm); padding: 1.6rem 1rem; text-align: center; }
.rst-no-match a, [rst-no-match] a { color: var(--rst-accent); }
.rst-count-line, [rst-count-line] { color: var(--rst-text-faint); font-size: var(--rst-fs-xs); margin: 0.7rem 0 0; }
+/* Controls above the overlay. A positioned overlay paints above every
+ element that is not positioned, whatever the DOM order, so without
+ this a checkbox before the name link would be covered and a tap on it
+ would open the item.
+
+ It lifts the CONTROLS, never their containers: lifting a row menu's
+ <details> would make it a stacking context and trap its open panel
+ under the next row's lifted controls. The :not() is attached to the
+ :is() with no space, so it filters the controls themselves; with a
+ space it is a descendant combinator and lifts things inside them.
+ The primary link is excluded, or its ::after would measure against
+ the link instead of the row.
+
+ The whole selector is inside :where(), so it weighs nothing: its job
+ is to give an otherwise static control a position and a z-index, and
+ never to replace a position a component chose. The switch's input,
+ the date field's pick button and select.js's hidden native select are
+ all absolutely placed, and at zero weight their own rules win
+ position while z-index still applies; at any weight they would be
+ pulled back into flow, out of the boxes they were drawn for.
+
+ Each lifted control is a stacking context at z-index 1, so a popup
+ it draws itself (a tooltip's [rst-tip]::after) paints under the next
+ row's lifted controls. Do not put such a control in a row without
+ raising it. */
+:where(:is(.rst-row, .rst-lrow) :is(a[href], button, summary, label, input, select, textarea):not(.rst-row__main > a, .rst-lrow > a.rst-nm, .rst-lrow > a.rst-person)), :where(:is([rst-row], [rst-lrow]) :is(a[href], button, summary, label, input, select, textarea):not([rst-row-main] > a, [rst-lrow] > a.rst-nm, [rst-lrow] > a[rst-person])) { position: relative; z-index: 1; }
@media (max-width: 800px) {
.rst-m-hide { display: none; }
- .rst-lrow, [rst-lrow], .rst-lrow--head, [rst-lrow~="head"] { grid-template-columns: minmax(0, 1fr) auto 32px; }
+ .rst-lrow, [rst-lrow], .rst-lrow--head, [rst-lrow~="head"] { grid-template-columns: minmax(0, 1fr) auto var(--rst-col-menu); }
}
/* row-menu — the per-row kebab: native details/summary, no JS. The
@@ -1025,6 +1175,15 @@ a.rst-nm:hover { color: var(--rst-accent); }
[rst-locale] hr { border: 0; border-top: 1px solid var(--rst-line); margin: 0.25rem 0; }
.rst-danger { color: var(--rst-tone-negative-fg); }
.rst-row-menu__panel .rst-danger:hover, [rst-row-menu-panel] .rst-danger:hover { background: var(--rst-tone-negative-bg); }
+/* A POST item is a one-button form: no margin, so it lines up with a
+ link item. busy.js puts a spinner in front of a submit button's label,
+ and a menu item is not an [rst-btn], so the spinner sits BESIDE the
+ label; this reserves its slot at the inline end whether or not the
+ item is busy, so a long translated label can never widen the panel or
+ wrap mid-submit. The cost is 1.5rem of padding on POST items. */
+.rst-row-menu__panel form, [rst-row-menu-panel] form { margin: 0; }
+.rst-row-menu__panel button, [rst-row-menu-panel] button { padding-inline-end: calc(0.65rem + 1rem + var(--rst-sp-2)); position: relative; }
+.rst-row-menu__panel button > .rst-spin, [rst-row-menu-panel] button > [rst-spin] { inset-block: 0; inset-inline-end: 0.65rem; margin-block: auto; position: absolute; }
/* dropdown — the details/summary menu vocabulary (header overflow,
list-bar Filter/Sort). Exclusivity between siblings is the native
@@ -1206,6 +1365,19 @@ details[open] > summary > .rst-caret, details[open] > summary > [rst-caret] { tr
out of its card, and a picker button anchored to the wrapper landing
47px short of the input's visible end. */
.rst-input, [rst-input] { background: var(--rst-surface); border: 1px solid var(--rst-line-strong); border-radius: var(--rst-radius-sm); box-sizing: border-box; color: inherit; font: inherit; padding: 0.45rem 0.6rem; width: 100%; }
+/* The zoom floor for the input, and why it is HERE. `font: inherit` in
+ the rule above resets font-size, so a floor placed before it would be
+ reset with it; the primary rule below sizes the field on purpose, so
+ the floor must come before that or it wins the tie and pulls a 19px
+ field down to 16. max(1rem, 1em) lifts a field whose parent is small
+ (a bulk bar, a menu panel) to 16px; 1em in font-size is the PARENT's
+ size, which is exactly why it cannot also keep a large field large —
+ the bug the old single floor had. A :where() spelling was rejected: at
+ zero weight it loses to `font: inherit` above and switches itself off.
+ ui/sizing_test.go holds all three floors to their positions. */
+@media (pointer: coarse), (max-width: 40rem) {
+ .rst-input, [rst-input] { font-size: max(1rem, 1em); }
+}
.rst-input:focus-visible, [rst-input]:focus-visible { outline: 2px solid var(--rst-accent); outline-offset: 2px; }
/* short is compact, not tiny: 8rem is a border-box width, so it renders
within a pixel or two of the old 6.5rem content box plus its padding,
@@ -1229,6 +1401,12 @@ textarea.rst-input, textarea[rst-input] { min-height: 5rem; resize: vertical; }
class; styled here so both field families render finished. */
.rst-field__required, [rst-field-required] { color: var(--rst-tone-negative-fg); }
.rst-textarea, [rst-textarea] { background: var(--rst-surface); border: 1px solid var(--rst-line-strong); border-radius: var(--rst-radius-sm); box-sizing: border-box; color: inherit; font: inherit; line-height: 1.6; min-height: 5rem; padding: 0.45rem 0.6rem; resize: vertical; width: 100%; }
+/* The textarea's zoom floor, directly after the rule whose `font:
+ inherit` would reset it: a floor above that line is dead, undone by
+ the reset. Reasoning as for the input floor above. */
+@media (pointer: coarse), (max-width: 40rem) {
+ .rst-textarea, [rst-textarea] { font-size: max(1rem, 1em); }
+}
.rst-textarea:focus-visible, [rst-textarea]:focus-visible { outline: 2px solid var(--rst-accent); outline-offset: 2px; }
/* The three message lines under a control. display:block is load-bearing
rather than cosmetic: the field partial emits them as <p>, and the
@@ -1429,7 +1607,7 @@ dialog.rst-modal-panel, dialog[rst-modal-panel] { block-size: auto; color: inher
.rst-help, [rst-help] { align-items: center; border: 1px solid var(--rst-line-strong); border-radius: 7px; color: var(--rst-text-muted); display: inline-flex; height: 28px; justify-content: center; position: relative; width: 28px; }
.rst-help:hover, [rst-help]:hover { border-color: var(--rst-text-faint); color: var(--rst-text); }
.rst-help svg, [rst-help] svg, .rst-help .icon, [rst-help] .icon { height: 15px; width: 15px; }
-.rst-tip::after, [rst-tip]::after { background: var(--rst-text); border-radius: var(--rst-radius-sm); color: var(--rst-bg); content: attr(data-tip); font-size: 0.71875rem; font-weight: 500; inset-inline-end: 0; opacity: 0; padding: 3px 8px; pointer-events: none; position: absolute; top: calc(100% + 6px); transition: opacity 0.12s; white-space: nowrap; z-index: 35; }
+.rst-tip::after, [rst-tip]::after { background: var(--rst-text); border-radius: var(--rst-radius-sm); color: var(--rst-bg); content: attr(data-tip); font-size: var(--rst-fs-xs); font-weight: 500; inset-inline-end: 0; opacity: 0; padding: 3px 8px; pointer-events: none; position: absolute; top: calc(100% + 6px); transition: opacity 0.12s; white-space: nowrap; z-index: 35; }
.rst-tip:hover::after, [rst-tip]:hover::after, .rst-tip:focus-visible::after, [rst-tip]:focus-visible::after { opacity: 1; }
/* bulk-bar + selbox — select mode as server-rendered state. */
@@ -1442,7 +1620,15 @@ dialog.rst-modal-panel, dialog[rst-modal-panel] { block-size: auto; color: inher
.rst-bulkbar__escalate:hover, [rst-bulkbar-escalate]:hover { text-decoration: underline; }
.rst-bulkbar .rst-dropdown, [rst-bulkbar] [rst-dropdown] { margin-inline-start: auto; }
.rst-bulkbar .rst-dropdown > summary, [rst-bulkbar] [rst-dropdown] > summary { background: var(--rst-surface); border: 1px solid var(--rst-line-strong); border-radius: 7px; font-size: var(--rst-fs-xs); font-weight: 550; padding: 0.22rem 0.6rem; }
-.rst-selbox, [rst-selbox] { align-items: center; display: flex; margin: 0; }
+/* The row checkbox's target is its LABEL, padded so the unchanged 16px
+ box sits in a 24×24 target (WCAG 2.5.8) on every screen. Inside a row
+ whose whole width is a link, WCAG's spacing exception cannot rescue a
+ 16px target, since the row's link covers the space around it, so
+ the padding applies on a desktop too, not only on touch. inline-flex
+ and justify-self: start keep the label its own size in a grid cell
+ and in a line, where display: flex made it as wide as either. A
+ larger invisible hit area was rejected: it would overlap the row's own overlay and take clicks meant for it. */
+.rst-selbox, [rst-selbox] { align-items: center; display: inline-flex; justify-self: start; margin: 0; padding: 4px; }
.rst-selbox input, [rst-selbox] input { accent-color: var(--rst-accent); cursor: pointer; height: 16px; margin: 0; width: 16px; }
.rst-selbox input:focus-visible, [rst-selbox] input:focus-visible { outline: 2px solid var(--rst-accent); outline-offset: 2px; }
@@ -1507,6 +1693,27 @@ dialog.rst-modal-panel, dialog[rst-modal-panel] { block-size: auto; color: inher
it names a run of rows without competing with them. */
.rst-combo__rows, [rst-combo-rows] { list-style: none; margin: 0; padding: 0; }
.rst-select__group, [rst-select-group] { color: var(--rst-text-faint); font-size: var(--rst-fs-xs); font-weight: 550; letter-spacing: 0.05em; padding-block: 0.4rem 0.2rem; padding-inline: 0.65rem; text-transform: uppercase; }
+/* The rest of the combobox's furniture. A divider the page drew between
+ two options (an <hr>, pinned rows above the rest) and the row saying
+ nothing matches are never stops: select.js leaves both out of the
+ keyboard order, and the divider hides while a search re-ranks. */
+.rst-combo__sep, [rst-combo-sep] { border-top: 1px solid var(--rst-line); list-style: none; margin-block: 0.25rem; }
+.rst-combo__empty, [rst-combo-empty] { color: var(--rst-text-faint); font-size: var(--rst-fs-sm); list-style: none; padding: 0.4rem 0.65rem; }
+/* An option with a description reads as one line: the name, then the
+ description quieter — the brackets the native option carries are for a
+ reader without JavaScript, and colour does that job here. */
+.rst-combo__desc, [rst-combo-desc] { color: var(--rst-text-faint); margin-inline-start: 0.5em; }
+/* A decorative glyph (a country's flag) before a row, and before the
+ closed box once that row is picked. aria-hidden everywhere; the text
+ still says which option it is. The box makes room only while there is
+ a glyph to show. */
+.rst-combo__option > .rst-combo__lead, [rst-combo-option] > [rst-combo-lead] { margin-inline-end: 0.5em; }
+.rst-combo > .rst-combo__lead, [rst-combo] > [rst-combo-lead] { align-items: center; display: flex; inset-block: 0; inset-inline-start: 0.65rem; pointer-events: none; position: absolute; }
+.rst-combo > .rst-combo__lead[hidden], [rst-combo] > [rst-combo-lead][hidden] { display: none; }
+.rst-combo.rst-combo__has-lead > .rst-input, [rst-combo][rst-combo-has-lead] > [rst-input] { padding-inline-start: 2.2rem; }
+/* The list opens upward when there is no room below it (select.js
+ measures the visual viewport, which a phone's keyboard shrinks). */
+.rst-combo.rst-combo__up > .rst-combo__list, [rst-combo][rst-combo-up] > [rst-combo-list] { bottom: 100%; margin-bottom: 4px; margin-top: 0; top: auto; }
/* The date fields' combobox, built by datetime.js on any input carrying
data-rst-date or data-rst-time. Same shape as .rst-combo above, plus
@@ -1526,19 +1733,19 @@ dialog.rst-modal-panel, dialog[rst-modal-panel] { block-size: auto; color: inher
against one font size. .rst-dtp__input's padding-inline-end is the
room this reserves inside the control, so text never runs under it,
with a value or without one. */
-.rst-dtp__pick, [rst-dtp-pick] { align-items: center; background: none; border: 0; border-radius: var(--rst-radius-sm); block-size: 1.75rem; color: var(--rst-muted); cursor: pointer; display: flex; inline-size: 1.75rem; inset-block: 0; inset-inline-end: 0.35rem; justify-content: center; margin-block: auto; padding: 0; position: absolute; }
+.rst-dtp__pick, [rst-dtp-pick] { align-items: center; background: none; border: 0; border-radius: var(--rst-radius-sm); block-size: 1.75rem; color: var(--rst-text-muted); cursor: pointer; display: flex; inline-size: 1.75rem; inset-block: 0; inset-inline-end: 0.35rem; justify-content: center; margin-block: auto; padding: 0; position: absolute; }
.rst-dtp__pick:hover, [rst-dtp-pick]:hover { color: var(--rst-text); }
.rst-dtp__pick svg, [rst-dtp-pick] svg { block-size: 1rem; inline-size: 1rem; }
.rst-dtp__list, [rst-dtp-list] { background: var(--rst-surface); border: 1px solid var(--rst-line); border-radius: 9px; box-shadow: var(--rst-shadow-pop); inset-inline: 0; list-style: none; margin-block-start: 4px; max-block-size: 17rem; overflow-y: auto; padding: 0.25rem; position: absolute; top: 100%; z-index: 40; }
.rst-dtp__row, [rst-dtp-row] { align-items: baseline; border-radius: var(--rst-radius-sm); color: inherit; cursor: pointer; display: flex; font-size: var(--rst-fs-sm); gap: 0.5rem; justify-content: space-between; padding: 0.4rem 0.65rem; }
.rst-dtp__row:hover, [rst-dtp-row]:hover, .rst-dtp__row.is-active, [rst-dtp-row].is-active { background: var(--rst-accent-soft); }
.rst-dtp__row--set .rst-dtp__label, [rst-dtp-row~="set"] [rst-dtp-label] { font-weight: 600; }
-.rst-dtp__quick .rst-dtp__set, [rst-dtp-quick] [rst-dtp-set] { color: var(--rst-muted); }
+.rst-dtp__quick .rst-dtp__set, [rst-dtp-quick] [rst-dtp-set] { color: var(--rst-text-muted); }
/* The committed value is marked in the accessibility tree by
aria-selected; this is the matching visual, never the only signal. */
.rst-dtp__row[aria-selected="true"] .rst-dtp__label, [rst-dtp-row][aria-selected="true"] [rst-dtp-label] { font-weight: 600; }
-.rst-dtp__set, [rst-dtp-set] { flex: none; font-size: var(--rst-fs-xs); color: var(--rst-muted); }
-.rst-dtp__hint, [rst-dtp-hint] { color: var(--rst-muted); font-size: var(--rst-fs-xs); padding: 0.35rem 0.65rem 0.15rem; }
+.rst-dtp__set, [rst-dtp-set] { flex: none; font-size: var(--rst-fs-xs); color: var(--rst-text-muted); }
+.rst-dtp__hint, [rst-dtp-hint] { color: var(--rst-text-muted); font-size: var(--rst-fs-xs); padding: 0.35rem 0.65rem 0.15rem; }
/* The popup opens above the field where there is no room below it —
datetime.js measures and sets .is-above, because a panel hanging off
@@ -1568,7 +1775,7 @@ dialog.rst-modal-panel, dialog[rst-modal-panel] { block-size: auto; color: inher
.rst-cal, [rst-cal] { background: var(--rst-surface); border: 1px solid var(--rst-line); border-radius: 9px; box-shadow: var(--rst-shadow-pop); inline-size: 18rem; inset-inline-start: 0; margin-block-start: 4px; padding: var(--rst-sp-2); position: absolute; top: 100%; z-index: 40; }
.rst-cal__head, [rst-cal-head] { align-items: center; display: flex; gap: var(--rst-sp-1); justify-content: space-between; padding-block-end: var(--rst-sp-2); }
.rst-cal__title, [rst-cal-title] { flex: 1; font-size: var(--rst-fs-sm); font-weight: 600; text-align: center; }
-.rst-cal__nav, [rst-cal-nav] { align-items: center; background: none; block-size: 1.75rem; border: 0; border-radius: var(--rst-radius-sm); color: var(--rst-muted); cursor: pointer; display: flex; inline-size: 1.75rem; justify-content: center; padding: 0; }
+.rst-cal__nav, [rst-cal-nav] { align-items: center; background: none; block-size: 1.75rem; border: 0; border-radius: var(--rst-radius-sm); color: var(--rst-text-muted); cursor: pointer; display: flex; inline-size: 1.75rem; justify-content: center; padding: 0; }
.rst-cal__nav:hover:not(:disabled), [rst-cal-nav]:hover:not(:disabled) { background: var(--rst-accent-soft); color: var(--rst-text); }
/* A month with nothing pickable in it — past the field's min, or past
its max. Dimmed and inert rather than removed, so the header keeps
@@ -1581,7 +1788,7 @@ dialog.rst-modal-panel, dialog[rst-modal-panel] { block-size: auto; color: inher
panel's own direction rather than a second rule. */
[rst-cal-nav~="next"] svg, .rst-cal__nav--next svg { transform: scaleX(-1); }
.rst-cal__grid, [rst-cal-grid] { border-collapse: collapse; inline-size: 100%; table-layout: fixed; }
-.rst-cal__grid th, [rst-cal-grid] th { color: var(--rst-muted); font-size: var(--rst-fs-xs); font-weight: 500; padding-block: var(--rst-sp-1); text-align: center; }
+.rst-cal__grid th, [rst-cal-grid] th { color: var(--rst-text-muted); font-size: var(--rst-fs-xs); font-weight: 500; padding-block: var(--rst-sp-1); text-align: center; }
.rst-cal__day, [rst-cal-day] { border-radius: var(--rst-radius-sm); cursor: pointer; font-size: var(--rst-fs-sm); font-variant-numeric: tabular-nums; line-height: 2.15; text-align: center; }
.rst-cal__day:hover, [rst-cal-day]:hover { background: var(--rst-accent-soft); }
/* The days either side of the month. Still pickable — reaching for the
@@ -1696,13 +1903,19 @@ dialog.rst-modal-panel, dialog[rst-modal-panel] { block-size: auto; color: inher
.rst-shell-topbar .rst-shell__tail > .rst-shell__nav a, [rst-shell-topbar] [rst-shell-tail] > [rst-shell-nav] a { padding-inline: 0; }
}
-/* sidebar shell — a rail beside the page. Below 800px the rail is
- hidden behind a <details> chrome strip and, opened, becomes an
- full-width column above the content: the open state is the
- native element's own, so the shell stays zero-JS like every idiom
- above it. The main column carries min-inline-size: 0 so a wide child
- (a list grid, a <pre>) scrolls itself instead of stretching the grid
- track. */
+/* sidebar shell — a rail beside the page. Below 800px the rail is not
+ shown on a content page and IS the page on the index (the index/back
+ block after the console's). The main column carries min-inline-size:
+ 0 so a wide child (a list grid, a <pre>) scrolls itself instead of
+ stretching the grid track.
+
+ LEGACY, kept for layouts written before the phone index: the
+ <details rst-shell-chrome> drawer, which opened the rail into the
+ page flow. Its rules are the ones naming rst-shell-chrome, below and
+ in the wide block. None of the index/back rules match an old
+ layout's root (it has no view), so an app that re-vendors tokens.css
+ but keeps its old layout still has its drawer. Delete these rules
+ together once no layout and no gallery page writes rst-shell-chrome. */
.rst-shell__main, [rst-shell-main] { min-inline-size: 0; }
/* box-sizing on the rail, per this file's convention (see .rst-input
above): set on the component that needs it, never in a `*` reset.
@@ -1715,8 +1928,11 @@ dialog.rst-modal-panel, dialog[rst-modal-panel] { block-size: auto; color: inher
was ever owed. Anything sized against the viewport has to be measured
the way the viewport measures it. */
.rst-shell-sidebar > .rst-shell__rail, [rst-shell-sidebar] > [rst-shell-rail] { background: var(--rst-surface); border-block-end: 1px solid var(--rst-line); box-sizing: border-box; display: none; flex-direction: column; gap: var(--rst-sp-3); padding: var(--rst-sp-4); }
+/* LEGACY: the old drawer, see above. */
.rst-shell-sidebar > .rst-shell__chrome > summary, [rst-shell-sidebar] > [rst-shell-chrome] > summary { align-items: center; border-block-end: 1px solid var(--rst-line); cursor: pointer; display: flex; font-size: var(--rst-fs-sm); font-weight: 550; gap: 0.3rem; list-style: none; padding-block: 0.55rem; padding-inline: var(--rst-sp-4); }
+/* LEGACY: the old drawer, see above. */
.rst-shell-sidebar > .rst-shell__chrome > summary::-webkit-details-marker, [rst-shell-sidebar] > [rst-shell-chrome] > summary::-webkit-details-marker { display: none; }
+/* LEGACY: the old drawer, see above. */
.rst-shell-sidebar > .rst-shell__chrome[open] + .rst-shell__rail, [rst-shell-sidebar] > [rst-shell-chrome][open] + [rst-shell-rail] { display: flex; }
.rst-shell__group, [rst-shell-group] { color: var(--rst-text-faint); font-size: var(--rst-fs-xs); font-weight: 550; letter-spacing: 0.05em; margin-block: var(--rst-sp-3) 0.2rem; text-transform: uppercase; }
.rst-shell-sidebar .rst-shell__nav, [rst-shell-sidebar] [rst-shell-nav] { flex-direction: column; gap: 2px; }
@@ -1728,21 +1944,20 @@ dialog.rst-modal-panel, dialog[rst-modal-panel] { block-size: auto; color: inher
whole mechanism: the rail is a column flex box, so the auto margin
eats whatever height is left between the nav and the bottom.
- Below 800px the disclosed rail is STILL a flex column — the media
- query below only adds block-size: 100dvh, it does not change the
- display — so the reason the foot stays put down there is not that the
- layout changed but that the column is content-height and the auto
- margin has no free space to distribute. That is a dependency, so name
- it: give the disclosed rail a min-block-size or a height and the foot
- floats back down to the bottom of it, silently. The 390px leg of
+ Below 800px the rail on the phone index is STILL a flex column, and
+ it has a min-block-size of the whole screen there, so this auto
+ margin would float the foot to the bottom of the screen, where the
+ language menu would open off it. The index rules replace it with a
+ fixed gap; the 390px leg of
TestTheSidebarRailPutsThePersonAtItsFootAndTheLanguageMenuOpensUpward
- asserts the rail's own height for exactly that reason. */
+ measures that gap for exactly that reason. */
.rst-shell__rail-foot, [rst-shell-rail-foot] { display: flex; flex-direction: column; gap: var(--rst-sp-3); margin-block-start: auto; }
/* An un-overridden shell renders the wrapper with nothing in it; draw
no gap for it, the same way .rst-shell__foot handles its own. */
.rst-shell__rail-foot:empty, [rst-shell-rail-foot]:empty { display: none; }
@media (min-width: 800px) {
.rst-shell-sidebar, [rst-shell-sidebar] { display: grid; grid-template-columns: 15rem 1fr; }
+ /* LEGACY: the old drawer, see above. */
.rst-shell-sidebar > .rst-shell__chrome, [rst-shell-sidebar] > [rst-shell-chrome] { display: none; }
.rst-shell-sidebar > .rst-shell__rail, [rst-shell-sidebar] > [rst-shell-rail] { block-size: 100dvh; border-block-end: 0; border-inline-end: 1px solid var(--rst-line); display: flex; inset-block-start: 0; overflow-y: auto; position: sticky; }
/* A menu at the foot of a full-height rail is a DROPUP: the same
@@ -1776,18 +1991,18 @@ dialog.rst-modal-panel, dialog[rst-modal-panel] { block-size: auto; color: inher
sidebar without the bar is sidebar; this is the admin shape both of
them were half of, and the one an app used to hand-write.
- TWO CHROMES, ONE CONTROL. Below 800px the bar's tail and the rail
- both have to go away, and two disclosures would be two things to
- learn. So one <details rst-shell-menu> gates both: its own next
- sibling with +, and the rail from the shell root with :has(). One
- [open], two reveals, one summary on screen at 390px.
-
- Both reveals are written as HIDE-WHEN-CLOSED rather than
- show-when-open. In an engine without :has() the rail's hide rule
- never matches and the rail renders as a column of links under the
- bar — longer page, navigation intact. Spelled the other way round,
- the same missing selector is a phone that cannot navigate. Choose
- the failure. */
+ TWO CHROMES, ONE PATTERN EACH. Below 800px the bar's tail (the account
+ and language menus) folds behind the one <details rst-shell-menu> and
+ opens as a card over the page (the card block after this one); the
+ rail is an index page, like the sidebar's (the index/back block after
+ that). The :has() pair below is LEGACY: the rail gated on the Menu's
+ [open], kept for console layouts written before the phone index,
+ whose root has no view.
+
+ The legacy gate is written HIDE-WHEN-CLOSED rather than
+ show-when-open: in an engine without :has() its hide rule never
+ matches and the rail renders as a column of links under the bar,
+ longer page, navigation intact. */
.rst-shell-console .rst-shell__bar, [rst-shell-console] [rst-shell-bar] { align-items: center; background: var(--rst-surface); border-block-end: 1px solid var(--rst-line); display: flex; flex-wrap: wrap; gap: var(--rst-sp-4); padding-block: 0.5rem; padding-inline: var(--rst-sp-4); }
.rst-shell-console .rst-shell__menu, [rst-shell-console] [rst-shell-menu] { margin-inline-start: auto; }
.rst-shell-console .rst-shell__menu > summary, [rst-shell-console] [rst-shell-menu] > summary { align-items: center; border: 1px solid var(--rst-line); border-radius: var(--rst-radius-sm); cursor: pointer; display: flex; font-size: var(--rst-fs-sm); font-weight: 550; gap: 0.35rem; list-style: none; min-block-size: 24px; padding-block: 0.3rem; padding-inline: 0.55rem; }
@@ -1803,9 +2018,12 @@ dialog.rst-modal-panel, dialog[rst-modal-panel] { block-size: auto; color: inher
.rst-shell-console .rst-shell__nav a, [rst-shell-console] [rst-shell-nav] a { border-block-end: 0; border-radius: var(--rst-radius-sm); display: block; padding-block: 0.35rem; padding-inline: 0.55rem; }
.rst-shell-console .rst-shell__nav a:hover, [rst-shell-console] [rst-shell-nav] a:hover { background: var(--rst-accent-soft); color: var(--rst-text); }
.rst-shell-console .rst-shell__nav a[aria-current], [rst-shell-console] [rst-shell-nav] a[aria-current] { background: var(--rst-accent-soft); color: var(--rst-accent); font-weight: 600; }
-/* The two hide rules. Same [open], two combinators. */
.rst-shell-console .rst-shell__menu:not([open]) + .rst-shell__tail, [rst-shell-console] [rst-shell-menu]:not([open]) + [rst-shell-tail] { display: none; }
-.rst-shell-console:has(.rst-shell__menu:not([open])) > .rst-shell__rail, [rst-shell-console]:has([rst-shell-menu]:not([open])) > [rst-shell-rail] { display: none; }
+/* LEGACY: the rail gated on the Menu's [open], for console layouts
+ written before the phone index. A root with a view is new markup,
+ where the rail follows the view and no :has() decides it; the :not()
+ keeps this rule out of that fight entirely. */
+.rst-shell-console:not(.rst-shell-console--page, .rst-shell-console--index):has(.rst-shell__menu:not([open])) > .rst-shell__rail, [rst-shell-console]:not([rst-shell-console~="page"], [rst-shell-console~="index"]):has([rst-shell-menu]:not([open])) > [rst-shell-rail] { display: none; }
/* Disclosed, the tail is a column across the full width of the wrapped
bar. The account's margin-inline-start: auto has to be undone by
hand and stretch does not do it: in a column flex box the inline
@@ -1850,8 +2068,8 @@ dialog.rst-modal-panel, dialog[rst-modal-panel] { block-size: auto; color: inher
one declaration the narrow hide rule contests — rides on :has().
That is the failure direction this shell chose, actually chosen
rather than nearly chosen. */
- .rst-shell-console:has(.rst-shell__menu:not([open])) > .rst-shell__rail,
- [rst-shell-console]:has([rst-shell-menu]:not([open])) > [rst-shell-rail] { display: flex; }
+ .rst-shell-console:not(.rst-shell-console--page, .rst-shell-console--index):has(.rst-shell__menu:not([open])) > .rst-shell__rail,
+ [rst-shell-console]:not([rst-shell-console~="page"], [rst-shell-console~="index"]):has([rst-shell-menu]:not([open])) > [rst-shell-rail] { display: flex; }
/* THE VIEWPORT. The rail itself is a grid item stretched to the row,
which can be taller than the window — that is what makes the
background reach the foot of a long page. So the thing that has to
@@ -1869,6 +2087,261 @@ dialog.rst-modal-panel, dialog[rst-modal-panel] { block-size: auto; color: inher
.rst-shell-console > .rst-shell__rail > .rst-shell__nav, [rst-shell-console] > [rst-shell-rail] > [rst-shell-nav] { box-sizing: border-box; inset-block-start: var(--rst-sp-4); max-block-size: calc(100dvh - 2 * var(--rst-sp-4)); overflow-y: auto; position: sticky; }
}
+/* The topbar's narrow menu is a floating card: it overlays the page and
+ never pushes it down. The open tail is placed against the bar, at its
+ inline end (logical insets, so an RTL page anchors it left), scrolling
+ itself if a long nav does not fit. The account and language menus
+ inside it expand in place: a popover inside a popover has nowhere good
+ to go on a phone. It is the menu panels' surface (9px, their border
+ and shadow), so every menu reads as one system. The current item is a
+ fill and weight, never a border or an inset bar.
+
+ EVERY rule here is scoped below 800px rather than written narrow and
+ undone at 800: the wide tail's display: contents removes the tail's
+ box and nothing else, so the account panel would stay static, the nav
+ would lose its underline, and the layer below would still swallow
+ clicks on a desktop someone widened with the menu open. These rules
+ come after both shells' narrow rules above and win at equal weight.
+
+ CLOSING ON AN OUTSIDE TAP, with and without scripts: while open, the
+ summary grows an invisible ::before over the viewport, under the card
+ and over the page. A tap anywhere outside the card lands on it, which
+ is a click on the summary, which closes the <details> natively, and
+ the tap never reaches the page. It is position: fixed inside the bar,
+ so it breaks if an app gives the bar or an ancestor a transform,
+ filter or backdrop-filter (each makes a containing block for fixed
+ descendants). While the card is open the bar's z-index 45 is above
+ the in-page panels (dropdown 30, row menu 40), so a menu left open
+ without scripts cannot paint over the card, and below the skip link
+ (60). Only while open: lifted for good, the bar painted over a modal's
+ scrim (z-index 10), undimmed and still tappable on a phone. Closed, it
+ is positioned at z-index auto, which the scrim covers. The card is
+ sized from the bar (100%), not the viewport, which counts a classic
+ scrollbar's gutter as room and overhung the start edge at 320px.
+ rastrillo.js adds Escape.
+
+ The console's bar folds the same way; its nav is in the rail, so its
+ card holds the account and language menus only. Only a console whose
+ root names its view: an old console layout gates its rail on the
+ same [open], and the tap-to-close layer would cover that rail, so a
+ tap on any rail link would close the navigation instead of following
+ it. An old layout keeps its push-down tail. */
+@media (max-width: 799.98px) {
+ .rst-shell-topbar .rst-shell__bar, [rst-shell-topbar] [rst-shell-bar], .rst-shell-console--page .rst-shell__bar, .rst-shell-console--index .rst-shell__bar, [rst-shell-console~="page"] [rst-shell-bar], [rst-shell-console~="index"] [rst-shell-bar] { position: relative; }
+ .rst-shell-topbar .rst-shell__bar:has(> .rst-shell__menu[open]), [rst-shell-topbar] [rst-shell-bar]:has(> [rst-shell-menu][open]), .rst-shell-console--page .rst-shell__bar:has(> .rst-shell__menu[open]), .rst-shell-console--index .rst-shell__bar:has(> .rst-shell__menu[open]), [rst-shell-console~="page"] [rst-shell-bar]:has(> [rst-shell-menu][open]), [rst-shell-console~="index"] [rst-shell-bar]:has(> [rst-shell-menu][open]) { z-index: 45; }
+ .rst-shell-topbar .rst-shell__menu[open] > summary, [rst-shell-topbar] [rst-shell-menu][open] > summary, .rst-shell-console--page .rst-shell__menu[open] > summary, .rst-shell-console--index .rst-shell__menu[open] > summary, [rst-shell-console~="page"] [rst-shell-menu][open] > summary, [rst-shell-console~="index"] [rst-shell-menu][open] > summary { background: var(--rst-accent-soft); border-color: var(--rst-accent); }
+ .rst-shell-topbar .rst-shell__menu[open] > summary::before, [rst-shell-topbar] [rst-shell-menu][open] > summary::before, .rst-shell-console--page .rst-shell__menu[open] > summary::before, .rst-shell-console--index .rst-shell__menu[open] > summary::before, [rst-shell-console~="page"] [rst-shell-menu][open] > summary::before, [rst-shell-console~="index"] [rst-shell-menu][open] > summary::before { content: ""; cursor: default; inset: 0; position: fixed; z-index: 1; }
+ .rst-shell-topbar .rst-shell__menu[open] + .rst-shell__tail, [rst-shell-topbar] [rst-shell-menu][open] + [rst-shell-tail], .rst-shell-console--page .rst-shell__menu[open] + .rst-shell__tail, .rst-shell-console--index .rst-shell__menu[open] + .rst-shell__tail, [rst-shell-console~="page"] [rst-shell-menu][open] + [rst-shell-tail], [rst-shell-console~="index"] [rst-shell-menu][open] + [rst-shell-tail] { animation: rst-shell-drop 0.14s ease-out; background: var(--rst-surface); border: 1px solid var(--rst-line); border-radius: 9px; box-shadow: var(--rst-shadow-pop); box-sizing: border-box; flex-basis: auto; gap: 0; inline-size: min(20rem, calc(100% - 1.5rem)); inset-block-start: calc(100% + 6px); inset-inline-end: 0.75rem; max-block-size: calc(100dvh - 5rem); overflow-y: auto; overscroll-behavior: contain; padding: 0.35rem; position: absolute; z-index: 2; }
+ .rst-shell-topbar .rst-shell__tail > .rst-shell__nav, [rst-shell-topbar] [rst-shell-tail] > [rst-shell-nav] { align-items: stretch; gap: 0; }
+ .rst-shell-topbar .rst-shell__tail > .rst-shell__nav a, [rst-shell-topbar] [rst-shell-tail] > [rst-shell-nav] a { border-block-end: 0; border-radius: var(--rst-radius-sm); color: var(--rst-text); padding-block: 0.55rem; padding-inline: 0.75rem; }
+ .rst-shell-topbar .rst-shell__tail > .rst-shell__nav a[aria-current], [rst-shell-topbar] [rst-shell-tail] > [rst-shell-nav] a[aria-current] { background: var(--rst-accent-soft); box-shadow: none; color: var(--rst-accent); font-weight: 600; }
+ /* Topbar only: the rule separates the account from the nav above it,
+ and in the console's card the account is first, under nothing. */
+ .rst-shell-topbar .rst-shell__tail > .rst-shell__account, [rst-shell-topbar] [rst-shell-tail] > [rst-shell-account] { border-block-start: 1px solid var(--rst-line); margin-block-start: 0.35rem; padding-block-start: 0.35rem; }
+ .rst-shell-topbar .rst-shell__tail > .rst-shell__account > summary, [rst-shell-topbar] [rst-shell-tail] > [rst-shell-account] > summary, .rst-shell-console--page .rst-shell__tail > .rst-shell__account > summary, .rst-shell-console--index .rst-shell__tail > .rst-shell__account > summary, [rst-shell-console~="page"] [rst-shell-tail] > [rst-shell-account] > summary, [rst-shell-console~="index"] [rst-shell-tail] > [rst-shell-account] > summary { justify-content: space-between; padding-inline: 0.75rem; }
+ .rst-shell-topbar .rst-shell__tail .rst-dropdown__menu, [rst-shell-topbar] [rst-shell-tail] [rst-dropdown-menu], .rst-shell-console--page .rst-shell__tail .rst-dropdown__menu, .rst-shell-console--index .rst-shell__tail .rst-dropdown__menu, [rst-shell-console~="page"] [rst-shell-tail] [rst-dropdown-menu], [rst-shell-console~="index"] [rst-shell-tail] [rst-dropdown-menu] { border: 0; box-shadow: none; inset: auto; margin: 0; max-block-size: none; min-width: 0; padding-block: 0; padding-inline: 0.5rem 0; position: static; }
+}
+@keyframes rst-shell-drop { from { opacity: 0; transform: translateY(-4px); } }
+@media (prefers-reduced-motion: reduce) {
+ .rst-shell-topbar .rst-shell__menu[open] + .rst-shell__tail, [rst-shell-topbar] [rst-shell-menu][open] + [rst-shell-tail], .rst-shell-console--page .rst-shell__menu[open] + .rst-shell__tail, .rst-shell-console--index .rst-shell__menu[open] + .rst-shell__tail, [rst-shell-console~="page"] [rst-shell-menu][open] + [rst-shell-tail], [rst-shell-console~="index"] [rst-shell-menu][open] + [rst-shell-tail] { animation: none; }
+}
+
+/* ── Sidebar and console on a phone: the index and the back control ──
+ Below 800px these two shells are two views of every URL, and the page
+ says which with the view block the layout writes into the root:
+ [rst-shell-sidebar~="index"] is the rail as a full-screen page of
+ sections at a real URL; [rst-shell-sidebar~="page"] is the content,
+ with a back control at the top inline-start whose href is the page's
+ up block. No hamburger. The console is the same for its rail, under
+ its bar.
+
+ An old layout's root has no view, so none of these rules match it and
+ it keeps its drawer (the legacy rules above). Scoped below 800px, like
+ the card: at 800 and up both views are the rail beside the page, the
+ back strip and the title are hidden by the rule just below, and
+ nothing here needs undoing. ─────────────────────────────────────── */
+.rst-shell__back, [rst-shell-back], .rst-shell__title, [rst-shell-title] { display: none; }
+@media (max-width: 799.98px) {
+ /* The content view: a sticky strip of its own at the top, first in
+ the reading order after the skip link. Its z-index 9 sits above the
+ sticky form bar (5), so a long form scrolled under the strip does
+ not paint over Back, and below a modal's scrim (10): above it, Back
+ stayed undimmed and live under an open modal on a phone. */
+ .rst-shell-sidebar--page > .rst-shell__back, [rst-shell-sidebar~="page"] > [rst-shell-back], .rst-shell-console--page > .rst-shell__back, [rst-shell-console~="page"] > [rst-shell-back] { align-items: center; background: var(--rst-surface); border-block-end: 1px solid var(--rst-line); display: flex; inset-block-start: 0; min-block-size: 2.75rem; padding-inline: var(--rst-sp-1); position: sticky; z-index: 9; }
+ /* The strip is sticky, not in the flow a plain scroll-to-fragment
+ measures against, so a #fragment link's native landing scroll parks
+ the target's top edge at the root's scroll origin — under the
+ strip, the one thing on a content page actually sitting there.
+ Reserving the strip's own height, border included, as scroll
+ padding is what makes that landing spot the strip's bottom edge
+ instead. */
+ :root:has(.rst-shell-sidebar--page), :root:has([rst-shell-sidebar~="page"]), :root:has(.rst-shell-console--page), :root:has([rst-shell-console~="page"]) { scroll-padding-block-start: calc(2.75rem + 1px); }
+ .rst-shell__back a, [rst-shell-back] a { align-items: center; border-radius: var(--rst-radius-sm); color: var(--rst-accent); display: inline-flex; font-size: 1rem; font-weight: 500; gap: 0.5rem; min-block-size: 2.75rem; padding-inline: 0.75rem; text-decoration: none; }
+ /* The chevron is drawn, not an icon: there is no left chevron in the
+ set and no icon mirroring, and a drawn one stays out of the
+ accessible name. Two borders of a small square, turned; physical on
+ purpose, like the switch's knob, because it is a drawing, and
+ mirrored by the rule after it. */
+ .rst-shell__back a::before, [rst-shell-back] a::before { block-size: 0.55rem; border-left: 2px solid currentColor; border-top: 2px solid currentColor; content: ""; flex: none; inline-size: 0.55rem; transform: rotate(-45deg); }
+ [dir="rtl"] .rst-shell__back a::before, [dir="rtl"] [rst-shell-back] a::before { transform: rotate(135deg); }
+ .rst-shell__back a:focus-visible, [rst-shell-back] a:focus-visible { outline: 2px solid var(--rst-accent); outline-offset: -2px; }
+ .rst-shell-console--page > .rst-shell__rail, [rst-shell-console~="page"] > [rst-shell-rail] { display: none; }
+
+ /* The index view: the rail is the page. main and the skip link go with
+ it, so the skip link never points at nothing; the sidebar's brand,
+ which would link to this very page, gives way to the title, the one
+ h1 here. On the index a page has no main landmark, which is the
+ point: the page is navigation. */
+ .rst-shell-sidebar--index > .rst-shell__main, [rst-shell-sidebar~="index"] > [rst-shell-main], .rst-shell-sidebar--index > .rst-skip, [rst-shell-sidebar~="index"] > [rst-skip], .rst-shell-console--index > .rst-shell__main, [rst-shell-console~="index"] > [rst-shell-main], .rst-shell-console--index > .rst-skip, [rst-shell-console~="index"] > [rst-skip], .rst-shell-sidebar--index .rst-shell__brand, [rst-shell-sidebar~="index"] [rst-shell-brand] { display: none; }
+ .rst-shell-sidebar--index > .rst-shell__rail, [rst-shell-sidebar~="index"] > [rst-shell-rail] { background: var(--rst-bg); border-block-end: 0; display: flex; gap: 0; min-block-size: 100dvh; padding: var(--rst-sp-5) var(--rst-sp-4); }
+ /* The console's index is the bar, then the rail, then the foot. The
+ three share the window: the root is a column at least the window
+ tall and the rail takes what the bar and the foot leave, so a short
+ nav does not scroll at all. A rail a whole window tall made every
+ index scroll by the bar's and the foot's height, however short its
+ nav; a long nav still grows the rail and the page scrolls. The
+ console rail is already border-box. */
+ .rst-shell-console--index, [rst-shell-console~="index"] { display: flex; flex-direction: column; min-block-size: 100dvh; }
+ .rst-shell-console--index > .rst-shell__rail, [rst-shell-console~="index"] > [rst-shell-rail] { background: var(--rst-bg); border-block-end: 0; display: flex; flex: 1 0 auto; gap: 0; padding: var(--rst-sp-5) var(--rst-sp-4); }
+ .rst-shell-sidebar--index .rst-shell__title, [rst-shell-sidebar~="index"] [rst-shell-title], .rst-shell-console--index .rst-shell__title, [rst-shell-console~="index"] [rst-shell-title] { display: block; font-size: 1.75rem; font-weight: 700; letter-spacing: -0.02em; line-height: 1.2; margin: 0 0 var(--rst-sp-2); padding-inline: 0.25rem; }
+ /* The rows: every link a full-width row, at least 3rem, 1rem text, a
+ chevron at the end. The grouping is the markup apps already write,
+ a group label and then its links: a run of rows is rounded at its
+ first link (the nav's first, or the one after a label) and its last
+ (the nav's last, or the one before a label). The :has() half is a
+ rule of its own, so an engine without :has() drops only it and a
+ run's last corner is square, which is cosmetic. aria-current is not
+ marked here: the index is its own page, where "current" is not
+ true. */
+ .rst-shell-sidebar--index .rst-shell__nav, [rst-shell-sidebar~="index"] [rst-shell-nav], .rst-shell-console--index .rst-shell__nav, [rst-shell-console~="index"] [rst-shell-nav] { gap: 0; }
+ .rst-shell-sidebar--index .rst-shell__nav > a, [rst-shell-sidebar~="index"] [rst-shell-nav] > a, .rst-shell-sidebar--index .rst-shell__nav > a[aria-current], [rst-shell-sidebar~="index"] [rst-shell-nav] > a[aria-current], .rst-shell-console--index .rst-shell__nav > a, [rst-shell-console~="index"] [rst-shell-nav] > a, .rst-shell-console--index .rst-shell__nav > a[aria-current], [rst-shell-console~="index"] [rst-shell-nav] > a[aria-current] { align-items: center; background: var(--rst-surface); border: 1px solid var(--rst-line); border-block-start-width: 0; border-radius: 0; box-sizing: border-box; color: var(--rst-text); display: flex; font-size: 1rem; font-weight: 450; gap: var(--rst-sp-3); min-block-size: 3rem; padding-block: 0; padding-inline: 1rem 0.75rem; }
+ .rst-shell-sidebar--index .rst-shell__nav > a:first-child, [rst-shell-sidebar~="index"] [rst-shell-nav] > a:first-child, .rst-shell-sidebar--index .rst-shell__nav > .rst-shell__group + a, [rst-shell-sidebar~="index"] [rst-shell-nav] > [rst-shell-group] + a, .rst-shell-console--index .rst-shell__nav > a:first-child, [rst-shell-console~="index"] [rst-shell-nav] > a:first-child, .rst-shell-console--index .rst-shell__nav > .rst-shell__group + a, [rst-shell-console~="index"] [rst-shell-nav] > [rst-shell-group] + a { border-block-start-width: 1px; border-start-end-radius: var(--rst-radius); border-start-start-radius: var(--rst-radius); }
+ .rst-shell-sidebar--index .rst-shell__nav > a:last-child, [rst-shell-sidebar~="index"] [rst-shell-nav] > a:last-child, .rst-shell-console--index .rst-shell__nav > a:last-child, [rst-shell-console~="index"] [rst-shell-nav] > a:last-child { border-end-end-radius: var(--rst-radius); border-end-start-radius: var(--rst-radius); }
+ .rst-shell-sidebar--index .rst-shell__nav > a:has(+ .rst-shell__group), [rst-shell-sidebar~="index"] [rst-shell-nav] > a:has(+ [rst-shell-group]), .rst-shell-console--index .rst-shell__nav > a:has(+ .rst-shell__group), [rst-shell-console~="index"] [rst-shell-nav] > a:has(+ [rst-shell-group]) { border-end-end-radius: var(--rst-radius); border-end-start-radius: var(--rst-radius); }
+ .rst-shell-sidebar--index .rst-shell__nav > a::after, [rst-shell-sidebar~="index"] [rst-shell-nav] > a::after, .rst-shell-console--index .rst-shell__nav > a::after, [rst-shell-console~="index"] [rst-shell-nav] > a::after { block-size: 0.5rem; border-right: 2px solid var(--rst-text-faint); border-top: 2px solid var(--rst-text-faint); content: ""; flex: none; inline-size: 0.5rem; margin-inline-start: auto; transform: rotate(45deg); }
+ [dir="rtl"] .rst-shell-sidebar--index .rst-shell__nav > a::after, [dir="rtl"] [rst-shell-sidebar~="index"] [rst-shell-nav] > a::after, [dir="rtl"] .rst-shell-console--index .rst-shell__nav > a::after, [dir="rtl"] [rst-shell-console~="index"] [rst-shell-nav] > a::after { transform: rotate(-135deg); }
+ .rst-shell-sidebar--index .rst-shell__nav > a:hover, [rst-shell-sidebar~="index"] [rst-shell-nav] > a:hover, .rst-shell-sidebar--index .rst-shell__nav > a:active, [rst-shell-sidebar~="index"] [rst-shell-nav] > a:active, .rst-shell-console--index .rst-shell__nav > a:hover, [rst-shell-console~="index"] [rst-shell-nav] > a:hover, .rst-shell-console--index .rst-shell__nav > a:active, [rst-shell-console~="index"] [rst-shell-nav] > a:active { background: var(--rst-accent-soft); }
+ .rst-shell-sidebar--index .rst-shell__nav > a:focus-visible, [rst-shell-sidebar~="index"] [rst-shell-nav] > a:focus-visible, .rst-shell-console--index .rst-shell__nav > a:focus-visible, [rst-shell-console~="index"] [rst-shell-nav] > a:focus-visible { outline: 2px solid var(--rst-accent); outline-offset: -2px; }
+ /* "You came from here": the back link points at #nav-<section>, and
+ that row flashes once as the index comes back. */
+ .rst-shell-sidebar--index .rst-shell__nav > a:target, [rst-shell-sidebar~="index"] [rst-shell-nav] > a:target, .rst-shell-console--index .rst-shell__nav > a:target, [rst-shell-console~="index"] [rst-shell-nav] > a:target { animation: rst-shell-flash 1.4s ease-out; }
+ .rst-shell-sidebar--index .rst-shell__group, [rst-shell-sidebar~="index"] [rst-shell-group], .rst-shell-console--index .rst-shell__group, [rst-shell-console~="index"] [rst-shell-group] { margin-block: var(--rst-sp-5) var(--rst-sp-2); padding-inline: 1rem; }
+ /* The foot FOLLOWS the nav on the index, by a fixed gap, never auto:
+ the rail has a min-height here, and an auto margin would float the
+ foot to the bottom of the screen, where the language menu would open
+ off it (the rail-foot comment above has the long version). */
+ .rst-shell-sidebar--index .rst-shell__rail-foot, [rst-shell-sidebar~="index"] [rst-shell-rail-foot] { margin-block-start: var(--rst-sp-6); }
+}
+@keyframes rst-shell-flash { from { background: var(--rst-accent-soft); } }
+@media (prefers-reduced-motion: reduce) {
+ .rst-shell-sidebar--index .rst-shell__nav > a:target, [rst-shell-sidebar~="index"] [rst-shell-nav] > a:target, .rst-shell-console--index .rst-shell__nav > a:target, [rst-shell-console~="index"] [rst-shell-nav] > a:target { animation: none; }
+}
+
+/* ── rst-signin: the shipped sign-in card (partials/signin.html) ─────
+ Brand at the start and the way in at the end, side by side from 48rem
+ and stacked below it, so a phone reads the brand and then the door.
+ The card paints its own surface and border because it usually sits
+ on the stage shell's backdrop: whatever picture an app puts there,
+ the controls stay on a known ground and their contrast is the
+ theme's, not the picture's. The border is --rst-line-strong because
+ it is the card's only edge against that picture, and
+ ui/contrast_test.go holds it to 3:1 against the default art.
+ overflow-wrap on the door is for the one long string it shows back —
+ an address, which has no spaces to break at and would otherwise push
+ a 320px screen sideways. */
+.rst-signin, [rst-signin] { background: var(--rst-surface); border: 1px solid var(--rst-line-strong); border-radius: var(--rst-radius); box-shadow: var(--rst-shadow-pop); box-sizing: border-box; display: grid; gap: var(--rst-sp-5); inline-size: 100%; max-inline-size: 56rem; padding: var(--rst-sp-5); }
+@media (min-width: 48rem) {
+ .rst-signin, [rst-signin] { align-items: center; gap: var(--rst-sp-6); grid-template-columns: minmax(0, 1fr) minmax(0, 1.25fr); padding: var(--rst-sp-6); }
+}
+/* The brand column reads mark, name, pitch. The pitch is the line the
+ column is for, so it is the largest type on the card, bigger than the
+ door's heading; the name above it is a label. A brand with no pitch
+ still has its name at label size: the door's heading already says
+ "Sign in to" that name. The mark takes the accent through
+ currentColor, so an inline SVG drawn in currentColor follows the
+ theme without colours of its own. */
+.rst-signin__brand, [rst-signin-brand] { display: flex; flex-direction: column; gap: var(--rst-sp-3); min-inline-size: 0; }
+.rst-signin__mark, [rst-signin-mark] { block-size: 3rem; color: var(--rst-accent); inline-size: 3rem; }
+.rst-signin__mark > *, [rst-signin-mark] > * { block-size: 100%; display: block; inline-size: 100%; }
+.rst-signin__name, [rst-signin-name] { color: var(--rst-text-muted); font-size: var(--rst-fs-lg); font-weight: 600; margin: 0; }
+.rst-signin__pitch, [rst-signin-pitch] { color: var(--rst-text); font-size: clamp(1.5rem, 1.2rem + 1vw, 2rem); font-weight: 650; letter-spacing: -0.02em; line-height: 1.2; margin: 0; text-wrap: balance; }
+.rst-signin__door, [rst-signin-door] { display: flex; flex-direction: column; gap: var(--rst-sp-4); min-inline-size: 0; overflow-wrap: anywhere; }
+.rst-signin__door h1, [rst-signin-door] h1 { color: var(--rst-text); font-size: 1.375rem; font-weight: 600; letter-spacing: -0.015em; line-height: 1.25; margin: 0; }
+.rst-signin__door > p, [rst-signin-door] > p { margin: 0; }
+/* The door spaces its children with gap; a callout's and a field's own
+ block margins would double it. */
+.rst-signin__door > .rst-callout, [rst-signin-door] > [rst-callout] { margin: 0; }
+.rst-signin__form, [rst-signin-form] { display: flex; flex-direction: column; gap: var(--rst-sp-3); margin: 0; }
+.rst-signin__form > .rst-field, [rst-signin-form] > [rst-field] { margin: 0; }
+.rst-signin__remembered, [rst-signin-remembered] { color: var(--rst-text-muted); margin: 0; }
+/* The passkey button and its message are one child of the door. The
+ message is a live region and must stay rendered, so it is never
+ display: none; it is only spaced from the button once it has
+ something to say. While the button is hidden — before the door's
+ script has found a working WebAuthn, or on a browser without one —
+ the whole pair is, so the door keeps no gap for a control nobody can
+ see; the region is back in the page before the button can be
+ pressed, which is all an announcement needs. The "or" between the
+ pair and the email form goes with them: beside a hidden button the
+ page has one way in, and an "or" would point at nothing. It is the
+ wrapper's immediate neighbour, before it on Ask and after it on a
+ remembered passkey, so one sibling selector each way reaches it. */
+.rst-signin__passkey, [rst-signin-passkey] { display: flex; flex-direction: column; }
+.rst-signin__passkey:has(> .rst-btn[hidden]), [rst-signin-passkey]:has(> [rst-btn][hidden]), .rst-signin__passkey:has(> .rst-btn[hidden]) + .rst-signin__or, [rst-signin-passkey]:has(> [rst-btn][hidden]) + [rst-signin-or], .rst-signin__or:has(+ .rst-signin__passkey > .rst-btn[hidden]), [rst-signin-or]:has(+ [rst-signin-passkey] > [rst-btn][hidden]) { display: none; }
+.rst-signin__passkey-msg, [rst-signin-passkey-msg] { color: var(--rst-text-muted); margin: 0; }
+.rst-signin__passkey-msg:not(:empty), [rst-signin-passkey-msg]:not(:empty) { margin-block-start: var(--rst-sp-2); }
+/* The word sits on a hairline drawn by its own ::before and ::after,
+ which carry no text, so a screen reader hears "or" in reading order
+ between the two ways in and nothing else. */
+.rst-signin__or, [rst-signin-or] { align-items: center; color: var(--rst-text-muted); display: flex; font-size: var(--rst-fs-sm); gap: var(--rst-sp-3); }
+.rst-signin__or::before, .rst-signin__or::after, [rst-signin-or]::before, [rst-signin-or]::after { border-block-start: 1px solid var(--rst-line); content: ""; flex: 1; }
+/* Provider buttons an app adds itself (Google, Apple, …), one form per
+ provider because each posts to its own route. Sibling forms carry no
+ spacing of their own, so without a wrapper the buttons touch and read
+ as one control split in half; the gap is the card form's, so the two
+ stacks read alike. The 44rem cap is rst-form's measure: stretched
+ past it a block button is a bar with a word in the middle. The forms'
+ margins are zeroed because an app's own `form { margin-bottom }` would
+ be added to the gap, the double spacing rst-form's rule exists to
+ stop. */
+.rst-signin__providers, [rst-signin-providers] { display: flex; flex-direction: column; gap: var(--rst-sp-3); max-inline-size: 44rem; }
+.rst-signin__providers > form, [rst-signin-providers] > form { margin: 0; }
+
+/* ── rst-stage: the stage shell (layouts/stage.html) ─────────────────
+ One card centred on a full-page backdrop. [rst-stage] is <body> in
+ the shell and a plain wrapper in the design-system gallery's frames,
+ so nothing here assumes which element carries it. The scene is
+ absolutely positioned inside it rather than fixed, so a page taller
+ than the window scrolls the picture with it instead of sliding the
+ card over a still image. The page column stacks its children and
+ centres each one, rather than laying them in a row: the card is
+ usually alone, but a line of text above or below it must not end up
+ beside it. inline-size: 100% because [rst-page]'s auto margins make
+ a grid item shrink to its content, and a card sized in percent would
+ shrink with it. */
+.rst-stage, [rst-stage] { box-sizing: border-box; display: grid; grid-template-rows: 1fr auto; isolation: isolate; min-block-size: 100dvh; position: relative; }
+.rst-stage > .rst-page, [rst-stage] > [rst-page] { align-items: center; align-self: center; display: flex; flex-direction: column; inline-size: 100%; position: relative; z-index: 1; }
+.rst-stage__scene, [rst-stage-scene] { inset: 0; overflow: hidden; pointer-events: none; position: absolute; z-index: 0; }
+.rst-stage__scene > *, [rst-stage-scene] > * { block-size: 100%; display: block; inline-size: 100%; object-fit: cover; }
+.rst-stage__foot, [rst-stage-foot] { color: var(--rst-text-muted); padding: var(--rst-sp-4); position: relative; text-align: center; z-index: 1; }
+/* The art's two colours: the line colour and a little accent, each
+ mixed into the page background, faint enough that the sign-in card's
+ border still clears 3:1 against them in every theme and scheme —
+ ui/contrast_test.go reads these two color-mix() calls and checks
+ exactly that. Lower the percentages rather than loosen the test.
+ non-scaling-stroke keeps the lines fine on a wide screen, where the
+ viewBox is scaled up to cover it. */
+.rst-stage-art__lines, [rst-stage-art-lines] { fill: none; stroke: color-mix(in srgb, var(--rst-line) 70%, var(--rst-bg)); stroke-width: 1.5; vector-effect: non-scaling-stroke; }
+.rst-stage-art__glow, [rst-stage-art-glow] { fill: color-mix(in srgb, var(--rst-accent) 8%, var(--rst-bg)); fill-opacity: 0.2; }
+/* Forced colours would paint the lines in the system's text colour at
+ full strength, and a printout does not need a picture of waves. */
+@media (forced-colors: active) {
+ .rst-stage-art, [rst-stage-art] { display: none; }
+}
+@media print {
+ .rst-stage-art, [rst-stage-art] { display: none; }
+}
+
/* error page — one centred column, whatever shell it lands in: the
status as a quiet label above the sentence, one or two ways out, and
the reference last, where an operator asked to quote it will find it.
@@ -1885,14 +2358,27 @@ dialog.rst-modal-panel, dialog[rst-modal-panel] { block-size: auto; color: inher
Touch: the two zooms nobody asked for.
Mobile Safari zooms the whole page when it focuses a form control
- whose text is under 16px, and this file's base type is 14px on
- purpose. So every text-entry control comes back up to 16px where the
- pointer is coarse, and stays 14px under a mouse. max() rather than a
- flat 1rem, so a control deliberately set larger keeps its size; rem
- rather than px, so a reader who raised the browser default keeps the
- increase. This is the fix. The other one — user-scalable=no in the
- viewport tag — buys the same quiet by taking pinch-zoom away from
- everyone, which is a WCAG 1.4.4 failure and not on offer here.
+ whose text is under 16px. The touch step at the top of this file
+ makes body text 16px, but controls inherit their size and some sit in
+ a smaller parent, so every text-entry control still has a floor. It
+ is written twice, because one spelling cannot do both jobs:
+
+ - the component floors (.rst-input, .rst-textarea, the search input)
+ sit directly after the rule that resets each one's font, at that
+ rule's weight, and before any rule that sizes the field on purpose.
+ max(1rem, 1em) is the floor; 1em in font-size is the PARENT's size,
+ so a floor that came after the primary field's rule would shrink
+ it. That was a bug here once: a single floor sat
+ here, after [rst-input~="primary"] at equal weight, and turned the
+ 17px primary field into 16px.
+ - the floor below, for an app's own input with no rst- attribute,
+ is wholly inside :where(): the UA stylesheet is what sizes such a
+ field, any author rule beats it, and any rule the app writes to size
+ its own field still wins.
+
+ rem rather than px so a reader who raised the browser default keeps
+ the increase. user-scalable=no in the viewport tag would buy the same
+ quiet by taking pinch-zoom from everyone, which fails WCAG 1.4.4.
Double-tap is the second zoom: tap a button or a row twice in quick
succession and the second tap lands as a zoom gesture rather than a
@@ -1902,11 +2388,8 @@ dialog.rst-modal-panel, dialog[rst-modal-panel] { block-size: auto; color: inher
narrow column. :where() weighs nothing, so any rule can override it.
--------------------------------------------------------------------- */
-@media (pointer: coarse) {
- input, select, textarea,
- .rst-input, [rst-input],
- .rst-textarea, [rst-textarea],
- .rst-search input[type="search"], [rst-search] input[type="search"] {
+@media (pointer: coarse), (max-width: 40rem) {
+ :where(input:not([type=checkbox]):not([type=radio]):not([type=range]), select, textarea) {
font-size: max(1rem, 1em);
}
}
@@ -1915,6 +2398,111 @@ dialog.rst-modal-panel, dialog[rst-modal-panel] { block-size: auto; color: inher
touch-action: manipulation;
}
+/* ── Tap targets on small or touch screens ──────────────────────────
+ Every interactive idiom is at least --rst-tap (44px) on both axes,
+ measured as the box a tap ACTIVATES: the label of a label-backed
+ control, the row of a stretched link. Anything that is display:
+ inline today becomes inline-flex or flex here, because min sizes do
+ nothing on an inline box. Each selector repeats the weight of the
+ rule it raises: a floor lighter than the size it raises loses
+ wherever it sits (the topbar's Menu summary is (0,2,1)).
+
+ The block is last before the utilities so it wins ties with every
+ component above it. ui/sizing_test.go fails when a rule styling an
+ a, button, summary or label arrives without a row in the inventory
+ this block implements, and ui/sizing_browser_test.go measures every
+ control at 390 and 1024 with a coarse pointer and at 600 with a
+ mouse. ─────────────────────────────────────────────────────────── */
+@media (pointer: coarse), (max-width: 40rem) {
+ :root { --rst-col-menu: var(--rst-tap); }
+
+ .rst-btn, [rst-btn] { justify-content: center; min-block-size: var(--rst-tap); min-inline-size: var(--rst-tap); }
+ /* min-inline-size for a field in a flex row (a bulk bar), which
+ shrinks it past the floor; a stretched field is far wider anyway. */
+ .rst-input, [rst-input], .rst-textarea, [rst-textarea] { min-block-size: var(--rst-tap); min-inline-size: var(--rst-tap); }
+ /* The box is the target, and so is the input inside it: a tap focuses
+ the input, so the input stretches to the box's full height. */
+ .rst-search, [rst-search] { min-block-size: var(--rst-tap); padding-block: 0; }
+ .rst-search input[type="search"], [rst-search] input[type="search"] { align-self: stretch; }
+ .rst-search__clear, [rst-search-clear] { min-block-size: var(--rst-tap); min-inline-size: var(--rst-tap); }
+ .rst-ftok a, [rst-ftok] a { block-size: var(--rst-tap); inline-size: var(--rst-tap); }
+ .rst-help, [rst-help] { block-size: var(--rst-tap); inline-size: var(--rst-tap); }
+
+ .rst-dropdown > summary, [rst-dropdown] > summary { align-items: center; min-block-size: var(--rst-tap); min-inline-size: var(--rst-tap); }
+ .rst-dropdown__menu .rst-menu-group > summary, [rst-dropdown-menu] [rst-menu-group] > summary { align-items: center; display: flex; min-block-size: var(--rst-tap); }
+ .rst-dropdown__menu a, [rst-dropdown-menu] a, .rst-dropdown__menu button, [rst-dropdown-menu] button, .rst-row-menu__panel a, [rst-row-menu-panel] a, .rst-row-menu__panel button, [rst-row-menu-panel] button, .rst-locale button, [rst-locale] button { align-items: center; box-sizing: border-box; display: flex; min-block-size: var(--rst-tap); }
+ /* The menu cap's 8rem floor exists so a three-item menu opened in a
+ short frame shows whole; three 44px rows are taller than 8rem, so
+ here the floor is three taps plus the panel's padding and border
+ (ui's TestAMenuOpenedInsideAShortFrameIsStillUsable). border-box
+ above for the same reason: the dropdown's links were content-box,
+ and their padding made each row 57px instead of 44. */
+ .rst-dropdown__menu, [rst-dropdown-menu], .rst-row-menu__panel, [rst-row-menu-panel] { max-block-size: min(20rem, max(calc(100dvh - 6rem), calc(3 * var(--rst-tap) + 1rem))); }
+ .rst-combo__option, [rst-combo-option], .rst-dtp__row, [rst-dtp-row] { align-items: center; box-sizing: border-box; display: flex; min-block-size: var(--rst-tap); }
+
+ /* The date field: a 44px pick button, and room in the input so typed
+ text never runs under it. */
+ .rst-dtp__input, [rst-dtp-input] { padding-inline-end: 3rem; }
+ .rst-dtp__pick, [rst-dtp-pick] { block-size: var(--rst-tap); inline-size: var(--rst-tap); inset-inline-end: 0; }
+ .rst-cal__nav, [rst-cal-nav] { block-size: var(--rst-tap); inline-size: var(--rst-tap); }
+ .rst-cal__day, [rst-cal-day] { block-size: var(--rst-tap); }
+ /* The calendar docks to the bottom of the viewport on every small or
+ touch screen. Anchored, it is placed from the field's inline start,
+ and a date field can sit at the trailing end of a field row at any
+ width; docked, it is always inside the viewport. The grid asks for
+ seven taps and the panel is fit-content around it, with the
+ scrollbar gutter reserved, so a classic scrollbar widens the PANEL
+ and never narrows the DAYS. Both max sizes clamp to the viewport:
+ below a 342px viewport the days narrow to about 41px and stay 44px
+ tall, a deliberate exception to the 44px target, because seven full
+ days and the panel's padding do not fit a narrower screen. The
+ height cap is why the panel scrolls itself: six weeks of 44px rows
+ are taller than a phone in landscape, and a fixed panel's clipped
+ top cannot be reached by scrolling the page. top: auto because the
+ base rule's physical top: 100% would otherwise be measured against
+ the viewport; the block-start inset and the margins reset because
+ .is-above sets them (the :is() weighs (0,3,0) and beats it). */
+ :is(.rst-cal, .rst-dtp .rst-cal, .rst-dtp.is-above .rst-cal), :is([rst-cal], [rst-dtp] [rst-cal], [rst-dtp].is-above [rst-cal]) { box-sizing: border-box; inline-size: fit-content; inset-block-end: var(--rst-sp-2); inset-block-start: auto; inset-inline: 0; margin: 0 auto; max-block-size: calc(100dvh - 2 * var(--rst-sp-2)); max-inline-size: calc(100vw - 2 * var(--rst-sp-2)); overflow-y: auto; overscroll-behavior: contain; position: fixed; scrollbar-gutter: stable; top: auto; }
+ :is(.rst-cal, .rst-dtp .rst-cal) .rst-cal__grid, :is([rst-cal], [rst-dtp] [rst-cal]) [rst-cal-grid] { inline-size: calc(7 * var(--rst-tap)); max-inline-size: 100%; }
+
+ /* The row's own controls. The checkbox's label grows by
+ padding, so the 16px box stays 16px and the label is 44×44. */
+ .rst-row__action, [rst-row-action] { align-items: center; box-sizing: border-box; display: inline-flex; justify-content: center; min-block-size: var(--rst-tap); min-inline-size: var(--rst-tap); padding-inline: 1rem; }
+ .rst-row-menu > summary, [rst-row-menu] > summary { block-size: var(--rst-tap); inline-size: var(--rst-tap); }
+ .rst-selbox, [rst-selbox] { box-sizing: border-box; justify-content: center; min-block-size: var(--rst-tap); min-inline-size: var(--rst-tap); padding: 0; }
+ .rst-lrow > .rst-nm, [rst-lrow] > .rst-nm { align-content: center; display: grid; min-block-size: var(--rst-tap); }
+ a.rst-person, a[rst-person] { min-block-size: var(--rst-tap); min-inline-size: var(--rst-tap); }
+
+ .rst-pagination a, [rst-pagination] a, .rst-pagination span, [rst-pagination] span { align-items: center; box-sizing: border-box; display: inline-flex; justify-content: center; min-block-size: var(--rst-tap); min-inline-size: var(--rst-tap); }
+ .rst-seg-tabs a, [rst-seg-tabs] a { align-items: center; box-sizing: border-box; display: inline-flex; justify-content: center; min-block-size: var(--rst-tap); min-inline-size: var(--rst-tap); }
+ .rst-switch, [rst-switch], .rst-choice__cards label, [rst-choice-cards] label, .rst-tblock > .rst-tblock__head, [rst-tblock] > [rst-tblock-head] { min-block-size: var(--rst-tap); }
+
+ /* flex: none because the bar is a flex row that, at 390, shrank the
+ close link to 26px wide to make room for the escalate link's text. */
+ .rst-bulkbar__close, [rst-bulkbar-close] { block-size: var(--rst-tap); flex: none; inline-size: var(--rst-tap); }
+ .rst-bulkbar__escalate, [rst-bulkbar-escalate] { align-items: center; display: inline-flex; justify-content: center; min-block-size: var(--rst-tap); min-inline-size: var(--rst-tap); }
+ .rst-modal-close, [rst-modal-close] { align-items: center; display: inline-flex; justify-content: center; min-block-size: var(--rst-tap); min-inline-size: var(--rst-tap); }
+ .rst-modal-panel > nav a, [rst-modal-panel] > nav a { align-items: center; display: flex; min-block-size: var(--rst-tap); }
+ .rst-back-nav a, [rst-back-nav] a { align-items: center; display: inline-flex; min-block-size: var(--rst-tap); min-inline-size: var(--rst-tap); }
+
+ .rst-shell__brand, [rst-shell-brand] { align-items: center; display: inline-flex; min-block-size: var(--rst-tap); min-inline-size: var(--rst-tap); }
+ .rst-shell-topbar .rst-shell__nav a, [rst-shell-topbar] [rst-shell-nav] a, .rst-shell-sidebar .rst-shell__nav a, [rst-shell-sidebar] [rst-shell-nav] a, .rst-shell-console .rst-shell__nav a, [rst-shell-console] [rst-shell-nav] a { align-items: center; box-sizing: border-box; display: flex; min-block-size: var(--rst-tap); }
+ .rst-shell-topbar .rst-shell__menu > summary, [rst-shell-topbar] [rst-shell-menu] > summary, .rst-shell-console .rst-shell__menu > summary, [rst-shell-console] [rst-shell-menu] > summary { min-block-size: var(--rst-tap); min-inline-size: var(--rst-tap); }
+ /* LEGACY: the old drawer's summary. */
+ .rst-shell-sidebar > .rst-shell__chrome > summary, [rst-shell-sidebar] > [rst-shell-chrome] > summary { min-block-size: var(--rst-tap); }
+ /* The card's own summaries: the open Menu (its pressed state is a
+ (0,3,1) rule) and the account menu inside it. */
+ .rst-shell-topbar .rst-shell__menu[open] > summary, [rst-shell-topbar] [rst-shell-menu][open] > summary, .rst-shell-console--page .rst-shell__menu[open] > summary, .rst-shell-console--index .rst-shell__menu[open] > summary, [rst-shell-console~="page"] [rst-shell-menu][open] > summary, [rst-shell-console~="index"] [rst-shell-menu][open] > summary { min-block-size: var(--rst-tap); min-inline-size: var(--rst-tap); }
+ .rst-shell-topbar .rst-shell__tail > .rst-shell__account > summary, [rst-shell-topbar] [rst-shell-tail] > [rst-shell-account] > summary, .rst-shell-console--page .rst-shell__tail > .rst-shell__account > summary, .rst-shell-console--index .rst-shell__tail > .rst-shell__account > summary, [rst-shell-console~="page"] [rst-shell-tail] > [rst-shell-account] > summary, [rst-shell-console~="index"] [rst-shell-tail] > [rst-shell-account] > summary { min-block-size: var(--rst-tap); }
+ /* The back control (its strip is 2.75rem already; the link is the
+ target) and the index rows, which are 3rem: the nav-link floor above
+ weighs the same as the index's row rule and comes later, so without
+ this it would shrink the rows to 44px on exactly the screens the
+ index is for. */
+ .rst-shell__back a, [rst-shell-back] a { min-inline-size: var(--rst-tap); }
+ .rst-shell-sidebar--index .rst-shell__nav > a, [rst-shell-sidebar~="index"] [rst-shell-nav] > a, .rst-shell-console--index .rst-shell__nav > a, [rst-shell-console~="index"] [rst-shell-nav] > a { min-block-size: 3rem; }
+}
+
/* ---------------------------------------------------------------------
Utilities, last on purpose.
diff --git a/internal/slides/templates/signin.html b/internal/slides/templates/signin.html
index c5f5f0e..95a79c5 100644
--- a/internal/slides/templates/signin.html
+++ b/internal/slides/templates/signin.html
@@ -1,9 +1,11 @@
{{/* signin — GET /signin (auth.go's signin handler): estilo's sign-in
form, or once a link is on its way, the check-your-email state that
replaces it, or the card between the form and Keymail. rastrillo/auth's
- own handlers never render a page (magic-links.md: "the sign-in page
- stays yours"): Begin, Callback and Verify report every outcome by
- redirecting back here with a query the handler reads.
+ own handlers never render a page of their own here: Begin, Callback
+ and Verify report every outcome by redirecting back here with a query
+ the handler reads, and the two pages they do ask for — the refusal
+ and the confirm step — are this template too (refused,
+ renderConfirm).
The form POSTs to rastrillo/auth's own Begin through gateBegin
(app.go, auth.go). An address whose domain runs Keymail is answered
@@ -15,4 +17,19 @@
account, and everything the brand could link to is behind the
session gate. Data: signinView. */}}
{{define "page-head"}}{{with .Continue}}{{template "home-oauth-head" .}}{{end}}{{end}}
-{{define "content"}}{{with .Sent}}{{template "estilo-signin-sent" .}}{{else}}{{template "estilo-signin" .SignIn}}{{end}}{{end}}
+{{/* The confirm step an emailed link opens on (auth.go's renderConfirm):
+ the door with its address field taken away, and one Sign in button
+ that posts the link's token back. GET /auth/verify spends nothing;
+ this POST does. estilo has no confirm variant, so the form is this
+ page's own. The token is the link's own query value, escaped here. */}}
+{{define "content"}}{{with .Confirm}}{{template "estilo-door-open" .Door}}
+ <h1>{{.Door.Heading}}</h1>
+ {{- with .Door.Lede}}
+ <p estilo-signin-lede>{{.}}</p>
+ {{- end}}
+ <form rst-form estilo-signin-form method="post" action="{{.Action}}">
+ <input type="hidden" name="token" value="{{.Token}}">
+ <div rst-form-foot><button rst-btn="primary" type="submit" autofocus>{{.Door.Submit}}</button></div>
+ </form>
+{{template "estilo-door-close"}}
+{{- else with .Sent}}{{template "estilo-signin-sent" .}}{{else}}{{template "estilo-signin" .SignIn}}{{end}}{{end}}
diff --git a/internal/slidestest/browser_csp_test.go b/internal/slidestest/browser_csp_test.go
new file mode 100644
index 0000000..7a92514
--- /dev/null
+++ b/internal/slidestest/browser_csp_test.go
@@ -0,0 +1,88 @@
+//go:build browser
+
+package slidestest
+
+import (
+ "math"
+ "strings"
+ "testing"
+
+ "github.com/chromedp/chromedp"
+)
+
+// TestSlideStylesSurviveTheProductionCSP: Slides in Chromium behind the
+// production serving stack (newRig: rastrillo's security headers and
+// Slides' CSP). A slide's theme reaches the slide, the canvas slide
+// places its heading where the deck put it, and the viewer's own
+// <style> lays out the pager.
+//
+// Every one of those is inline: the theme is custom properties on the
+// slide's style attribute, the box is left/top/width/height on the
+// block's, and the pager is a <style> element in viewer.html. Under
+// rastrillo fb806a2's default style-src the browser refuses them all —
+// the attribute stays in the DOM but declares nothing, the canvas
+// heading falls back into the slide's corner, the pager stacks. Checked
+// here by what the page looks like, because the rig does not fail on a
+// CSP report by itself.
+func TestSlideStylesSurviveTheProductionCSP(t *testing.T) {
+ rig, mailer := newRig(t)
+ browserSignIn(t, rig, mailer)
+ var deck string
+ rig.Run(chromedp.Evaluate(`(document.querySelector('a[href^="/d/"]')?.getAttribute('href') ?? '')`, &deck))
+ if deck == "" {
+ t.Fatal("the deck list links no deck")
+ }
+
+ var flow struct {
+ Attr, Applied, Display string
+ }
+ rig.Run(
+ chromedp.Navigate(rig.Origin+deck+"?slide=1"),
+ chromedp.WaitVisible(`[slides-slide]`, chromedp.ByQuery),
+ chromedp.Evaluate(`(() => {
+ const s = document.querySelector('[slides-slide]');
+ return {
+ Attr: s.getAttribute('style') ?? '',
+ Applied: s.style.getPropertyValue('--slides-font-heading'),
+ Display: getComputedStyle(document.querySelector('[slides-pager]')).display,
+ };
+ })()`, &flow),
+ )
+ if !strings.Contains(flow.Attr, "--slides-font-heading:") {
+ t.Fatalf("the slide carries no theme on its style attribute: %q", flow.Attr)
+ }
+ if flow.Applied == "" {
+ t.Error("the slide's theme declares nothing: its style attribute was refused")
+ }
+ if flow.Display != "flex" {
+ t.Errorf("the pager is display %q, want flex: the viewer's <style> was refused", flow.Display)
+ }
+
+ // The canvas slide: its heading's box is percentages of the slide on
+ // the heading's own style attribute.
+ var box struct {
+ Attr string
+ Left, Top, SlideLeft, SlideTop, W, H float64
+ WantLeft, WantTop float64
+ }
+ rig.Run(
+ chromedp.Navigate(rig.Origin+deck+"?slide=8"),
+ chromedp.WaitVisible(`[slides-slide][data-layout="canvas"] [data-block="heading"]`, chromedp.ByQuery),
+ chromedp.Evaluate(`(() => {
+ const slide = document.querySelector('[slides-slide]');
+ const heading = slide.querySelector('[data-block="heading"]');
+ const s = slide.getBoundingClientRect(), h = heading.getBoundingClientRect();
+ const attr = heading.getAttribute('style') ?? '';
+ const pct = (k) => parseFloat((attr.match(new RegExp('(?:^|;)' + k + ':([0-9.]+)%')) ?? [])[1]);
+ return {Attr: attr, Left: h.left, Top: h.top, SlideLeft: s.left, SlideTop: s.top, W: s.width, H: s.height, WantLeft: pct('left'), WantTop: pct('top')};
+ })()`, &box),
+ )
+ if math.IsNaN(box.WantLeft) || math.IsNaN(box.WantTop) || box.WantTop == 0 {
+ t.Fatalf("the canvas heading carries no box on its style attribute: %q", box.Attr)
+ }
+ wantLeft := box.SlideLeft + box.WantLeft/100*box.W
+ wantTop := box.SlideTop + box.WantTop/100*box.H
+ if math.Abs(box.Left-wantLeft) > 2 || math.Abs(box.Top-wantTop) > 2 {
+ t.Errorf("the canvas heading is at (%.1f, %.1f), want (%.1f, %.1f) from %q: its style attribute was refused", box.Left, box.Top, wantLeft, wantTop, box.Attr)
+ }
+}
diff --git a/internal/slidestest/browser_test.go b/internal/slidestest/browser_test.go
index f5dad94..f0b097c 100644
--- a/internal/slidestest/browser_test.go
+++ b/internal/slidestest/browser_test.go
@@ -17,6 +17,7 @@ import (
"bytes"
"context"
"fmt"
+ "html"
"io"
"log/slog"
"net/http"
@@ -27,6 +28,7 @@ import (
"testing"
"time"
+ "amadan.net/rastrillo/rastrillo"
"amadan.net/rastrillo/rastrillo/blobs"
"amadan.net/rastrillo/rastrillo/db"
"amadan.net/rastrillo/rastrillo/harness"
@@ -83,7 +85,18 @@ func newRigWith(t *testing.T, store blobs.Store) (*harness.Rig, *fakeMailer) {
}
t.Cleanup(func() { a.Close() })
mailer.flush = a.FlushMail
- return localised(t, mux)
+ // Behind rastrillo's serving stack, built from the ServeOptions
+ // main hands rastrillo.Serve: its security headers, the locale
+ // middleware and Slides' CSP. Serving the bare mux would leave
+ // every drive here blind to the policy a browser is really told,
+ // and a style the policy refused is a console error the rig
+ // fails on.
+ h, closeFn, err := rastrillo.Handler(a.ServeOptions(rastrillo.Options{Logger: logger}, mux))
+ if err != nil {
+ t.Fatalf("rastrillo.Handler: %v", err)
+ }
+ t.Cleanup(func() { _ = closeFn() })
+ return h
})
return rig, mailer
}
@@ -100,7 +113,8 @@ func TestBrowserWalk(t *testing.T) {
}
// browserSignIn signs the browser in the way a person does: the form,
-// the mail, the link. Ends on the deck list.
+// the mail, the link, and the confirm door's one Sign in button. Ends on
+// the deck list.
func browserSignIn(t *testing.T, rig *harness.Rig, mailer *fakeMailer) {
t.Helper()
rig.Run(
@@ -121,7 +135,16 @@ func browserSignIn(t *testing.T, rig *harness.Rig, mailer *fakeMailer) {
if link == "" {
t.Fatalf("no magic link in the mailed body:\n%s", msgs[len(msgs)-1].Body)
}
- rig.Run(chromedp.Navigate(link))
+ rig.Run(
+ chromedp.Navigate(link),
+ chromedp.WaitVisible(`form[action="/auth/verify"] button[type="submit"]`, chromedp.ByQuery),
+ )
+ rig.Screen("body", "confirm door")
+ rig.Run(
+ chromedp.Click(`form[action="/auth/verify"] button[type="submit"]`, chromedp.ByQuery),
+ waitFor(`location.pathname === '/'`),
+ chromedp.WaitVisible(`[rst-page]`, chromedp.ByQuery),
+ )
rig.Screen("[rst-page]", "signed in")
}
@@ -287,11 +310,35 @@ func serverClient(t *testing.T, rig *harness.Rig, mailer *fakeMailer) *http.Clie
t.Fatalf("POST /signin sent %d mails, want 1", len(msgs)-before)
}
link := magicLinkPattern.FindString(msgs[len(msgs)-1].Body)
- verify, err := c.Get(link)
+ // Opening the link is the confirm door and spends nothing; its Sign
+ // in button POSTs the token back with a browser's same-origin
+ // evidence, and that POST is what signs in.
+ u, err := url.Parse(link)
+ if err != nil {
+ t.Fatalf("parse the magic link: %v", err)
+ }
+ open, err := c.Get(link)
if err != nil {
t.Fatalf("GET the magic link: %v", err)
}
+ page, _ := io.ReadAll(open.Body)
+ open.Body.Close()
+ m := confirmForm.FindStringSubmatch(string(page))
+ if open.StatusCode != http.StatusOK || m == nil {
+ t.Fatalf("GET the magic link = %d, want the confirm door:\n%s", open.StatusCode, page)
+ }
+ req, _ = http.NewRequest(http.MethodPost, rig.Origin+html.UnescapeString(m[1]), strings.NewReader(url.Values{"token": {html.UnescapeString(m[2])}}.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Origin", rig.Origin)
+ req.Header.Set("Sec-Fetch-Site", "same-origin")
+ verify, err := c.Do(req)
+ if err != nil {
+ t.Fatalf("POST %s: %v", u.Path, err)
+ }
verify.Body.Close()
+ if verify.StatusCode != http.StatusOK || verify.Request.URL.Path != "/" {
+ t.Fatalf("signing in ended at %d %s, want 200 on the deck list", verify.StatusCode, verify.Request.URL.Path)
+ }
return c
}
diff --git a/internal/slidestest/harness_test.go b/internal/slidestest/harness_test.go
index dde46af..81d10fb 100644
--- a/internal/slidestest/harness_test.go
+++ b/internal/slidestest/harness_test.go
@@ -8,6 +8,7 @@ package slidestest
import (
"context"
+ "html"
"io"
"log/slog"
"net/http"
@@ -89,6 +90,10 @@ type testApp struct {
http.Handler
mailer *fakeMailer
db *db.DB
+ // mux is slides.New's own mux, before the locale middleware
+ // localised puts around it: what production hands rastrillo.Serve,
+ // and what productionHandler builds the real serving stack over.
+ mux *http.ServeMux
// app is the built app itself, reachable only through its exported
// methods — slides.New returns an unexported type on purpose, so
// this names the shape rather than the type. Today: Close; the
@@ -101,6 +106,7 @@ type testApp struct {
SetExportCaps(perDeck, perInstance int64)
ForgetExportJobs() error
FlushMail()
+ ServeOptions(rastrillo.Options, *http.ServeMux) rastrillo.Options
}
}
@@ -143,7 +149,7 @@ func newAppWith(t *testing.T, cfg slides.Config, mailer *fakeMailer) *testApp {
if mailer != nil {
mailer.flush = a.FlushMail
}
- return &testApp{Handler: localised(t, mux), mailer: mailer, db: d, app: a}
+ return &testApp{Handler: localised(t, mux), mailer: mailer, db: d, mux: mux, app: a}
}
// openTestDB opens a fresh SQLite file for one test, closed on
@@ -238,9 +244,51 @@ func postLegacy(t *testing.T, h http.Handler, target, origin string, form url.Va
return rec
}
+// confirmForm is the confirm door's one form, as a browser would read it
+// (signin.html's Confirm branch, auth.go's renderConfirm).
+var confirmForm = regexp.MustCompile(`<form[^>]*method="post" action="([^"]+)">\s*<input type="hidden" name="token" value="([^"]+)">`)
+
+// openLink is opening an emailed link: a GET that must spend nothing and
+// answer with the confirm door. It returns the door's form action and the
+// token it carries, read off the page rather than the link, so a door
+// that dropped or mangled the token fails here.
+func openLink(t *testing.T, h http.Handler, link string, cookies ...*http.Cookie) (action, token string) {
+ t.Helper()
+ u, err := url.Parse(link)
+ if err != nil {
+ t.Fatalf("parse emailed link %q: %v", link, err)
+ }
+ rec := get(t, h, u.RequestURI(), cookies...)
+ if rec.Code != http.StatusOK || len(rec.Result().Cookies()) != 0 {
+ t.Fatalf("GET %s = %d with %d cookies, want 200 and the confirm door, minting nothing", u.Path, rec.Code, len(rec.Result().Cookies()))
+ }
+ m := confirmForm.FindStringSubmatch(rec.Body.String())
+ if m == nil {
+ t.Fatalf("GET %s: no confirm form on the page:\n%s", u.Path, rec.Body.String())
+ }
+ return html.UnescapeString(m[1]), html.UnescapeString(m[2])
+}
+
+// postLink is pressing the confirm door's Sign in button: the token,
+// POSTed back to the door's action with a browser's same-origin
+// evidence (post sends Origin and Sec-Fetch-Site: same-origin). This is
+// the request that spends the link.
+func postLink(t *testing.T, h http.Handler, action, token string, cookies ...*http.Cookie) *httptest.ResponseRecorder {
+ t.Helper()
+ return post(t, h, action, url.Values{"token": {token}}, cookies...)
+}
+
+// followLink is both: open the emailed link, then press Sign in.
+func followLink(t *testing.T, h http.Handler, link string, cookies ...*http.Cookie) *httptest.ResponseRecorder {
+ t.Helper()
+ action, token := openLink(t, h, link, cookies...)
+ return postLink(t, h, action, token, cookies...)
+}
+
// signedIn runs the whole magic-link flow for address against the fake
// mailer — POST /signin, read the link out of the captured message,
-// GET it — and returns the cookies the verify step set. Every later
+// open it and press the confirm door's Sign in — and returns the
+// cookies the verify step set. Every later
// test that needs a signed-in request builds on this rather than
// minting a session by hand, so the suite always signs in the way a
// person does.
@@ -260,12 +308,7 @@ func signedIn(t *testing.T, a *testApp, address string) []*http.Cookie {
if link == "" {
t.Fatalf("no magic link in the mailed body for %q:\n%s", address, msgs[len(msgs)-1].Body)
}
- u, err := url.Parse(link)
- if err != nil {
- t.Fatalf("parse magic link %q: %v", link, err)
- }
-
- verify := get(t, a, u.RequestURI())
+ verify := followLink(t, a, link)
cookies := verify.Result().Cookies()
if len(cookies) == 0 {
t.Fatalf("following the magic link for %q set no session cookie (status %d)", address, verify.Code)
diff --git a/internal/slidestest/keymail_signin_test.go b/internal/slidestest/keymail_signin_test.go
index c3e2d6d..961b267 100644
--- a/internal/slidestest/keymail_signin_test.go
+++ b/internal/slidestest/keymail_signin_test.go
@@ -209,8 +209,8 @@ func TestKeymailMemberIsSignedIn(t *testing.T) {
// TestKeymailRefusalIsTheDoor: a non-permitted address that Keymail
// has verified is refused AFTER verification, and the refusal is the
// sign-in door, rendered right there with the error and a form for a
-// different address — not rastrillo's bare-text 403. No session is
-// minted. And the sentence cannot be summoned by anybody else: visiting
+// different address by rauth's Refused hook (refused) — not rastrillo's
+// bare-text 403. No session is minted. And the sentence cannot be summoned by anybody else: visiting
// /signin?err=refused shows no such thing (Astra, round 3), because the
// refusal is never a query parameter.
func TestKeymailRefusalIsTheDoor(t *testing.T) {
@@ -228,6 +228,9 @@ func TestKeymailRefusalIsTheDoor(t *testing.T) {
if strings.Contains(string(body), "not admitted here") {
t.Error("the refusal is rastrillo's bare text")
}
+ if got := res.Header.Get("Cache-Control"); got != "no-store" {
+ t.Errorf("refusal door Cache-Control = %q, want no-store", got)
+ }
if rec := get(t, app, "/", cookies...); rec.Code == http.StatusOK {
t.Fatal("a refused Keymail sign-in left a working session")
}
diff --git a/internal/slidestest/serving_test.go b/internal/slidestest/serving_test.go
new file mode 100644
index 0000000..36236d1
--- /dev/null
+++ b/internal/slidestest/serving_test.go
@@ -0,0 +1,130 @@
+package slidestest
+
+import (
+ "io"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "amadan.net/rastrillo/rastrillo"
+
+ slides "amadan.net/oficina/slides/internal/slides"
+)
+
+// productionHandler is a behind rastrillo's serving stack, built from the
+// same ServeOptions main hands to rastrillo.Serve: security headers, CSP,
+// locales and all. a.Handler alone carries no CSP, so a test that serves
+// only the mux cannot see what a browser is told.
+func productionHandler(t *testing.T, a *testApp) http.Handler {
+ t.Helper()
+ opts := a.app.ServeOptions(rastrillo.Options{Logger: slog.New(slog.NewTextHandler(io.Discard, nil))}, a.mux)
+ h, closeFn, err := rastrillo.Handler(opts)
+ if err != nil {
+ t.Fatalf("rastrillo.Handler: %v", err)
+ }
+ t.Cleanup(func() { _ = closeFn() })
+ return h
+}
+
+// directives splits a policy into directive name → source list.
+func directives(policy string) map[string]string {
+ out := map[string]string{}
+ for _, d := range strings.Split(policy, ";") {
+ name, value, _ := strings.Cut(strings.TrimSpace(d), " ")
+ if name != "" {
+ out[name] = strings.TrimSpace(value)
+ }
+ }
+ return out
+}
+
+// TestProductionCSPIsTheDefaultBarStyles: Slides' policy is rastrillo's
+// default with style-src alone widened to 'unsafe-inline'
+// (slides.ContentSecurityPolicy says why). Every other directive must
+// match the framework's, read off a handler built with no CSP of its
+// own, so a rastrillo bump that tightens anything else fails here rather
+// than being silently undone by our constant.
+func TestProductionCSPIsTheDefaultBarStyles(t *testing.T) {
+ bare, closeFn, err := rastrillo.Handler(rastrillo.Options{Mux: http.NewServeMux(), Logger: slog.New(slog.NewTextHandler(io.Discard, nil))})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer closeFn()
+ rec := httptest.NewRecorder()
+ bare.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
+ def := directives(rec.Header().Get("Content-Security-Policy"))
+ ours := directives(slides.ContentSecurityPolicy)
+ if len(def) == 0 {
+ t.Fatal("rastrillo set no default CSP")
+ }
+ for name, value := range def {
+ if name == "style-src" {
+ continue
+ }
+ if ours[name] != value {
+ t.Errorf("%s: ours %q, rastrillo's default %q", name, ours[name], value)
+ }
+ }
+ for name := range ours {
+ if _, ok := def[name]; !ok {
+ t.Errorf("ours adds %s, which rastrillo's default does not have", name)
+ }
+ }
+ // A hash or nonce beside 'unsafe-inline' makes the browser ignore
+ // 'unsafe-inline' (CSP3), which is exactly how the slides would break.
+ if got := ours["style-src"]; got != "'self' 'unsafe-inline'" {
+ t.Errorf("style-src = %q, want 'self' 'unsafe-inline' and nothing else", got)
+ }
+}
+
+// TestStandaloneServesInlineStyles: through the production stack, the
+// viewer — whose slide carries its theme as custom properties on a style
+// attribute, and whose own layout is a <style> element — is served under
+// exactly Slides' policy, which lets both apply. Under rastrillo
+// fb806a2's default it would be served with style-src refusing them.
+func TestStandaloneServesInlineStyles(t *testing.T) {
+ a := newApp(t)
+ cookies := signedIn(t, a, allowedAddress)
+ path := sampleDeck(t, a, cookies)
+ h := productionHandler(t, a)
+ req := httptest.NewRequest(http.MethodGet, path, nil)
+ for _, c := range cookies {
+ req.AddCookie(c)
+ }
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `style="--slides-bg:`) {
+ t.Fatalf("viewer = %d, or its slide carries no theme style attribute", rec.Code)
+ }
+ policies := rec.Header().Values("Content-Security-Policy")
+ if len(policies) != 1 || policies[0] != slides.ContentSecurityPolicy {
+ t.Fatalf("CSP = %q, want exactly Slides' policy", policies)
+ }
+}
+
+// TestTheConfirmDoorKeepsEveryPolicy: the confirm door through the
+// production stack carries Slides' policy and rauth's added
+// frame-ancestors 'none', and rauth's no-store, strict-origin referrer
+// and framing refusal survive the render. Browsers enforce every CSP
+// header, so the second one only narrows.
+func TestTheConfirmDoorKeepsEveryPolicy(t *testing.T) {
+ a := newApp(t)
+ link := mailedLink(t, a, allowedAddress)
+ h := productionHandler(t, a)
+ if action, _ := openLink(t, h, link); action != "/auth/verify" {
+ t.Fatalf("confirm action = %q", action)
+ }
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, strings.TrimPrefix(link, testOrigin), nil))
+ got := rec.Header().Values("Content-Security-Policy")
+ if len(got) != 2 || got[0] != slides.ContentSecurityPolicy || got[1] != "frame-ancestors 'none'" {
+ t.Fatalf("confirm door CSP = %q, want Slides' policy then frame-ancestors 'none'", got)
+ }
+ for header, want := range map[string]string{"X-Frame-Options": "DENY", "Referrer-Policy": "strict-origin", "Cache-Control": "no-store"} {
+ if v := rec.Header().Get(header); v != want {
+ t.Errorf("%s = %q, want %q", header, v, want)
+ }
+ }
+}
diff --git a/internal/slidestest/signin_confirm_test.go b/internal/slidestest/signin_confirm_test.go
new file mode 100644
index 0000000..90ae902
--- /dev/null
+++ b/internal/slidestest/signin_confirm_test.go
@@ -0,0 +1,178 @@
+package slidestest
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+
+ slides "amadan.net/oficina/slides/internal/slides"
+)
+
+// mailedLink asks for a link for address and returns the one emailed.
+func mailedLink(t *testing.T, a *testApp, address string) string {
+ t.Helper()
+ before := len(a.mailer.messages())
+ if rec := post(t, a, "/signin", url.Values{"address": {address}}); rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signin?sent=1" {
+ t.Fatalf("POST /signin = %d %q, want 303 /signin?sent=1", rec.Code, rec.Header().Get("Location"))
+ }
+ msgs := a.mailer.messages()
+ if len(msgs) != before+1 {
+ t.Fatalf("POST /signin for %q mailed %d messages, want 1", address, len(msgs)-before)
+ }
+ link := magicLinkPattern.FindString(msgs[len(msgs)-1].Body)
+ if link == "" {
+ t.Fatalf("no magic link in the mailed body:\n%s", msgs[len(msgs)-1].Body)
+ }
+ return link
+}
+
+// sessionCookie reports whether rec set a live session cookie.
+func sessionCookie(rec *httptest.ResponseRecorder) bool {
+ for _, c := range rec.Result().Cookies() {
+ if strings.Contains(c.Name, "session") && c.Value != "" && c.MaxAge >= 0 {
+ return true
+ }
+ }
+ return false
+}
+
+// TestEmailedLinkOpensTheConfirmDoor walks the magic-link sign-in end to
+// end: begin, the mail, GET of the emailed link (the confirm door, which
+// spends nothing), the door's POST, and a session that opens the deck
+// list.
+//
+// GET /auth/verify used to sign in on sight, so a mail-security gateway
+// that fetches every link in an inbound message (Safe Links, Proofpoint)
+// spent it before the person clicked, and they were told it had expired.
+// rastrillo now confirms on GET and redeems on POST (#64); this pins that
+// the confirm step is Slides' own door with one Sign in button, not
+// rastrillo's plain English fallback, and that the POST is mounted.
+func TestEmailedLinkOpensTheConfirmDoor(t *testing.T) {
+ a := newApp(t)
+ link := mailedLink(t, a, allowedAddress)
+ u, err := url.Parse(link)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Opening it, twice, as a scanner and then the person would.
+ for _, who := range []string{"scanner", "person"} {
+ rec := get(t, a, u.RequestURI())
+ if rec.Code != http.StatusOK || len(rec.Result().Cookies()) != 0 {
+ t.Fatalf("%s's GET = %d with %d cookies, want 200 and nothing minted", who, rec.Code, len(rec.Result().Cookies()))
+ }
+ body := rec.Body.String()
+ for _, want := range []string{
+ "estilo-door", "estilo-signin-form", "<span>Slides</span>",
+ "Select Sign in to finish signing in.",
+ `action="/auth/verify"`,
+ `name="token" value="` + u.Query().Get("token") + `"`,
+ } {
+ if !strings.Contains(body, want) {
+ t.Errorf("%s: confirm door lacks %q:\n%s", who, want, body)
+ }
+ }
+ if n := strings.Count(body, "<button"); n != 1 || !strings.Contains(body, `type="submit" autofocus>Sign in</button>`) {
+ t.Errorf("%s: confirm door has %d buttons, want exactly one Sign in:\n%s", who, n, body)
+ }
+ if strings.Contains(body, `name="address"`) {
+ t.Errorf("%s: confirm door asks for an address again", who)
+ }
+ if strings.Contains(body, "slides.signin.") {
+ t.Errorf("%s: a catalog key leaked onto the confirm door", who)
+ }
+ if strings.Contains(body, "<title>Sign in to ") {
+ t.Errorf("%s: rastrillo's fallback confirm page, not the door", who)
+ }
+ // rauth sets these before RenderConfirm; render must not undo them.
+ for header, want := range map[string]string{
+ "Cache-Control": "no-store",
+ "Referrer-Policy": "strict-origin",
+ "X-Frame-Options": "DENY",
+ } {
+ if got := rec.Header().Get(header); got != want {
+ t.Errorf("%s: %s = %q, want %q", who, header, got, want)
+ }
+ }
+ }
+
+ // A cross-site post is refused and spends nothing: the proper POST
+ // below still signs in with the same token.
+ action, token := openLink(t, a, link)
+ cross := httptest.NewRequest(http.MethodPost, action, strings.NewReader(url.Values{"token": {token}}.Encode()))
+ cross.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ cross.Header.Set("Sec-Fetch-Site", "cross-site")
+ refused := httptest.NewRecorder()
+ a.ServeHTTP(refused, cross)
+ if refused.Code != http.StatusForbidden || len(refused.Result().Cookies()) != 0 {
+ t.Fatalf("cross-site POST = %d with %d cookies, want 403 and nothing minted", refused.Code, len(refused.Result().Cookies()))
+ }
+
+ // Sign in: the POST spends the link and mints the session.
+ signed := postLink(t, a, action, token)
+ if signed.Code != http.StatusSeeOther || signed.Header().Get("Location") != "/auth/landed" || !sessionCookie(signed) {
+ t.Fatalf("POST %s = %d %q, want 303 /auth/landed with a session", action, signed.Code, signed.Header().Get("Location"))
+ }
+ cookies := signed.Result().Cookies()
+ if list := get(t, a, "/", cookies...); list.Code != http.StatusOK {
+ t.Fatalf("GET / with the new session = %d, want 200", list.Code)
+ }
+
+ // Spent: the door still opens (it looks nothing up), but its button
+ // now says the link expired.
+ action, token = openLink(t, a, link)
+ again := postLink(t, a, action, token)
+ if sessionCookie(again) || again.Header().Get("Location") != "/signin?err=expired" {
+ t.Fatalf("second POST = %d %q, want /signin?err=expired and no session", again.Code, again.Header().Get("Location"))
+ }
+}
+
+// TestALinkRefusedAtRedemptionIsTheDoor: an address on the allowlist
+// when its link was mailed but off it when the link is redeemed — the
+// list changed and the instance restarted in between — gets the sign-in
+// door with the refusal on it, in rauth's 403 (Config.Refused, refused),
+// and no session.
+func TestALinkRefusedAtRedemptionIsTheDoor(t *testing.T) {
+ const leaver = "leaver@example.test"
+ cfg, mailer := newConfig(t)
+ cfg.AllowedAddresses = []string{allowedAddress, leaver}
+ d := openTestDB(t, cfg)
+ before, b, err := slides.New(d, cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { b.Close() })
+ mailer.flush = b.FlushMail
+ link := mailedLink(t, &testApp{Handler: localised(t, before), mailer: mailer, db: d}, leaver)
+
+ // The same database, restarted without the leaver on the list.
+ cfg.AllowedAddresses = []string{allowedAddress}
+ afterMux, after, err := slides.New(d, cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { after.Close() })
+ h := localised(t, afterMux)
+
+ rec := followLink(t, h, link)
+ if rec.Code != http.StatusForbidden || rec.Header().Get("Location") != "" {
+ t.Fatalf("refused redemption = %d %q, want 403 and the door itself", rec.Code, rec.Header().Get("Location"))
+ }
+ if sessionCookie(rec) {
+ t.Fatal("a refused sign-in set a session cookie")
+ }
+ body := rec.Body.String()
+ for _, want := range []string{"estilo-door", `name="address"`, `action="/signin"`, "can’t sign in to Slides"} {
+ if !strings.Contains(body, want) {
+ t.Errorf("refusal door lacks %q:\n%s", want, body)
+ }
+ }
+ if strings.Contains(body, "not admitted here") {
+ t.Error("the refusal is rastrillo's bare text")
+ }
+ if got := rec.Header().Get("Cache-Control"); got != "no-store" {
+ t.Errorf("refusal door Cache-Control = %q, want no-store", got)
+ }
+}
diff --git a/internal/slidestest/vendored_test.go b/internal/slidestest/vendored_test.go
index abacdb3..e78115f 100644
--- a/internal/slidestest/vendored_test.go
+++ b/internal/slidestest/vendored_test.go
@@ -28,6 +28,11 @@ 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,
+ // estilo's busy.js, for the same reason: the busy rule as the suite
+ // carries it. ui's own copy moved on at rastrillo fb806a2 (pow.js
+ // arming) and estilo's has not, so the two can no longer both be
+ // pinned. Pinned to estilo by TestBusyFilesAreEstilos.
+ "busy.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.