Calendar moves to rastrillo fb806a2: links confirm before they are spent
Opening a sign-in link now shows Calendar's own door with one Sign in button; the POST spends it, so mail scanners no longer burn links. POST /auth/verify is mounted standalone and Home-local. A refused identity gets the door through auth's Refused hook, replacing doorOnRefusal and its buffering. ProofOff keeps today's no-pow sign-in. rastrillo's new CSP refuses style attributes. The constant ones move to calendar.css; the day/week chip and slot positions and --cal-hue are per event, so standalone keeps style-src 'self' 'unsafe-inline' with every other directive pinned to rastrillo's default, and a production-stack browser test fails without it. Times now come back from the database in UTC: the share page's feed created/last-polled times are converted to the viewer's zone. tokens.css, select.js and datetime.js are re-copied from the new ui by hand (doctor refuses while go.mod requires the old path); busy.js stays estilo's.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
36 files changed,
+2672
−522
AGENTS.md+19 −1Makefile+7 −5cmd/calendar/main.go+2 −4go.mod+6 −1go.sum+6 −2internal/calendar/app.go+6 −3internal/calendar/auth.go+21 −3internal/calendar/auth_keymail.go+2 −99internal/calendar/credits/notices.json+12 −1internal/calendar/handlers_misc.go+12 −0internal/calendar/home_router_test.go+9 −1internal/calendar/home_security_test.go+14 −28internal/calendar/keymail_signin_test.go+13 −2internal/calendar/render.go+17 −1internal/calendar/routes.go+15 −6internal/calendar/serving.go+48 −0internal/calendar/serving_test.go+153 −0internal/calendar/signin.go+74 −13internal/calendar/static/calendar.css+33 −0internal/calendar/static/datetime.js+17 −6internal/calendar/static/select.js+1085 −256internal/calendar/static/tokens.css+653 −65internal/calendar/templates/agenda.html+1 −1internal/calendar/templates/calendars.html+1 −2internal/calendar/templates/event.html+4 −6internal/calendar/templates/share.html+1 −1internal/calendar/templates/signin.html+16 −1internal/calendar/testing.go+33 −3internal/calendartest/auth_test.go+36 −9internal/calendartest/browser_test.go+7 −1internal/calendartest/csp_browser_test.go+127 −0internal/calendartest/harness_test.go+36 −0internal/calendartest/playwright_test.go+24 −1internal/calendartest/share_zone_test.go+45 −0internal/calendartest/signin_confirm_test.go+107 −0internal/calendartest/vendored_test.go+10 −0
CI passed — run details
CI log
=== vet === go vet ./... go vet -tags browser ./internal/calendartest/ === fmt === === test === go test ./... ? amadan.net/oficina/calendar/cmd/calendar [no test files] ok amadan.net/oficina/calendar/internal/calendar 5.391s ? amadan.net/oficina/calendar/internal/calendar/icons [no test files] ? amadan.net/oficina/calendar/internal/calendar/keymailtest [no test files] ? amadan.net/oficina/calendar/internal/calendar/seal [no test files] ok amadan.net/oficina/calendar/internal/calendartest 2.762s ok amadan.net/oficina/calendar/internal/guardstest 0.065s ? amadan.net/oficina/calendar/scripts/writeestilo [no test files] === migration-check === go tool rastrillo migration check rastrillo migration check: models and migrations agree
diff --git a/AGENTS.md b/AGENTS.md| index 733b7b2..e5dafe7 100644 |
| --- a/AGENTS.md |
| +++ b/AGENTS.md |
| @@ -245,7 +245,25 @@ relying on remembering one, make it a test instead. |
| `go run ./scripts/writeestilo`, and calendar.js is Calendar's OWN |
| script, not rastrillo's month-grid overlay of the same name. If |
| `--fix` ever writes rastrillo.js, re-run writeestilo: the old shim |
| - must never load beside busy.js (TestBusyFilesAreEstilos). |
| + must never load beside busy.js (TestBusyFilesAreEstilos). busy.js is |
| + estilo's too, and shell.css/shell.js are rastrillo's console shell, |
| + which Calendar does not draw. **While go.mod still requires the old |
| + path, doctor compares nothing and `--fix` refuses**: copy the drifted |
| + files out of the new module's `ui/` by hand, and |
| + TestVendoredAssetsMatchTheLibrary says which. |
| +- **An emailed link confirms before it is spent.** GET /auth/verify draws |
| + the door with one Sign in button (`handlers.renderConfirm`) and spends |
| + nothing, so a mail scanner cannot burn it; the POST redeems. Both are |
| + mounted in both modes. A test that signs in opens the link, reads the |
| + token off the door and POSTs it (`SignInForTest`, `openLink`/`postLink`). |
| +- **Inline style attributes need the CSP's `'unsafe-inline'`.** |
| + rastrillo's default style-src refuses them; the day and week grids |
| + place chips and slots with them and every chip carries `--cal-hue`, so |
| + `ContentSecurityPolicy` (serving.go) widens style-src alone. |
| + `TestProductionCSPIsTheDefaultBarStyles` keeps every other directive |
| + at rastrillo's default, and `TestPlacedChipsSurviveTheProductionCSP` |
| + (`make browser`) is the one browser check that sees the real policy. |
| + A CONSTANT style belongs in calendar.css, never in an attribute. |
| **Logs** |
| - **No title, place, note or address in any log line, error string or |
diff --git a/Makefile b/Makefile| index 14b14aa..814f9b4 100644 |
| --- a/Makefile |
| +++ b/Makefile |
| @@ -141,13 +141,15 @@ ci: vet fmt-check test migration-check |
| # toolchain the app itself does not use. Run it before shipping a |
| # change to the box. |
| # |
| -# TestNoZoomOnTap (the iOS zoom-on-tap check) and |
| -# TestFormFootOnANarrowScreen drive Chromium through chromedp instead, |
| -# and find it through RASTRILLO_CHROME; on a Mac the Playwright cache's |
| -# Chromium is used when that is unset. |
| +# TestNoZoomOnTap (the iOS zoom-on-tap check), |
| +# TestFormFootOnANarrowScreen and TestPlacedChipsSurviveTheProductionCSP |
| +# drive Chromium through chromedp instead, and find it through |
| +# RASTRILLO_CHROME; on a Mac the Playwright cache's Chromium is used when |
| +# that is unset. The CSP check is the one served through rastrillo's |
| +# production stack, so it is the one that sees the real policy. |
| browser: |
| npm install --no-save playwright |
| - RASTRILLO_CHROME="$(RASTRILLO_CHROME)" go test -tags browser -run 'InARealBrowser|OnTap|OnANarrowScreen' ./internal/calendartest/ |
| + RASTRILLO_CHROME="$(RASTRILLO_CHROME)" go test -tags browser -run 'InARealBrowser|OnTap|OnANarrowScreen|SurviveTheProductionCSP' ./internal/calendartest/ |
| RASTRILLO_CHROME ?= $(firstword $(wildcard $(HOME)/Library/Caches/ms-playwright/chromium-*/chrome-mac*/Chromium.app/Contents/MacOS/Chromium)) |
diff --git a/cmd/calendar/main.go b/cmd/calendar/main.go| index a19e6e8..9a9493c 100644 |
| --- a/cmd/calendar/main.go |
| +++ b/cmd/calendar/main.go |
| @@ -74,12 +74,10 @@ func main() { |
| } |
| defer app.Close() |
| - opts.Mux = app.Handler() |
| + // The router, the error page and the CSP (calendar.ServeOptions). |
| + opts = app.ServeOptions(opts) |
| opts.DBPath = "" |
| opts.NextDue = app.NextDue |
| - // The same page a handler's own error path renders, so a panic and |
| - // a handled failure look identical to whoever hits one. |
| - opts.ErrorPage = calendar.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 f90a106..7cca2e8 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -7,7 +7,7 @@ toolchain go1.26.5 |
| require ( |
| 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/carlosframework/rastrillo v0.23.0 |
| github.com/chromedp/chromedp v0.14.2 |
| github.com/go-chi/chi/v5 v5.3.2 |
| @@ -44,4 +44,9 @@ require ( |
| require amadan.net/rastrillo/native/server v0.0.0-20260923173546-2a5d07b2c8d3 |
| +require ( |
| + amadan.net/rastrillo/rastrillo/money v0.1.0 // indirect |
| + golang.org/x/mod v0.38.0 // indirect |
| +) |
| + |
| tool amadan.net/rastrillo/rastrillo/cmd/rastrillo |
diff --git a/go.sum b/go.sum| index 2ab2ecd..5322bf1 100644 |
| --- a/go.sum |
| +++ b/go.sum |
| @@ -6,8 +6,10 @@ amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 h1:RPKtyry/71 |
| amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489/go.mod h1:YISpzFJ+iMgrM0rUL032OSzsAQbP4/nozvXLCCOcn1k= |
| amadan.net/rastrillo/native/server v0.0.0-20260923173546-2a5d07b2c8d3 h1:5EFx7BLJj/+G/rt8bTmeyMWVfF5s5Sns7ajTvX4w/I8= |
| amadan.net/rastrillo/native/server v0.0.0-20260923173546-2a5d07b2c8d3/go.mod h1:ZaaxeE278dJltdrLwmwpxUjQ5wuKzqDAVSNRet+WupY= |
| -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= |
| @@ -56,6 +58,8 @@ github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhA |
| github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= |
| github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= |
| github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= |
| +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= |
| +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= |
| golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= |
| golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= |
| golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= |
diff --git a/internal/calendar/app.go b/internal/calendar/app.go| index 0b44f6d..0a5d5a1 100644 |
| --- a/internal/calendar/app.go |
| +++ b/internal/calendar/app.go |
| @@ -147,14 +147,18 @@ func New(cfg Config) (*App, error) { |
| // Refusals are counted in the log without the address (AGENTS.md |
| // §Logs) — by the queue's worker, through its non-blocking logf. |
| signinMail := newMailQueueLogging(mailer, list.allows, nil, logger, "calendar: signin refused by allowlist") |
| - authPlugin, err := newAuthPlugin(origin, writer, instanceKey, signinMail, list, logger, homeSettings != nil) |
| + // handlers before auth: auth's Refused and RenderConfirm are pages |
| + // the handlers draw. viewerFrom is auth's, so it is set just after. |
| + store := NewStore(d.G, sealer, logger) |
| + h := &handlers{store: store, logger: logger, origin: origin, mailer: mailer, gate: newGate()} |
| + authPlugin, err := newAuthPlugin(origin, writer, instanceKey, signinMail, list, logger, homeSettings != nil, h) |
| if err != nil { |
| signinMail.close() |
| d.Close() |
| return nil, fmt.Errorf("calendar: wire sign-in: %w", err) |
| } |
| + h.viewerFrom = authPlugin.SessionFrom |
| - store := NewStore(d.G, sealer, logger) |
| a := &App{ |
| cfg: cfg, |
| db: d, |
| @@ -164,7 +168,6 @@ func New(cfg Config) (*App, error) { |
| signinMail: signinMail, |
| auth: authPlugin, |
| } |
| - h := &handlers{store: store, logger: logger, origin: origin, mailer: mailer, gate: newGate(), viewerFrom: authPlugin.SessionFrom} |
| a.h = h |
| if homeSettings != nil { |
| a.home, err = a.newHome(homeSettings, list) |
diff --git a/internal/calendar/auth.go b/internal/calendar/auth.go| index 3b73235..03b1bbb 100644 |
| --- a/internal/calendar/auth.go |
| +++ b/internal/calendar/auth.go |
| @@ -192,7 +192,11 @@ func resolveAllowlist(cfg Config) (allowlist, error) { |
| // stops a session being minted for a non-permitted address, whether |
| // Keymail verified it or a link was somehow minted for it (mailed |
| // before the allowlist changed, say). |
| -func newAuthPlugin(origin string, writer *sql.DB, instanceKey string, mailer mail.Sender, list allowlist, logger *slog.Logger, homeEnabled bool) (*rauth.Auth, error) { |
| +// |
| +// h supplies the two pages auth asks the app for: the door a verified |
| +// but refused address is shown (handlers.refused) and the door an |
| +// emailed link opens on (handlers.renderConfirm). |
| +func newAuthPlugin(origin string, writer *sql.DB, instanceKey string, mailer mail.Sender, list allowlist, logger *slog.Logger, homeEnabled bool, h *handlers) (*rauth.Auth, error) { |
| signin, signedIn := "/signin", "/" |
| if homeEnabled { |
| signin, signedIn = "/signin/local", connect.LocalCompletePath |
| @@ -205,8 +209,22 @@ func newAuthPlugin(origin string, writer *sql.DB, instanceKey string, mailer mai |
| InstanceKey: instanceKey, |
| Mailer: mailer, |
| Authorize: list.allows, |
| - Subject: chrome.EmailSubject("Calendar"), |
| - Logger: logger, |
| + // A verified address the allowlist does not hold gets the door, |
| + // with the refusal on it, in auth's own 403 (handlers.refused). |
| + Refused: h.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 (handlers.renderConfirm). |
| + RenderConfirm: h.renderConfirm, |
| + // No proof-of-work on the sign-in form. Calendar never had one: |
| + // Begin's rate 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). |
| + ProofOff: true, |
| + Subject: chrome.EmailSubject("Calendar"), |
| + Logger: logger, |
| }) |
| } |
diff --git a/internal/calendar/auth_keymail.go b/internal/calendar/auth_keymail.go| index 334261a..ad9fc93 100644 |
| --- a/internal/calendar/auth_keymail.go |
| +++ b/internal/calendar/auth_keymail.go |
| @@ -1,10 +1,7 @@ |
| package calendar |
| import ( |
| - "bytes" |
| - "context" |
| "net/http" |
| - "strings" |
| "amadan.net/oficina/home/connect" |
| ) |
| @@ -23,8 +20,8 @@ import ( |
| // chain in Safari, so a 303 out of the POST would be refused. |
| // Admission is asked only after Keymail has verified the address, |
| // by rauth's Authorize, and its refusal is the door saying so |
| -// (doorOnRefusal) — told only to someone who has just proved they |
| -// hold the address. |
| +// (rauth's Refused hook, handlers.refused) — told only to someone |
| +// who has just proved they hold the address. |
| // - Any other address is answered ?sent=1, and mailQueue mails the |
| // link only if the allowlist holds it — after the response, in a |
| // worker, so not even the response time differs (signin_mail.go) — |
| @@ -43,97 +40,3 @@ import ( |
| func beginSignin(origin string, begin http.HandlerFunc, render func(http.ResponseWriter, *http.Request, string)) http.HandlerFunc { |
| return connect.OAuthContinuation(origin, begin, render) |
| } |
| - |
| -// authRefusal is the bare-text body rastrillo/auth's admit() writes, |
| -// with a 403, when a verified address is not admitted. rastrillo at the |
| -// version pinned here has no hook to render anything else, so |
| -// doorOnRefusal recognises this exact answer; TestKeymailRefusalIsTheDoor |
| -// pins it, so a bump that changes the sentence fails loudly. |
| -const authRefusal = "This address is verified but not admitted here." |
| - |
| -// doorOnRefusal wraps rauth's Callback and Verify, the handlers that end |
| -// in admission, and turns its bare-text refusal into the door, rendered |
| -// in the refusal's own 403 by door (the sign-in page) with the refusal |
| -// marked on the request context (refusedHere). Every other answer, |
| -// cookies included, passes through untouched. |
| -// |
| -// Rendered, not redirected to ?err=refused: a query parameter is |
| -// something anybody can type, and the sentence means "you proved this |
| -// address and it is not admitted" (Astra, round 3 on #28). Remove it |
| -// when rastrillo has 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()) == authRefusal { |
| - for _, c := range rec.header.Values("Set-Cookie") { |
| - w.Header().Add("Set-Cookie", c) |
| - } |
| - w.Header().Set("Cache-Control", "no-store") |
| - 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()) |
| - } |
| -} |
| - |
| -// 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 |
| -} |
| - |
| -// forcedStatus answers with status whatever status the page writes. |
| -type forcedStatus struct { |
| - http.ResponseWriter |
| - status int |
| - wrote bool |
| -} |
| - |
| -func (f *forcedStatus) WriteHeader(int) { |
| - if !f.wrote { |
| - f.wrote = true |
| - f.ResponseWriter.WriteHeader(f.status) |
| - } |
| -} |
| - |
| -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 |
| - } |
| - return b.body.Write(p) |
| -} |
diff --git a/internal/calendar/credits/notices.json b/internal/calendar/credits/notices.json| index 751ad94..683e00f 100644 |
| --- a/internal/calendar/credits/notices.json |
| +++ b/internal/calendar/credits/notices.json |
| @@ -35,7 +35,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": [ |
| { |
| @@ -48,6 +48,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": "github.com/carlosframework/rastrillo", |
| "Version": "v0.23.0", |
diff --git a/internal/calendar/handlers_misc.go b/internal/calendar/handlers_misc.go| index 9ead269..d0f6d73 100644 |
| --- a/internal/calendar/handlers_misc.go |
| +++ b/internal/calendar/handlers_misc.go |
| @@ -31,6 +31,18 @@ func (h *handlers) share(w http.ResponseWriter, r *http.Request) { |
| h.fail(w, r, "list feeds", err) |
| return |
| } |
| + // The database hands every time back in UTC (rastrillo's |
| + // _timezone=UTC); the page prints them in the viewer's own zone, as |
| + // every other surface does. |
| + if loc, err := Zone(personZone(p)); err == nil { |
| + for i := range feeds { |
| + feeds[i].CreatedAt = feeds[i].CreatedAt.In(loc) |
| + if at := feeds[i].LastPolledAt; at != nil { |
| + local := at.In(loc) |
| + feeds[i].LastPolledAt = &local |
| + } |
| + } |
| + } |
| render(w, "share", h.page(r, "Share "+cal.Name, map[string]any{ |
| "Calendar": cal, |
| "Feeds": feeds, |
diff --git a/internal/calendar/home_router_test.go b/internal/calendar/home_router_test.go| index 6ba2bd4..da36341 100644 |
| --- a/internal/calendar/home_router_test.go |
| +++ b/internal/calendar/home_router_test.go |
| @@ -82,7 +82,15 @@ func (f *homeRouterFixture) local(t *testing.T) { |
| if err != nil || link == "" { |
| t.Fatal("no captured local verification link") |
| } |
| - landing := homeRedirect(t, f.do("GET", u.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 callback. |
| + open := f.do("GET", u.RequestURI(), "") |
| + action, token, ok := ConfirmFormForTest(open.Body.String()) |
| + if open.Code != 200 || !ok || !strings.Contains(open.Body.String(), "estilo-door") { |
| + t.Fatalf("Home-local link: %d, want the confirm door\n%s", open.Code, open.Body.String()) |
| + } |
| + landing := homeRedirect(t, f.do("POST", action, url.Values{"token": {token}}.Encode())) |
| if landing != "/auth/local/complete" { |
| t.Fatal("wrong local landing", landing) |
| } |
diff --git a/internal/calendar/home_security_test.go b/internal/calendar/home_security_test.go| index 4ef6307..59ce13e 100644 |
| --- a/internal/calendar/home_security_test.go |
| +++ b/internal/calendar/home_security_test.go |
| @@ -1,43 +1,29 @@ |
| package calendar |
| import ( |
| - "amadan.net/rastrillo/rastrillo" |
| - "net/http" |
| "net/http/httptest" |
| - "strings" |
| "testing" |
| ) |
| +// TestHomeCSPPreservesPinnedFrameworkDirectives: with Home on, connect's |
| +// middleware replaces the policy with connect.FormPolicy. Through the |
| +// production stack (App.ServeOptions), every directive must still be |
| +// rastrillo's default but two: form-action also names the issuer, and |
| +// style-src is 'self' 'unsafe-inline', which the day and week grids' |
| +// chip positions need (ContentSecurityPolicy says why). |
| func TestHomeCSPPreservesPinnedFrameworkDirectives(t *testing.T) { |
| - mux := http.NewServeMux() |
| - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }) |
| - baselineHandler, closeBaseline, err := rastrillo.Handler(rastrillo.Options{Mux: mux}) |
| - if err != nil { |
| - t.Fatal(err) |
| - } |
| - if closeBaseline != nil { |
| - defer closeBaseline() |
| - } |
| - rec := httptest.NewRecorder() |
| - baselineHandler.ServeHTTP(rec, httptest.NewRequest("GET", homeTestOrigin+"/", nil)) |
| - baseline := rec.Header().Get("Content-Security-Policy") |
| - want := strings.Replace(baseline, "form-action 'self'", "form-action 'self' "+homeTestIssuer, 1) |
| - if want == baseline { |
| - t.Fatal("framework form-action changed") |
| - } |
| f := newHomeRouterFixture(t) |
| - wrapped, done, err := rastrillo.Handler(rastrillo.Options{Mux: f.app.mux}) |
| - if err != nil { |
| - t.Fatal(err) |
| - } |
| - if done != nil { |
| - defer done() |
| - } |
| + wrapped := productionHandler(t, f.app) |
| for _, path := range []string{"/signin/local", "/auth/start", "/account/home", "/signed-out"} { |
| rec := httptest.NewRecorder() |
| wrapped.ServeHTTP(rec, httptest.NewRequest("GET", homeTestOrigin+path, nil)) |
| - if got := rec.Header().Get("Content-Security-Policy"); got != want { |
| - t.Fatalf("policy drift at %s: %q", path, got) |
| + got := rec.Header().Values("Content-Security-Policy") |
| + if len(got) != 1 { |
| + t.Fatalf("%s: %d policies, want one: %q", path, len(got), got) |
| } |
| + samePolicyBut(t, path, got[0], map[string]string{ |
| + "form-action": "'self' " + homeTestIssuer, |
| + "style-src": "'self' 'unsafe-inline'", |
| + }) |
| } |
| } |
diff --git a/internal/calendar/keymail_signin_test.go b/internal/calendar/keymail_signin_test.go| index 64241e0..18127d7 100644 |
| --- a/internal/calendar/keymail_signin_test.go |
| +++ b/internal/calendar/keymail_signin_test.go |
| @@ -251,10 +251,21 @@ func TestKeymailRefusalIsTheDoor(t *testing.T) { |
| u := continuation(t, address, begin) |
| return f.get("/auth/callback?"+url.Values{"state": {u.Query().Get("state")}, "code": {f.km.Approve(address)}}.Encode(), begin.Result().Cookies()...) |
| } |
| - if cb := roundTrip(keymailMember); cb.Code != http.StatusSeeOther || strings.Contains(cb.Header().Get("Location"), "err=") { |
| + cb := roundTrip(keymailMember) |
| + if cb.Code != http.StatusSeeOther || strings.Contains(cb.Header().Get("Location"), "err=") { |
| t.Fatalf("permitted callback: %d %q", cb.Code, cb.Header().Get("Location")) |
| } |
| - cb := roundTrip(keymailStranger) |
| + // The continuation ends signed in: standalone, the session |
| + // opens a view that names the person; with Home, the callback |
| + // hands over to connect's local completion. |
| + if home { |
| + if loc := cb.Header().Get("Location"); loc != connect.LocalCompletePath { |
| + t.Fatalf("permitted callback with Home lands on %q, want %s", loc, connect.LocalCompletePath) |
| + } |
| + } else if day := f.get("/agenda", cb.Result().Cookies()...); day.Code != http.StatusOK || !strings.Contains(day.Body.String(), `aria-label="Account: `+keymailMember+`"`) { |
| + t.Fatalf("GET /agenda with the Keymail session = %d, or it does not name %s", day.Code, keymailMember) |
| + } |
| + cb = roundTrip(keymailStranger) |
| if cb.Code != http.StatusForbidden || cb.Header().Get("Location") != "" { |
| t.Fatalf("non-permitted callback: %d %q, want 403 and the door itself (bare text is the bug)\n%s", cb.Code, cb.Header().Get("Location"), cb.Body.String()) |
| } |
diff --git a/internal/calendar/render.go b/internal/calendar/render.go| index f01e82a..fe4d4d2 100644 |
| --- a/internal/calendar/render.go |
| +++ b/internal/calendar/render.go |
| @@ -159,7 +159,10 @@ func (h *handlers) page(r *http.Request, title string, data any) PageData { |
| pd.Address = id.Address |
| } |
| pd.Chrome = calendarChrome(r, pd.SignedIn, pd.Address, mode, title, h.directory) |
| - if r.URL.Path == "/signin" || r.URL.Path == "/signin/local" || r.URL.Path == "/signed-out" { |
| + // Every door — the form, its notices, the confirm step an emailed |
| + // link opens on, and the refusal auth answers /auth/* with — shows |
| + // the brand alone: no account menu, no Sign in. |
| + if _, door := data.(signinData); door || r.URL.Path == "/signin" || r.URL.Path == "/signin/local" || r.URL.Path == "/signed-out" { |
| pd.Chrome.Account, pd.Chrome.SignIn = nil, nil |
| } |
| return pd |
| @@ -183,6 +186,19 @@ func render(w http.ResponseWriter, name string, data any) { |
| buf.WriteTo(w) |
| } |
| +// renderStatus is render answering with status, written only once the |
| +// page has rendered, so a template error is still a clean 500. |
| +func renderStatus(w http.ResponseWriter, name string, status int, data any) { |
| + var buf bytes.Buffer |
| + if err := pages[name].ExecuteTemplate(&buf, "layout", data); err != nil { |
| + http.Error(w, "Something went wrong.", http.StatusInternalServerError) |
| + return |
| + } |
| + w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| + w.WriteHeader(status) |
| + buf.WriteTo(w) |
| +} |
| + |
| // ErrorPage renders the shared error page for status, with ref being |
| // rastrillo's own request reference or empty when there is none. It is |
| // the rastrillo.ErrorPageFunc cmd/calendar/main.go points |
diff --git a/internal/calendar/routes.go b/internal/calendar/routes.go| index 8058d10..2ad2dd1 100644 |
| --- a/internal/calendar/routes.go |
| +++ b/internal/calendar/routes.go |
| @@ -57,18 +57,27 @@ func (a *App) routes(h *handlers, authPlugin *rauth.Auth, list allowlist, origin |
| r.Get("/signin", h.signin) |
| r.Get("/signed-out", h.signedOut) |
| } |
| + // The handlers rastrillo/auth expects an app to mount. Verify answers |
| + // GET with the confirm door (handlers.renderConfirm), which spends |
| + // nothing, and POST with the redemption, so it is mounted for both |
| + // methods in both modes: without the POST the door's Sign in button |
| + // gets a 405 and nobody signs in. The path is baked into every |
| + // emailed link, so moving it later invalidates links already in |
| + // people's inboxes. A verified address the allowlist does not hold |
| + // is answered by auth's Refused hook (handlers.refused), inside |
| + // Callback and Verify. |
| begin := beginSignin(origin, authPlugin.Begin, h.renderKeymail) |
| - callback := doorOnRefusal(authPlugin.Callback, h.signin) |
| - verify := doorOnRefusal(authPlugin.Verify, h.signin) |
| if a.home != nil { |
| r.Post("/signin", a.home.routes.LocalGuard(begin)) |
| - r.Get("/auth/callback", a.home.routes.LocalGuard(callback)) |
| - r.Get("/auth/verify", a.home.routes.LocalGuard(verify)) |
| + r.Get("/auth/callback", a.home.routes.LocalGuard(authPlugin.Callback)) |
| + r.Get("/auth/verify", a.home.routes.LocalGuard(authPlugin.Verify)) |
| + r.Post("/auth/verify", a.home.routes.LocalGuard(authPlugin.Verify)) |
| r.Post("/signout", a.home.routes.Signout) |
| } else { |
| r.Post("/signin", begin) |
| - r.Get("/auth/callback", callback) |
| - r.Get("/auth/verify", verify) |
| + r.Get("/auth/callback", authPlugin.Callback) |
| + r.Get("/auth/verify", authPlugin.Verify) |
| + r.Post("/auth/verify", authPlugin.Verify) |
| r.Post("/signout", signOutToSignedOut(authPlugin.Signout)) |
| } |
diff --git a/internal/calendar/serving.go b/internal/calendar/serving.go| new file mode 100644 |
| index 0000000..f80a6b3 |
| --- /dev/null |
| +++ b/internal/calendar/serving.go |
| @@ -0,0 +1,48 @@ |
| +package calendar |
| + |
| +import ( |
| + "amadan.net/rastrillo/rastrillo" |
| +) |
| + |
| +// ContentSecurityPolicy is the policy Calendar 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 Calendar draws its time with |
| +// them: |
| +// |
| +// - the day and week grids place each event by its start and length |
| +// (top, height, and the packer's left and width, in percent of the |
| +// day: day.html, week.html, pack.go), and each half-hour slot link |
| +// the same way. They are per event and continuous, so they cannot |
| +// be classes in calendar.css; without them every chip and slot |
| +// collapses to the top of its column; |
| +// - every chip, and each calendar's swatch on the Calendars page, |
| +// carries its calendar's hue as --cal-hue (_chip.html, |
| +// calendars.html). The hue is per calendar (personalHue), and |
| +// calendar.css mixes every colour from it. |
| +// |
| +// Hashing them is not possible (the values are per event), and the other |
| +// directives are unchanged, so scripts stay same-origin only and framing, |
| +// base and form targets stay locked. The constant layout that used to be |
| +// style attributes is in calendar.css now. 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 Calendar serves: its router, 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) rastrillo.Options { |
| + opts.Mux = a.Handler() |
| + // The same page a handler's own error path renders, so a panic and |
| + // a handled failure look identical to whoever hits one. |
| + opts.ErrorPage = ErrorPage |
| + opts.CSP = ContentSecurityPolicy |
| + return opts |
| +} |
diff --git a/internal/calendar/serving_test.go b/internal/calendar/serving_test.go| new file mode 100644 |
| index 0000000..f36151d |
| --- /dev/null |
| +++ b/internal/calendar/serving_test.go |
| @@ -0,0 +1,153 @@ |
| +package calendar |
| + |
| +import ( |
| + "io" |
| + "log/slog" |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/rastrillo/rastrillo" |
| +) |
| + |
| +// productionHandler is app behind rastrillo's serving stack, built from |
| +// the same ServeOptions main hands to rastrillo.Serve: security headers, |
| +// CSP and all. app.Handler() alone carries no CSP, so a test that serves |
| +// only the router cannot see what a browser is told. |
| +func productionHandler(t *testing.T, app *App) http.Handler { |
| + t.Helper() |
| + h, closeFn, err := rastrillo.Handler(app.ServeOptions(rastrillo.Options{Logger: slog.New(slog.NewTextHandler(io.Discard, nil))})) |
| + if err != nil { |
| + t.Fatalf("rastrillo.Handler: %v", err) |
| + } |
| + if closeFn != nil { |
| + t.Cleanup(func() { _ = closeFn() }) |
| + } |
| + return h |
| +} |
| + |
| +// defaultPolicy is rastrillo's own CSP, read off a handler built with no |
| +// CSP of its own. |
| +func defaultPolicy(t *testing.T) map[string]string { |
| + t.Helper() |
| + bare, closeFn, err := rastrillo.Handler(rastrillo.Options{Mux: http.NewServeMux(), Logger: slog.New(slog.NewTextHandler(io.Discard, nil))}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if closeFn != nil { |
| + defer closeFn() |
| + } |
| + rec := httptest.NewRecorder() |
| + bare.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil)) |
| + def := directives(rec.Header().Get("Content-Security-Policy")) |
| + if len(def) == 0 { |
| + t.Fatal("rastrillo set no default CSP") |
| + } |
| + return def |
| +} |
| + |
| +// 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 |
| +} |
| + |
| +// samePolicyBut fails unless got is rastrillo's default directive for |
| +// directive, except where except names a different value. |
| +func samePolicyBut(t *testing.T, where, got string, except map[string]string) { |
| + t.Helper() |
| + def, ours := defaultPolicy(t), directives(got) |
| + for name, value := range def { |
| + if want, ok := except[name]; ok { |
| + value = want |
| + } |
| + if ours[name] != value { |
| + t.Errorf("%s: %s is %q, want %q", where, name, ours[name], value) |
| + } |
| + } |
| + for name := range ours { |
| + if _, ok := def[name]; !ok { |
| + t.Errorf("%s adds %s, which rastrillo's default does not have", where, name) |
| + } |
| + } |
| +} |
| + |
| +// TestProductionCSPIsTheDefaultBarStyles: Calendar's policy is |
| +// rastrillo's default with style-src alone widened to 'unsafe-inline' |
| +// (ContentSecurityPolicy says why). Every other directive must match the |
| +// framework's, so a rastrillo bump that tightens anything else fails here |
| +// rather than being silently undone by our constant. A hash or nonce |
| +// beside 'unsafe-inline' would make the browser ignore 'unsafe-inline' |
| +// (CSP3), so style-src must be exactly that. |
| +func TestProductionCSPIsTheDefaultBarStyles(t *testing.T) { |
| + samePolicyBut(t, "ContentSecurityPolicy", ContentSecurityPolicy, map[string]string{"style-src": "'self' 'unsafe-inline'"}) |
| +} |
| + |
| +// TestStandaloneServesInlineStyles: through the production stack, a day |
| +// view with an event on it — whose chip is placed by a style attribute — |
| +// is served under exactly Calendar's policy, which lets that attribute |
| +// apply. Under rastrillo fb806a2's default the chip and every slot link |
| +// collapse to the top of the column (TestPlacedChipsSurviveTheProductionCSP |
| +// shows it in a browser). |
| +func TestStandaloneServesInlineStyles(t *testing.T) { |
| + app := NewForTest(t, t.TempDir()) |
| + session := app.SignInForTest(t, "someone@example.com") |
| + person := app.PersonByAddressForTest(t, "someone@example.com") |
| + calendarID, err := app.store.DefaultCalendarID(t.Context(), person.ID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := app.store.CreateEvent(t.Context(), person.ID, EventInput{CalendarID: calendarID, Title: "Standup", StartLocal: "2026-09-10T09:30", EndLocal: "2026-09-10T10:00", TZ: "UTC"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + h := productionHandler(t, app) |
| + rec := httptest.NewRecorder() |
| + h.ServeHTTP(rec, RequestForTest(session, http.MethodGet, "/day/2026-09-10", nil)) |
| + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `cal-placed`) || !strings.Contains(rec.Body.String(), `style="top:`) { |
| + t.Fatalf("day view = %d, or it places no chip", rec.Code) |
| + } |
| + policies := rec.Header().Values("Content-Security-Policy") |
| + if len(policies) != 1 || policies[0] != ContentSecurityPolicy { |
| + t.Fatalf("CSP = %q, want exactly Calendar's policy", policies) |
| + } |
| +} |
| + |
| +// TestTheConfirmDoorKeepsEveryPolicy: the confirm door, through the |
| +// production stack, carries Calendar's policy and auth's added |
| +// frame-ancestors 'none' (browsers enforce every CSP header, so the |
| +// second only narrows), and the headers auth sets before RenderConfirm |
| +// survive the render: the URL is a live credential. |
| +func TestTheConfirmDoorKeepsEveryPolicy(t *testing.T) { |
| + app := NewForTest(t, t.TempDir()) |
| + form := strings.NewReader("address=someone%40example.com&force=1") |
| + req := httptest.NewRequest(http.MethodPost, "/signin", form) |
| + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + req.Header.Set("Origin", TestOrigin) |
| + app.mux.ServeHTTP(httptest.NewRecorder(), req) |
| + app.signinMail.wait() |
| + target, err := linkPath(magicLinkPattern.FindString(app.mailRecorder.last())) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + rec := httptest.NewRecorder() |
| + productionHandler(t, app).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, target, nil)) |
| + if action, _, ok := ConfirmFormForTest(rec.Body.String()); rec.Code != http.StatusOK || !ok || action != "/auth/verify" { |
| + t.Fatalf("confirm door = %d, action %q", rec.Code, action) |
| + } |
| + got := rec.Header().Values("Content-Security-Policy") |
| + if len(got) != 2 || got[0] != ContentSecurityPolicy || got[1] != "frame-ancestors 'none'" { |
| + t.Fatalf("confirm door CSP = %q, want Calendar's 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/calendar/signin.go b/internal/calendar/signin.go| index 5665274..36f6518 100644 |
| --- a/internal/calendar/signin.go |
| +++ b/internal/calendar/signin.go |
| @@ -6,6 +6,7 @@ import ( |
| "amadan.net/oficina/estilo.git/chrome" |
| "amadan.net/oficina/estilo.git/sky" |
| "amadan.net/oficina/home/connect" |
| + rauth "amadan.net/rastrillo/rastrillo/auth" |
| ) |
| // The sign-in pages are the Oficina door (estilo chrome): one card on the |
| @@ -30,14 +31,72 @@ import ( |
| // second link is one click away, as it was when the form stayed below |
| // the notice. |
| func (h *handlers) signin(w http.ResponseWriter, r *http.Request) { |
| - q := r.URL.Query() |
| - form := &chrome.SignIn{ |
| - Icon: assets.Path("static/brand/icon.svg"), |
| - App: "Calendar", |
| - Sky: sky.For(r.Host), |
| - Action: "/signin", |
| - OpenSource: &chrome.Link{Href: "/help/open-source", Label: "Open source"}, |
| + h.renderSignin(w, r, http.StatusOK, "") |
| +} |
| + |
| +// refusal is the one sentence the door says to an address that proved |
| +// itself and is not admitted (handlers.refused). |
| +const refusal = "That address can’t sign in to Calendar. Use a different address, or ask whoever runs this team to add it." |
| + |
| +// refused is auth's Refused hook: Keymail or an emailed link verified the |
| +// address and the allowlist does not hold it. It answers with the door, |
| +// carrying the refusal, in a 403 — no session is minted, and auth has |
| +// already ended the attempt. |
| +// |
| +// Rendered, not redirected to ?err=refused: a query parameter is |
| +// something anybody can type, and the sentence means "you proved this |
| +// address and it is not admitted" (Astra, round 3 on #28). Only this hook |
| +// can say it, and auth calls it only after the address is proved, so |
| +// saying it is no membership oracle. |
| +func (h *handlers) refused(w http.ResponseWriter, r *http.Request, _ rauth.Identity) { |
| + w.Header().Set("Cache-Control", "no-store") |
| + h.renderSignin(w, r, http.StatusForbidden, refusal) |
| +} |
| + |
| +// door is the sign-in door's chrome for this instance: Calendar's mark |
| +// and name on its sky. |
| +func door(r *http.Request) chrome.SignIn { |
| + return chrome.SignIn{ |
| + Icon: assets.Path("static/brand/icon.svg"), |
| + App: "Calendar", |
| + Sky: sky.For(r.Host), |
| } |
| +} |
| + |
| +// renderConfirm is auth'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. |
| +// |
| +// 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 Calendar, not rastrillo's plain English fallback. auth has |
| +// already set no-store, Referrer-Policy: strict-origin (the URL is a |
| +// live credential) and the framing refusals; render adds nothing that |
| +// replaces them. d.Token is untrusted query input and reaches the page |
| +// only through html/template. |
| +func (h *handlers) renderConfirm(w http.ResponseWriter, r *http.Request, d rauth.ConfirmPageData) { |
| + c := signinConfirm{Door: door(r), Action: d.Action, Token: d.Token} |
| + c.Door.Lede = "Select Sign in to finish signing in." |
| + render(w, "signin", h.page(r, c.Door.Page(), signinData{Confirm: &c})) |
| +} |
| + |
| +// 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 |
| +} |
| + |
| +// renderSignin draws the sign-in form in status. refusedText, when set, |
| +// is handlers.refused's sentence and replaces whatever the query says: |
| +// it is never read from the query. |
| +func (h *handlers) renderSignin(w http.ResponseWriter, r *http.Request, status int, refusedText string) { |
| + q := r.URL.Query() |
| + form := door(r) |
| + form.Action = "/signin" |
| + form.OpenSource = &chrome.Link{Href: "/help/open-source", Label: "Open source"} |
| 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 |
| @@ -57,18 +116,18 @@ func (h *handlers) signin(w http.ResponseWriter, r *http.Request) { |
| default: |
| form.Error = "That link did not work. Ask for a new one." |
| } |
| - if refusedHere(r) { |
| + if refusedText != "" { |
| // Keymail or a link verified the address and the allowlist does |
| - // not hold it, in this very response (doorOnRefusal). Never from |
| - // the query. The form is the way on. |
| - form.Error = "That address can’t sign in to Calendar. Use a different address, or ask whoever runs this team to add it." |
| + // not hold it, in this very response (handlers.refused). Never |
| + // from the query. The form is the way on. |
| + form.Error, form.AddressError = refusedText, "" |
| } |
| sent := q.Get("sent") == "1" |
| failed := form.Error != "" || form.AddressError != "" |
| switch { |
| case sent && !failed: |
| notice := chrome.SignInNotice{Icon: form.Icon, App: form.App, Sky: form.Sky}.Sent(r.URL.Path) |
| - render(w, "signin", h.page(r, notice.Page(), signinData{Sent: ¬ice})) |
| + renderStatus(w, "signin", status, h.page(r, notice.Page(), signinData{Sent: ¬ice})) |
| case sent: |
| // Somebody who asks for a second link after a failed one should |
| // see both facts, not whichever the page happened to check first: |
| @@ -77,17 +136,19 @@ func (h *handlers) signin(w http.ResponseWriter, r *http.Request) { |
| form.Lede = chrome.English.SentBody |
| fallthrough |
| default: |
| - render(w, "signin", h.page(r, form.Page(), signinData{Form: form})) |
| + renderStatus(w, "signin", status, h.page(r, form.Page(), signinData{Form: &form})) |
| } |
| } |
| // signinData is the sign-in page's own view data: exactly one of the |
| // door cards. Continue is set beside Sent on the page between the form |
| // and Keymail (renderKeymail): its meta refresh goes on to Keymail. |
| +// Confirm is the step an emailed link opens on (renderConfirm). |
| type signinData struct { |
| Form *chrome.SignIn |
| Sent *chrome.SignInNotice |
| SignedOut *chrome.SignInNotice |
| + Confirm *signinConfirm |
| Continue *connect.OAuthData |
| } |
diff --git a/internal/calendar/static/calendar.css b/internal/calendar/static/calendar.css| index f1a207c..ba59e6c 100644 |
| --- a/internal/calendar/static/calendar.css |
| +++ b/internal/calendar/static/calendar.css |
| @@ -675,3 +675,36 @@ html { scroll-padding-block-start: var(--estilo-bar-block-size); } |
| [cal-book-details] > div:has(> button[type="submit"]) { |
| flex-wrap: wrap; |
| } |
| + |
| +/* ── Layout that used to be style attributes ────────────────────────── |
| + * rastrillo's CSP (fb806a2) refuses style attributes by default. These |
| + * were constant, so they live here; what is still an attribute is a |
| + * per-event or per-calendar value (serving.go says which, and why the |
| + * policy keeps 'unsafe-inline' for them). */ |
| +[cal-day-chips] { |
| + display: flex; |
| + flex-direction: column; |
| + gap: var(--rst-sp-1); |
| + padding: var(--rst-sp-3); |
| +} |
| +[cal-box-body] { padding: var(--rst-sp-3); } |
| +[cal-reminder] { |
| + display: flex; |
| + align-items: center; |
| + gap: var(--rst-sp-2); |
| +} |
| +[cal-reminder-add] { |
| + display: flex; |
| + align-items: flex-end; |
| + gap: var(--rst-sp-2); |
| + margin-top: var(--rst-sp-3); |
| +} |
| +[cal-feed-new] { margin-top: var(--rst-sp-4); } |
| +/* A calendar's swatch on the Calendars list: its hue, the one value the |
| + * attribute still carries. */ |
| +[cal-dot] { |
| + background: hsl(var(--cal-hue, 220) 55% 45%); |
| + border-radius: var(--rst-radius-pill); |
| + inline-size: 12px; |
| + block-size: 12px; |
| +} |
diff --git a/internal/calendar/static/datetime.js b/internal/calendar/static/datetime.js| index 285d8ea..1442fc8 100644 |
| --- a/internal/calendar/static/datetime.js |
| +++ b/internal/calendar/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/calendar/static/select.js b/internal/calendar/static/select.js| index 127b501..39a975e 100644 |
| --- a/internal/calendar/static/select.js |
| +++ b/internal/calendar/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/calendar/static/tokens.css b/internal/calendar/static/tokens.css| index 10f6da5..6886854 100644 |
| --- a/internal/calendar/static/tokens.css |
| +++ b/internal/calendar/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/calendar/templates/agenda.html b/internal/calendar/templates/agenda.html| index f81e9b7..27bbb25 100644 |
| --- a/internal/calendar/templates/agenda.html |
| +++ b/internal/calendar/templates/agenda.html |
| @@ -25,7 +25,7 @@ |
| <header rst-box-head> |
| <h2><a href="{{.Href}}">{{.Title}}</a>{{if .Today}} — today{{end}}</h2> |
| </header> |
| - <div style="display:flex;flex-direction:column;gap:var(--rst-sp-1);padding:var(--rst-sp-3)"> |
| + <div cal-day-chips> |
| {{range .Chips}}{{template "chip" .}}{{end}} |
| </div> |
| </section> |
diff --git a/internal/calendar/templates/calendars.html b/internal/calendar/templates/calendars.html| index 1a86385..f2e8e5f 100644 |
| --- a/internal/calendar/templates/calendars.html |
| +++ b/internal/calendar/templates/calendars.html |
| @@ -9,8 +9,7 @@ |
| <div rst-list> |
| {{range .Data.Calendars}} |
| <div rst-row> |
| - <span rst-row-lead aria-hidden="true" |
| - style="background: hsl({{.Hue}} 55% 45%); border-radius: var(--rst-radius-pill); inline-size: 12px; block-size: 12px;"></span> |
| + <span rst-row-lead cal-dot aria-hidden="true" style="--cal-hue: {{.Hue}}"></span> |
| <span rst-row-main> |
| <a href="/calendars/{{.ID}}/share">{{.Name}}</a> |
| {{/* The role is said in words. Colour and position carry none of |
diff --git a/internal/calendar/templates/event.html b/internal/calendar/templates/event.html| index cfdde13..fd68a1a 100644 |
| --- a/internal/calendar/templates/event.html |
| +++ b/internal/calendar/templates/event.html |
| @@ -53,7 +53,7 @@ |
| {{with .Data.RuleText}} |
| <section rst-box> |
| <header rst-box-head><h2>Repeats</h2></header> |
| - <div style="padding:var(--rst-sp-3)"> |
| + <div cal-box-body> |
| <p>{{.}}</p> |
| {{if $.Data.Event.RRuleForeign}} |
| {{template "callout" dict "Tone" "info" "Body" "This rule came from another calendar and is outside the set this app can edit. It is kept exactly as it arrived, and exported unchanged."}} |
| @@ -64,13 +64,12 @@ |
| <section rst-box> |
| <header rst-box-head><h2>Your reminders</h2></header> |
| - <div style="padding:var(--rst-sp-3)"> |
| + <div cal-box-body> |
| {{/* Yours only. Whether a colleague wants a warning is a fact |
| about them, and not something this page should carry. */}} |
| {{if .Data.Reminders}} |
| {{range .Data.Reminders}} |
| - <form method="post" action="/events/{{$.Data.Event.ID}}/reminders/remove" |
| - style="display:flex;align-items:center;gap:var(--rst-sp-2)"> |
| + <form cal-reminder method="post" action="/events/{{$.Data.Event.ID}}/reminders/remove"> |
| <input type="hidden" name="reminder_id" value="{{.ID}}"> |
| {{/* class, not an attribute: rst-grow is one of the seven |
| rastrillo utilities that keep the class spelling and have no |
| @@ -83,8 +82,7 @@ |
| <p>No reminder for this one.</p> |
| {{end}} |
| - <form method="post" action="/events/{{.Data.Event.ID}}/reminders" |
| - style="display:flex;align-items:flex-end;gap:var(--rst-sp-2);margin-top:var(--rst-sp-3)"> |
| + <form cal-reminder-add method="post" action="/events/{{.Data.Event.ID}}/reminders"> |
| {{template "field-select" dict |
| "ID" "offset_min" "Name" "offset_min" "Label" "Remind me" "Short" true |
| "Options" (list |
diff --git a/internal/calendar/templates/share.html b/internal/calendar/templates/share.html| index dd21c2c..65d23fd 100644 |
| --- a/internal/calendar/templates/share.html |
| +++ b/internal/calendar/templates/share.html |
| @@ -46,7 +46,7 @@ |
| "Body" "A link lets another calendar app read this one. Nothing is shared until you make one."}} |
| {{end}} |
| -<form method="post" action="/calendars/{{.Data.Calendar.ID}}/feeds" style="margin-top:var(--rst-sp-4)"> |
| +<form cal-feed-new method="post" action="/calendars/{{.Data.Calendar.ID}}/feeds"> |
| <button rst-btn="primary" type="submit">Create a subscription link</button> |
| </form> |
| {{end}} |
diff --git a/internal/calendar/templates/signin.html b/internal/calendar/templates/signin.html| index 6668e24..573ae01 100644 |
| --- a/internal/calendar/templates/signin.html |
| +++ b/internal/calendar/templates/signin.html |
| @@ -1,7 +1,8 @@ |
| {{define "title"}}{{.Title}}{{end}} |
| {{/* The Oficina door (estilo chrome). Exactly one card: the form, the |
| check-your-email notice, the card between the form and Keymail (with |
| - .Data.Continue's meta refresh in head), or where Home sign-out lands. |
| + .Data.Continue's meta refresh in head), where Home sign-out lands, or |
| + the confirm step an emailed link opens on. |
| The form posts its address as "address", the name the auth plugin |
| reads. */}} |
| {{define "head"}}{{with .Data.Continue}}{{template "home-oauth-head" .}}{{end}}{{end}} |
| @@ -9,4 +10,18 @@ |
| {{- with .Data.Form}}{{template "estilo-signin" .}}{{end}} |
| {{- with .Data.Sent}}{{template "estilo-signin-sent" .}}{{end}} |
| {{- with .Data.SignedOut}}{{template "estilo-signed-out" .}}{{end}} |
| +{{- /* The confirm step an emailed link opens on (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. The token is the link's own query value, escaped here. */}} |
| +{{- with .Data.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"}}{{end}} |
| {{end}} |
diff --git a/internal/calendar/testing.go b/internal/calendar/testing.go| index 28bfd08..0c45f21 100644 |
| --- a/internal/calendar/testing.go |
| +++ b/internal/calendar/testing.go |
| @@ -5,6 +5,7 @@ import ( |
| "context" |
| "database/sql" |
| "fmt" |
| + "html" |
| "io" |
| "log/slog" |
| "net/http" |
| @@ -12,6 +13,7 @@ import ( |
| "net/url" |
| "os" |
| "path/filepath" |
| + "regexp" |
| "strings" |
| "time" |
| ) |
| @@ -60,7 +62,8 @@ func NewForTest(t TB, dir string) *App { |
| // session cookie it minted. |
| // |
| // It drives the actual flow — POST /signin, read the emailed link out |
| -// of the development mail recorder, follow it — rather than writing a |
| +// of the development mail recorder, open it and press the confirm |
| +// door's Sign in — rather than writing a |
| // session row directly. A helper that forged a session would keep |
| // passing after the real sign-in path broke, which is the one thing a |
| // sign-in test must not do. |
| @@ -87,17 +90,44 @@ func (a *App) SignInForTest(t TB, address string) *http.Cookie { |
| t.Fatalf("calendar: SignInForTest: %v", err) |
| } |
| + // Opening the link is the confirm door, which spends nothing; its |
| + // Sign in button POSTs the token back, and that POST signs in. |
| + open := httptest.NewRecorder() |
| + a.mux.ServeHTTP(open, httptest.NewRequest(http.MethodGet, target, nil)) |
| + action, token, ok := ConfirmFormForTest(open.Body.String()) |
| + if open.Code != http.StatusOK || !ok { |
| + t.Fatalf("calendar: SignInForTest: opening the link answered %d with no confirm form", open.Code) |
| + } |
| + req = httptest.NewRequest(http.MethodPost, action, strings.NewReader(url.Values{"token": {token}}.Encode())) |
| + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + req.Header.Set("Origin", TestOrigin) |
| + req.Header.Set("Sec-Fetch-Site", "same-origin") |
| rec := httptest.NewRecorder() |
| - a.mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, target, nil)) |
| + a.mux.ServeHTTP(rec, req) |
| for _, c := range rec.Result().Cookies() { |
| if c.Name == a.auth.SessionCookie() { |
| return c |
| } |
| } |
| - t.Fatalf("calendar: SignInForTest: following the link minted no session cookie (status %d)", rec.Code) |
| + t.Fatalf("calendar: SignInForTest: confirming the link minted no session cookie (status %d)", rec.Code) |
| return nil |
| } |
| +// confirmForm is the confirm door's one form, as a browser reads it. |
| +var confirmForm = regexp.MustCompile(`<form[^>]*method="post" action="([^"]+)">\s*<input type="hidden" name="token" value="([^"]+)">`) |
| + |
| +// ConfirmFormForTest reads the confirm door an emailed link opens on: the |
| +// form's action and the token it posts back, unescaped. Read off the |
| +// page rather than the link, so a door that dropped or mangled the token |
| +// fails the test that signs in through it. |
| +func ConfirmFormForTest(body string) (action, token string, ok bool) { |
| + m := confirmForm.FindStringSubmatch(body) |
| + if m == nil { |
| + return "", "", false |
| + } |
| + return html.UnescapeString(m[1]), html.UnescapeString(m[2]), true |
| +} |
| + |
| // RequestForTest builds a request already carrying session, with the |
| // same-origin evidence csrf.Protect requires on a state-changing |
| // method. Every current browser sends that evidence on a form |
diff --git a/internal/calendartest/auth_test.go b/internal/calendartest/auth_test.go| index 3f20268..eecb446 100644 |
| --- a/internal/calendartest/auth_test.go |
| +++ b/internal/calendartest/auth_test.go |
| @@ -88,9 +88,13 @@ func TestSignInAnswersIdenticallyForPermittedAndNot(t *testing.T) { |
| // A link minted for an address that is no longer permitted must not |
| // mint a session — the second of the two wirings, defending admission |
| -// itself rather than delivery. |
| +// itself rather than delivery. The refusal is the door, with the |
| +// refusal on it, in auth's 403 (auth.Config.Refused, handlers.refused): |
| +// not rastrillo's bare text, and not a redirect to a query anybody could |
| +// type. |
| func TestASessionIsRefusedForANonPermittedAddress(t *testing.T) { |
| - app := newAppWithAllowlist(t, "allowed@example.com") |
| + dir := t.TempDir() |
| + app := newAppWithAllowlistAt(t, dir, "allowed@example.com") |
| h := app.Handler() |
| post(t, h, "/signin", url.Values{"address": {"allowed@example.com"}}) |
| @@ -98,14 +102,30 @@ func TestASessionIsRefusedForANonPermittedAddress(t *testing.T) { |
| if err != nil || body == "" { |
| t.Fatalf("no link was mailed: %v", err) |
| } |
| + link := magicLink(t, body) |
| + app.Close() |
| - // A second app, with a different allowlist, standing in for "the |
| + // The same database, reopened with a different allowlist: "the |
| // allowlist changed between the link being mailed and clicked". |
| - other := newAppWithAllowlist(t, "somebody-else@example.com") |
| - link := magicLink(t, body) |
| - rec := get(t, other.Handler(), link) |
| - if rec.Code == http.StatusOK { |
| - t.Error("a link for a non-permitted address was accepted") |
| + other := newAppWithAllowlistAt(t, dir, "somebody-else@example.com") |
| + action, token := openLink(t, other.Handler(), link) |
| + rec := postLink(t, other.Handler(), action, token) |
| + if rec.Code != http.StatusForbidden || rec.Header().Get("Location") != "" { |
| + t.Fatalf("refused redemption = %d %q, want 403 and the door itself\n%s", rec.Code, rec.Header().Get("Location"), rec.Body.String()) |
| + } |
| + for _, c := range rec.Result().Cookies() { |
| + if c.Value != "" && c.MaxAge >= 0 && strings.Contains(c.Name, "session") { |
| + t.Fatalf("a refused sign-in set a session cookie %q", c.Name) |
| + } |
| + } |
| + page := rec.Body.String() |
| + for _, want := range []string{"estilo-door", `name="address"`, `action="/signin"`, "That address can’t sign in to Calendar."} { |
| + if !strings.Contains(page, want) { |
| + t.Errorf("refusal door lacks %q:\n%s", want, page) |
| + } |
| + } |
| + if rec.Header().Get("Cache-Control") != "no-store" { |
| + t.Errorf("refusal door Cache-Control = %q, want no-store", rec.Header().Get("Cache-Control")) |
| } |
| } |
| @@ -113,9 +133,16 @@ func TestASessionIsRefusedForANonPermittedAddress(t *testing.T) { |
| // Env stays "development" so mail is still recorded rather than sent; |
| // the allowlist is what is under test, not the environment gate. |
| func newAppWithAllowlist(t *testing.T, addresses ...string) *calendar.App { |
| + t.Helper() |
| + return newAppWithAllowlistAt(t, t.TempDir(), addresses...) |
| +} |
| + |
| +// newAppWithAllowlistAt is newAppWithAllowlist over the database in dir, |
| +// so a second app can reopen the first one's. |
| +func newAppWithAllowlistAt(t *testing.T, dir string, addresses ...string) *calendar.App { |
| t.Helper() |
| app, err := calendar.New(calendar.Config{ |
| - DBPath: filepath.Join(t.TempDir(), "calendar.db"), |
| + DBPath: filepath.Join(dir, "calendar.db"), |
| Env: "development", |
| Origin: testOrigin, |
| AllowedAddresses: strings.Join(addresses, ","), |
diff --git a/internal/calendartest/browser_test.go b/internal/calendartest/browser_test.go| index b7cb82a..211e491 100644 |
| --- a/internal/calendartest/browser_test.go |
| +++ b/internal/calendartest/browser_test.go |
| @@ -70,8 +70,14 @@ func TestBrowserWalk(t *testing.T) { |
| chromedp.Click(`button[type="submit"]`, chromedp.ByQuery), |
| chromedp.WaitVisible("body", chromedp.ByQuery), |
| ) |
| + // The link opens the confirm door; its one Sign in button signs in. |
| link := signInLinkFrom(t, app, rig.Origin) |
| - rig.Run(chromedp.Navigate(link), chromedp.WaitVisible("body", chromedp.ByQuery)) |
| + rig.Run( |
| + chromedp.Navigate(link), |
| + chromedp.WaitVisible(`input[name="token"] ~ div button[type="submit"]`, chromedp.ByQuery), |
| + chromedp.Click(`button[type="submit"]`, chromedp.ByQuery), |
| + chromedp.WaitVisible("body", chromedp.ByQuery), |
| + ) |
| rig.Screen("body", "signed in") |
| // Create an event through the form. |
diff --git a/internal/calendartest/csp_browser_test.go b/internal/calendartest/csp_browser_test.go| new file mode 100644 |
| index 0000000..f5ba186 |
| --- /dev/null |
| +++ b/internal/calendartest/csp_browser_test.go |
| @@ -0,0 +1,127 @@ |
| +//go:build browser |
| + |
| +package calendartest |
| + |
| +import ( |
| + "context" |
| + "io" |
| + "log/slog" |
| + "net/http" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| + |
| + "amadan.net/rastrillo/rastrillo" |
| + "amadan.net/rastrillo/rastrillo/harness" |
| + "github.com/chromedp/cdproto/network" |
| + "github.com/chromedp/chromedp" |
| + |
| + calendar "amadan.net/oficina/calendar/internal/calendar" |
| +) |
| + |
| +// TestPlacedChipsSurviveTheProductionCSP: Calendar in Chromium behind the |
| +// production serving stack (App.ServeOptions through rastrillo.Handler: |
| +// the security headers and Calendar's CSP), standalone. Two events on |
| +// one day, at 09:00 for an hour and at 14:00 for two, paint where the |
| +// packer put them: the later one lower, twice as tall, and a team |
| +// calendar's chip in a different colour from a personal one. |
| +// |
| +// Every one of those is a style attribute. Under rastrillo fb806a2's |
| +// default style-src the browser drops them all — both chips stack at the |
| +// top of the column at their content height, and every chip falls back |
| +// to the stylesheet's default hue — while the other browser checks, |
| +// which serve App.Handler() with no CSP at all, stay green. |
| +func TestPlacedChipsSurviveTheProductionCSP(t *testing.T) { |
| + var app *calendar.App |
| + rig := harness.New(t, func(origin string) http.Handler { |
| + a, err := calendar.New(calendar.Config{ |
| + DBPath: filepath.Join(t.TempDir(), "calendar.db"), |
| + Env: "development", |
| + Origin: origin, |
| + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), |
| + }) |
| + if err != nil { |
| + t.Fatalf("calendar.New: %v", err) |
| + } |
| + t.Cleanup(func() { a.Close() }) |
| + app = a |
| + h, closeFn, err := rastrillo.Handler(a.ServeOptions(rastrillo.Options{Logger: slog.New(slog.NewTextHandler(io.Discard, nil))})) |
| + if err != nil { |
| + t.Fatalf("rastrillo.Handler: %v", err) |
| + } |
| + if closeFn != nil { |
| + t.Cleanup(func() { _ = closeFn() }) |
| + } |
| + return h |
| + }) |
| + session := signInOverHTTP(t, app, rig.Origin, "host@example.com") |
| + |
| + ctx := context.Background() |
| + st := app.StoreForTest() |
| + host := app.PersonByAddressForTest(t, "host@example.com") |
| + personal, err := st.DefaultCalendarID(ctx, host.ID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + cals, err := st.Calendars(ctx, host.ID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var team int64 |
| + for _, c := range cals { |
| + if c.Kind == "team" { |
| + team = c.ID |
| + } |
| + } |
| + if team == 0 || team == personal { |
| + t.Fatalf("no team calendar beside the personal one: %+v", cals) |
| + } |
| + for _, e := range []calendar.EventInput{ |
| + {CalendarID: personal, Title: "Morning", StartLocal: "2026-09-10T09:00", EndLocal: "2026-09-10T10:00", TZ: "UTC"}, |
| + {CalendarID: team, Title: "Afternoon", StartLocal: "2026-09-10T14:00", EndLocal: "2026-09-10T16:00", TZ: "UTC"}, |
| + } { |
| + if _, err := st.CreateEvent(ctx, host.ID, e); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + |
| + run := func(what string, actions ...chromedp.Action) { |
| + t.Helper() |
| + c, cancel := context.WithTimeout(rig.Context(), 20*time.Second) |
| + defer cancel() |
| + if err := chromedp.Run(c, actions...); err != nil { |
| + t.Fatalf("%s: %v", what, err) |
| + } |
| + } |
| + run("sign in", network.SetCookie(session.Name, session.Value).WithURL(rig.Origin+"/"). |
| + WithHTTPOnly(session.HttpOnly).WithSameSite(network.CookieSameSiteLax)) |
| + |
| + var day struct { |
| + MorningTop, MorningHeight, AfternoonTop, AfternoonHeight float64 |
| + MorningBorder, AfternoonBorder string |
| + } |
| + run("day view", |
| + chromedp.Navigate(rig.Origin+"/day/2026-09-10"), |
| + chromedp.WaitVisible(`[cal-placed]`, chromedp.ByQuery), |
| + chromedp.Evaluate(`(() => { |
| + const placed = (title) => [...document.querySelectorAll('[cal-placed]')].find(p => p.textContent.includes(title)); |
| + const box = (title) => placed(title).getBoundingClientRect(); |
| + const chip = (title) => getComputedStyle(placed(title).querySelector('[cal-chip]')); |
| + return { |
| + MorningTop: box('Morning').top, MorningHeight: box('Morning').height, |
| + AfternoonTop: box('Afternoon').top, AfternoonHeight: box('Afternoon').height, |
| + MorningBorder: chip('Morning').borderInlineStartColor, |
| + AfternoonBorder: chip('Afternoon').borderInlineStartColor, |
| + }; |
| + })()`, &day), |
| + ) |
| + if day.AfternoonTop-day.MorningTop < 100 { |
| + t.Errorf("the 14:00 chip sits %.0fpx below the 09:00 one: its position was refused", day.AfternoonTop-day.MorningTop) |
| + } |
| + if ratio := day.AfternoonHeight / day.MorningHeight; ratio < 1.8 || ratio > 2.2 { |
| + t.Errorf("a two-hour chip is %.0fpx and a one-hour chip %.0fpx: their heights were refused", day.AfternoonHeight, day.MorningHeight) |
| + } |
| + if day.MorningBorder == day.AfternoonBorder { |
| + t.Errorf("the personal and team chips paint the same hue %s: --cal-hue was refused", day.MorningBorder) |
| + } |
| +} |
diff --git a/internal/calendartest/harness_test.go b/internal/calendartest/harness_test.go| index 4785daf..87f85c0 100644 |
| --- a/internal/calendartest/harness_test.go |
| +++ b/internal/calendartest/harness_test.go |
| @@ -80,3 +80,39 @@ func postAs(t *testing.T, h http.Handler, session *http.Cookie, target string, f |
| h.ServeHTTP(rec, calendar.RequestForTest(session, http.MethodPost, target, form)) |
| return rec |
| } |
| + |
| +// 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) (action, token string) { |
| + t.Helper() |
| + rec := get(t, h, link) |
| + 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", link, rec.Code, len(rec.Result().Cookies())) |
| + } |
| + action, token, ok := calendar.ConfirmFormForTest(rec.Body.String()) |
| + if !ok { |
| + t.Fatalf("GET %s: no confirm form on the page:\n%s", link, rec.Body.String()) |
| + } |
| + return action, token |
| +} |
| + |
| +// 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. This is the request that spends the link. |
| +func postLink(t *testing.T, h http.Handler, action, token string) *httptest.ResponseRecorder { |
| + t.Helper() |
| + r := calendar.RequestForTest(nil, http.MethodPost, action, url.Values{"token": {token}}) |
| + r.Header.Set("Sec-Fetch-Site", "same-origin") |
| + rec := httptest.NewRecorder() |
| + h.ServeHTTP(rec, r) |
| + return rec |
| +} |
| + |
| +// serve runs one request through h. |
| +func serve(h http.Handler, r *http.Request) *httptest.ResponseRecorder { |
| + rec := httptest.NewRecorder() |
| + h.ServeHTTP(rec, r) |
| + return rec |
| +} |
diff --git a/internal/calendartest/playwright_test.go b/internal/calendartest/playwright_test.go| index 1e0f7c4..18d17bb 100644 |
| --- a/internal/calendartest/playwright_test.go |
| +++ b/internal/calendartest/playwright_test.go |
| @@ -119,9 +119,32 @@ func signInOverHTTP(t *testing.T, app *calendar.App, origin, address string) *ht |
| } |
| link := magicLink(t, body) |
| + // Open the link — the confirm door, which spends nothing — and |
| + // press its Sign in button, which does. |
| res, err = client.Get(origin + link) |
| if err != nil { |
| - t.Fatalf("follow the link: %v", err) |
| + t.Fatalf("open the link: %v", err) |
| + } |
| + door, err := io.ReadAll(res.Body) |
| + res.Body.Close() |
| + if err != nil { |
| + t.Fatalf("read the confirm door: %v", err) |
| + } |
| + action, token, ok := calendar.ConfirmFormForTest(string(door)) |
| + if !ok { |
| + t.Fatalf("opening the link drew no confirm door (status %d)", res.StatusCode) |
| + } |
| + req, err = http.NewRequest(http.MethodPost, origin+action, |
| + strings.NewReader(url.Values{"token": {token}}.Encode())) |
| + if err != nil { |
| + t.Fatalf("build confirm request: %v", err) |
| + } |
| + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + req.Header.Set("Origin", origin) |
| + req.Header.Set("Sec-Fetch-Site", "same-origin") |
| + res, err = client.Do(req) |
| + if err != nil { |
| + t.Fatalf("confirm the link: %v", err) |
| } |
| res.Body.Close() |
diff --git a/internal/calendartest/share_zone_test.go b/internal/calendartest/share_zone_test.go| new file mode 100644 |
| index 0000000..56a5047 |
| --- /dev/null |
| +++ b/internal/calendartest/share_zone_test.go |
| @@ -0,0 +1,45 @@ |
| +package calendartest |
| + |
| +import ( |
| + "context" |
| + "net/http" |
| + "net/url" |
| + "strconv" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// TestTheSharePageSaysFeedTimesInTheViewersZone: the database hands every |
| +// stored time back in UTC (rastrillo's _timezone=UTC, from fb806a2), and |
| +// the share page used to print a feed's created and last-polled times |
| +// straight off the row — UTC, without saying so. They are the viewer's |
| +// zone now, like every other time on every other page. |
| +func TestTheSharePageSaysFeedTimesInTheViewersZone(t *testing.T) { |
| + app, session := signedIn(t, "someone@example.com") |
| + h := app.Handler() |
| + if rec := postAs(t, h, session, "/settings", url.Values{"zone": {"Pacific/Auckland"}, "week_start": {"1"}, |
| + "default_reminder_min": {"10"}, "default_all_day_min": {"0"}}); rec.Code != http.StatusSeeOther { |
| + t.Fatalf("POST /settings = %d", rec.Code) |
| + } |
| + person := app.PersonByAddressForTest(t, "someone@example.com") |
| + cal, err := app.StoreForTest().DefaultCalendarID(context.Background(), person.ID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + share := "/calendars/" + strconv.FormatInt(cal, 10) + "/share" |
| + if rec := postAs(t, h, session, "/calendars/"+strconv.FormatInt(cal, 10)+"/feeds", nil); rec.Code != http.StatusSeeOther { |
| + t.Fatalf("mint feed = %d", rec.Code) |
| + } |
| + // 20:00 UTC on 2 March is 09:00 on 3 March in Auckland (NZDT, +13). |
| + at := time.Date(2026, 3, 2, 20, 0, 0, 0, time.UTC) |
| + if _, err := app.DBForTest().Exec(`UPDATE feeds SET created_at = ?, last_polled_at = ?, poll_count = 1`, at, at); err != nil { |
| + t.Fatal(err) |
| + } |
| + page := getAs(t, h, session, share).Body.String() |
| + for _, want := range []string{"Created 3 March 2026", "last 3 Mar 09:00"} { |
| + if !strings.Contains(page, want) { |
| + t.Errorf("share page lacks %q (the viewer's zone):\n%s", want, page) |
| + } |
| + } |
| +} |
diff --git a/internal/calendartest/signin_confirm_test.go b/internal/calendartest/signin_confirm_test.go| new file mode 100644 |
| index 0000000..1ad24a7 |
| --- /dev/null |
| +++ b/internal/calendartest/signin_confirm_test.go |
| @@ -0,0 +1,107 @@ |
| +package calendartest |
| + |
| +import ( |
| + "net/http" |
| + "net/url" |
| + "strings" |
| + "testing" |
| + |
| + calendar "amadan.net/oficina/calendar/internal/calendar" |
| +) |
| + |
| +// 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 a view. |
| +// |
| +// 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; this pins that the |
| +// confirm step is Calendar's own door with one Sign in button, not |
| +// rastrillo's plain English fallback, and that the POST is mounted. |
| +func TestEmailedLinkOpensTheConfirmDoor(t *testing.T) { |
| + app := newApp(t) |
| + h := app.Handler() |
| + |
| + if w := post(t, h, "/signin", url.Values{"address": {"someone@example.com"}, "force": {"1"}}); w.Code != http.StatusSeeOther || w.Header().Get("Location") != "/signin?sent=1" { |
| + t.Fatalf("POST /signin = %d %q, want 303 /signin?sent=1", w.Code, w.Header().Get("Location")) |
| + } |
| + body, err := app.LastMailForTest() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + link := magicLink(t, body) |
| + 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, h, link) |
| + 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())) |
| + } |
| + page := rec.Body.String() |
| + for _, want := range []string{"estilo-door", "estilo-signin-form", "<span>Calendar</span>", "<title>Sign in · Calendar</title>", `name="token" value="` + u.Query().Get("token") + `"`} { |
| + if !strings.Contains(page, want) { |
| + t.Errorf("%s: confirm door lacks %q:\n%s", who, want, page) |
| + } |
| + } |
| + if n := strings.Count(page, "<button"); n != 1 || !strings.Contains(page, `type="submit" autofocus>Sign in</button>`) { |
| + t.Errorf("%s: confirm door has %d buttons, want exactly one Sign in:\n%s", who, n, page) |
| + } |
| + if strings.Contains(page, `name="address"`) { |
| + t.Errorf("%s: confirm door asks for an address again", who) |
| + } |
| + if strings.Contains(page, "<title>Sign in to ") { |
| + t.Errorf("%s: rastrillo's fallback confirm page, not the door", who) |
| + } |
| + // auth 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 POST with no browser's same-origin evidence is refused and |
| + // spends nothing. |
| + action, token := openLink(t, h, link) |
| + cross := calendar.RequestForTest(nil, http.MethodPost, action, url.Values{"token": {token}}) |
| + cross.Header.Del("Origin") |
| + cross.Header.Set("Sec-Fetch-Site", "cross-site") |
| + if rec := serve(h, cross); rec.Code != http.StatusForbidden || len(rec.Result().Cookies()) != 0 { |
| + t.Fatalf("cross-site POST = %d with %d cookies, want 403 and nothing minted", rec.Code, len(rec.Result().Cookies())) |
| + } |
| + |
| + // Sign in: the POST spends the link and mints the session. |
| + signed := postLink(t, h, action, token) |
| + if signed.Code != http.StatusSeeOther || signed.Header().Get("Location") != "/" { |
| + t.Fatalf("POST %s = %d %q, want 303 /", action, signed.Code, signed.Header().Get("Location")) |
| + } |
| + var session *http.Cookie |
| + for _, c := range signed.Result().Cookies() { |
| + if c.Value != "" && c.MaxAge >= 0 { |
| + session = c |
| + } |
| + } |
| + if session == nil { |
| + t.Fatal("the confirm POST minted no session") |
| + } |
| + if view := follow(t, h, session, "/"); view.Code != http.StatusOK || !strings.Contains(view.Body.String(), `aria-label="Account: someone@example.com"`) { |
| + t.Fatalf("GET / with the new session = %d, or it does not name the person", view.Code) |
| + } |
| + |
| + // Spent: the door still opens (it looks nothing up), but its button |
| + // now says the link expired. |
| + action, token = openLink(t, h, link) |
| + again := postLink(t, h, action, token) |
| + if len(again.Result().Cookies()) != 0 || 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")) |
| + } |
| +} |
diff --git a/internal/calendartest/vendored_test.go b/internal/calendartest/vendored_test.go| index 6311f57..766ed33 100644 |
| --- a/internal/calendartest/vendored_test.go |
| +++ b/internal/calendartest/vendored_test.go |
| @@ -41,6 +41,16 @@ var vendoredIsMine = map[string]bool{ |
| // picker, as it did on v0.23. Adopting the overlay means renaming |
| // ours first. |
| "calendar.js": true, |
| + // estilo's busy.js, not rastrillo's: from fb806a2 rastrillo's copy |
| + // learned to leave pow.js forms alone, and Calendar has no pow (auth |
| + // runs ProofOff). Pinned to estilo by TestBusyFilesAreEstilos, like |
| + // rastrillo.js; it follows estilo's when estilo takes the change. |
| + "busy.js": true, |
| + // rastrillo's console shell (from v0.27), which Calendar does not |
| + // draw: its bar and page frame are estilo's chrome. Never vendored, |
| + // so there is nothing here to pin. |
| + "shell.css": true, |
| + "shell.js": true, |
| } |
| // TestThemeIsEstilosForCalendar pins static/theme.css to the estilo this |