oficina / sheets Public

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

Opening a sign-in link shows Sheets' own door with one Sign in button;
the POST spends it, so mail scanners and other browsers no longer burn
links. A refused identity gets the door through auth's Refused hook,
replacing the interim wrapper. Standalone keeps inline styles allowed
(per-document cell colours) with every other directive at rastrillo's
default. Reviewed by GPT-6-Astra via Codex (#46).

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

Squashed from chore/rastrillo-confirm into main.
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 6d700d6ab579e8130f6f00af9706f81dea62c307 parent 0dbb085
24 files changed, +1309 −251
  • Makefile +4 −2
  • cmd/sheets/main.go +3 −11
  • go.mod +3 −1
  • go.sum +6 −2
  • internal/sheets/auth.go +26 −9
  • internal/sheets/begin.go +11 −91
  • internal/sheets/begin_test.go +56 −1
  • internal/sheets/chrome_menu_test.go +1 −6
  • internal/sheets/grants_http_test.go +1 −5
  • internal/sheets/helpers_test.go +3 −8
  • internal/sheets/home_navigation_test.go +10 −1
  • internal/sheets/home_routes.go +15 −9
  • internal/sheets/home_test.go +1 −1
  • internal/sheets/keymail_signin_test.go +10 −5
  • internal/sheets/page_test.go +8 −9
  • internal/sheets/serving.go +54 −0
  • internal/sheets/serving_browser_test.go +91 −0
  • internal/sheets/serving_test.go +145 −0
  • internal/sheets/signin.go +54 −20
  • internal/sheets/signin_confirm_test.go +131 −0
  • internal/sheets/signin_test.go +7 −3
  • internal/sheets/signin_timing_test.go +1 −1
  • internal/sheets/static/tokens.css +653 −65
  • internal/sheets/templates/signin.html +15 −1

CI passed — run details

CI log
amadan: .amadan/timeout: this job's budget is 15m0s (this runner allows up to 30m0s)
=== vet ===
CGO_ENABLED=0 go vet ./...
CGO_ENABLED=0 go vet -tags browser ./internal/sheets/
=== fmt ===
=== build ===
CGO_ENABLED=0 go build ./...
=== migrations ===
go tool rastrillo migration check
rastrillo migration check: models and migrations agree
=== go-test ===
CGO_ENABLED=0 go test -count=1 ./...
?   	amadan.net/oficina/sheets/cmd/sheets	[no test files]
ok  	amadan.net/oficina/sheets/internal/guardstest	0.094s
ok  	amadan.net/oficina/sheets/internal/sheets	4.306s
ok  	amadan.net/oficina/sheets/internal/sheets/calc	0.015s
ok  	amadan.net/oficina/sheets/internal/sheets/format	0.002s
?   	amadan.net/oficina/sheets/internal/sheets/keymailtest	[no test files]
ok  	amadan.net/oficina/sheets/internal/sheets/live	0.052s
ok  	amadan.net/oficina/sheets/internal/sheets/seal	0.002s
ok  	amadan.net/oficina/sheets/internal/sheets/sealtest	0.002s
ok  	amadan.net/oficina/sheets/internal/sheets/store	3.093s
ok  	amadan.net/oficina/sheets/internal/sheets/xlsx	0.323s
?   	amadan.net/oficina/sheets/tools/gencorpus	[no test files]
=== race ===
go test -race -count=1 ./internal/sheets/store/... ./internal/sheets/live/...
ok  	amadan.net/oficina/sheets/internal/sheets/store	48.359s
ok  	amadan.net/oficina/sheets/internal/sheets/live	1.061s
diff --git a/Makefile b/Makefile
index b55bd98..3a1b696 100644
--- a/Makefile
+++ b/Makefile
@@ -127,6 +127,8 @@ test-race:
# Chromium: on PATH, via RASTRILLO_CHROME, or in a Playwright cache.
# Deliberately NOT part of `test`: the gate CI runs on every push must
# not need a browser. Run it before shipping a change to the CSS or to
-# rastrillo's pin — TestNoZoomOnTap is the iOS zoom-on-tap check.
+# rastrillo's pin — TestNoZoomOnTap is the iOS zoom-on-tap check, and
+# TestDocumentStylesSurviveTheProductionCSP is the one browser check served
+# through rastrillo's production stack, so it sees the real CSP.
test-browser:
- CGO_ENABLED=0 go test -tags browser -count=1 -run 'OnTap' ./internal/sheets/
+ CGO_ENABLED=0 go test -tags browser -count=1 -run 'OnTap|DocumentStyles' ./internal/sheets/
diff --git a/cmd/sheets/main.go b/cmd/sheets/main.go
index 7fa5f19..7f5488d 100644
--- a/cmd/sheets/main.go
+++ b/cmd/sheets/main.go
@@ -25,7 +25,6 @@ package main
import (
"context"
"log/slog"
- "net/http"
"os"
"amadan.net/rastrillo/rastrillo"
@@ -96,7 +95,7 @@ func main() {
Admit: sheets.Admits(authEnv.Owners),
})
- signin, err := sheets.NewAuth(d.Writer(), authEnv, logger, app.Admits)
+ signin, err := sheets.NewAuth(d.Writer(), authEnv, logger, app)
if err != nil {
logger.Error("sign-in", "err", err)
os.Exit(1)
@@ -112,15 +111,8 @@ func main() {
os.Exit(1)
}
- // rastrillo.Options.Mux wants a *http.ServeMux; the app's own
- // router is a chi.Router (an http.Handler), so it mounts under one.
- mux := http.NewServeMux()
- mux.Handle("/", app.Router())
- opts.Mux = mux
- // A panic outside the router's signed-in group (where the app's own
- // recoverer renders the viewer's chrome) is the same error page, not
- // rastrillo's plain text (spec §3.3).
- opts.ErrorPage = app.ErrorPage
+ // The router, the error page and the CSP (sheets.ServeOptions).
+ opts = app.ServeOptions(opts)
// The app opened its own database handle above (db.Open already
// Pings eagerly, which satisfies Serve's boot-materialization duty
diff --git a/go.mod b/go.mod
index 75dbbb4..26dc9bd 100644
--- a/go.mod
+++ b/go.mod
@@ -6,7 +6,7 @@ require (
amadan.net/oficina/estilo.git v0.0.0-20261001093750-530cb932e768
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/cdproto v0.0.0-20250724212937-08a3db8b4327
github.com/chromedp/chromedp v0.14.2
@@ -17,11 +17,13 @@ require (
)
require (
+ amadan.net/rastrillo/rastrillo/money v0.1.0 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
+ golang.org/x/mod v0.38.0 // indirect
)
require (
diff --git a/go.sum b/go.sum
index 54727f1..ddcf78e 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=
@@ -64,6 +66,8 @@ github.com/richardlehane/mscfb v1.0.7 h1:oeoiM0WE79vHwE8RpIYYvIAc8ajTH2mb6UZm55/
github.com/richardlehane/mscfb v1.0.7/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
+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=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
diff --git a/internal/sheets/auth.go b/internal/sheets/auth.go
index 681460c..263893e 100644
--- a/internal/sheets/auth.go
+++ b/internal/sheets/auth.go
@@ -137,13 +137,15 @@ func splitOwners(raw string) []string {
// request would get a fresh empty budget every time and rate-limit
// nothing, silently.
//
-// authorize is the admission gate consulted at redemption. It is a
-// parameter rather than Admits(env.Owners) because admission is no
-// longer a static list: a guest who has been granted a sheet must be
-// able to sign in, and only the database knows who that is. Passing
-// App.Admits closes the loop.
-func NewAuth(db *sql.DB, env AuthEnv, logger *slog.Logger, authorize func(string) bool) (*auth.Auth, error) {
- if authorize == nil {
+// app supplies the three things auth asks of the app at redemption:
+// the admission gate (App.Admits), the door a verified but refused
+// address is shown (App.refused), and the door an emailed link opens on
+// (App.renderConfirm). Admission is the App's rather than
+// Admits(env.Owners) because it is no longer a static list: a guest who
+// has been granted a sheet must be able to sign in, and only the
+// database knows who that is.
+func NewAuth(db *sql.DB, env AuthEnv, logger *slog.Logger, app *App) (*auth.Auth, error) {
+ if app == nil {
// A nil Authorize admits every verified address, which on a
// public-hostname instance means anyone who can receive mail.
// Refusing here beats defaulting to the allowlist, because a
@@ -151,6 +153,7 @@ func NewAuth(db *sql.DB, env AuthEnv, logger *slog.Logger, authorize func(string
// is the version that says so.
return nil, errors.New("sign-in: refusing to wire auth with no admission gate")
}
+ authorize := app.Admits
queue := newMailQueue(env.Mailer, authorize, nil, logger)
if a, ok := env.Mailer.(queueAttacher); ok {
a.attachQueue(queue.wait)
@@ -165,8 +168,22 @@ func NewAuth(db *sql.DB, env AuthEnv, logger *slog.Logger, authorize func(string
InstanceKey: env.InstanceKey,
// Begin runs for every address (handleBegin); the mail goes
// out after the response, and only to one authorize admits.
- Mailer: queue,
- Authorize: authorize,
+ Mailer: queue,
+ Authorize: authorize,
+ // A verified address Authorize refuses gets the door, with the
+ // refusal on it, in auth's own 403 (App.refused).
+ Refused: app.refused,
+ // GET /auth/verify spends nothing: it opens the door with one
+ // Sign in button that POSTs the link back, so a mail scanner's
+ // fetch cannot spend it (App.renderConfirm).
+ RenderConfirm: app.renderConfirm,
+ // No proof-of-work on the sign-in form. Sheets never had one:
+ // handleBegin's rate budgets and mailQueue's admission 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,
SigninPath: "/signin",
// Where a fresh session lands. auth defaults this to "/",
// which is the anonymous entry page — so without this line
diff --git a/internal/sheets/begin.go b/internal/sheets/begin.go
index a0ded16..a160a28 100644
--- a/internal/sheets/begin.go
+++ b/internal/sheets/begin.go
@@ -1,12 +1,10 @@
package sheets
import (
- "bytes"
- "context"
"net/http"
- "strings"
"amadan.net/oficina/home/connect"
+ "amadan.net/rastrillo/rastrillo/auth"
)
// handleBegin is POST /signin, in both modes: auth's Begin for EVERY
@@ -28,7 +26,7 @@ import (
// ("Refused to load https://keymail.dev/oauth/authorize… form-action",
// Paul, 2026-10-06). Admission is asked after Keymail has verified
// the address, by auth's Authorize (App.Admits), and a refusal is
-// the door saying so (doorOnRefusal), told only to someone who has
+// the door saying so (App.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 App.Admits says yes — after the response, in a
@@ -51,95 +49,17 @@ func (a *App) handleBegin(w http.ResponseWriter, r *http.Request) {
connect.OAuthContinuation(a.origin, a.auth.Begin, a.renderHomeOAuth)(w, r)
}
-// 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 auth's Callback and Verify, the handlers that end
-// in admission, and turns its bare-text refusal into the sign-in door,
-// rendered in the refusal's own 403 by door (handleSignin) with the
-// refusal marked on the request context (refusedHere). Every other
-// answer, cookies included, passes through untouched.
+// refused is auth's Refused hook: Keymail or an emailed link verified
+// the address and Admits said no. It answers with the sign-in 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 on oficina/slides #20, round
-// 3). 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)
- }
- 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)
+// 3). Only this hook can say it, and auth calls it only after the
+// address is proved, so saying it is no membership oracle.
+func (a *App) refused(w http.ResponseWriter, r *http.Request, _ auth.Identity) {
+ a.renderDoor(w, r, http.StatusForbidden,
+ "That address can’t sign in to Sheets. Use a different address, or ask someone to share a sheet with it.", false)
}
diff --git a/internal/sheets/begin_test.go b/internal/sheets/begin_test.go
index 4169d8a..905bfb5 100644
--- a/internal/sheets/begin_test.go
+++ b/internal/sheets/begin_test.go
@@ -2,8 +2,10 @@ package sheets
import (
"context"
+ "html"
"net/http"
"net/http/httptest"
+ "net/url"
"path/filepath"
"regexp"
"strings"
@@ -119,7 +121,7 @@ func appWithOwners(t *testing.T, origin string, owners ...string) (*App, *record
InstanceKey: "test-instance-key",
Owners: owners,
Mailer: rec,
- }, quietLogger(), app.Admits)
+ }, quietLogger(), app)
if err != nil {
t.Fatalf("NewAuth: %v", err)
}
@@ -140,6 +142,59 @@ func postSignin(t *testing.T, app *App, address string) *httptest.ResponseRecord
return w
}
+// confirmForm is the confirm door's one form, as a browser would read it.
+var confirmForm = regexp.MustCompile(`<form[^>]*method="post" action="([^"]+)">\s*<input type="hidden" name="token" value="([^"]+)">`)
+
+// openLink is opening an emailed link: a GET that must spend nothing and
+// answer with the confirm door. It returns the door's form action and the
+// token it carries, read off the page rather than the link, so a door
+// that dropped or mangled the token fails here.
+func openLink(t *testing.T, h http.Handler, link string, cookies ...*http.Cookie) (action, token string) {
+ t.Helper()
+ u, err := url.Parse(link)
+ if err != nil {
+ t.Fatalf("parse emailed link %q: %v", link, err)
+ }
+ r := httptest.NewRequest(http.MethodGet, u.RequestURI(), nil)
+ for _, c := range cookies {
+ r.AddCookie(c)
+ }
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, r)
+ if rec.Code != http.StatusOK || len(rec.Result().Cookies()) != 0 {
+ t.Fatalf("GET %s = %d with %d cookies, want 200 and the confirm door, minting nothing", u.Path, rec.Code, len(rec.Result().Cookies()))
+ }
+ m := confirmForm.FindStringSubmatch(rec.Body.String())
+ if m == nil {
+ t.Fatalf("GET %s: no confirm form on the page:\n%s", u.Path, rec.Body.String())
+ }
+ return html.UnescapeString(m[1]), html.UnescapeString(m[2])
+}
+
+// postLink is pressing the confirm door's Sign in button: the token,
+// POSTed back to the door's action with a browser's same-origin
+// evidence. This is the request that spends the link.
+func postLink(t *testing.T, h http.Handler, action, token string, cookies ...*http.Cookie) *httptest.ResponseRecorder {
+ t.Helper()
+ r := httptest.NewRequest(http.MethodPost, action, strings.NewReader(url.Values{"token": {token}}.Encode()))
+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ r.Header.Set("Origin", testOrigin)
+ r.Header.Set("Sec-Fetch-Site", "same-origin")
+ for _, c := range cookies {
+ r.AddCookie(c)
+ }
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, r)
+ return rec
+}
+
+// followLink is both: open the emailed link, then press Sign in.
+func followLink(t *testing.T, h http.Handler, link string) *httptest.ResponseRecorder {
+ t.Helper()
+ action, token := openLink(t, h, link)
+ return postLink(t, h, action, token)
+}
+
// TestBeginDoesNotEmailStrangers is the defect this handler exists for.
// auth checks Authorize at redemption, so without the gate a stranger
// still cannot get a session — but the instance cheerfully emails them a
diff --git a/internal/sheets/chrome_menu_test.go b/internal/sheets/chrome_menu_test.go
index 69811b2..5e260ec 100644
--- a/internal/sheets/chrome_menu_test.go
+++ b/internal/sheets/chrome_menu_test.go
@@ -4,7 +4,6 @@ import (
"html"
"net/http"
"net/http/httptest"
- "net/url"
"os"
"path/filepath"
"regexp"
@@ -37,11 +36,7 @@ func TestTheBarRenamesTheSheet(t *testing.T) {
t.Fatal(err)
}
postSignin(t, app, "reader@example.com")
- u, err := url.Parse(app.testMailer(t).lastLink(t))
- if err != nil {
- t.Fatal(err)
- }
- reader := do(t, app, httptest.NewRequest(http.MethodGet, u.RequestURI(), nil)).Result().Cookies()
+ reader := followLink(t, app.Router(), app.testMailer(t).lastLink(t)).Result().Cookies()
body = page(t, app, reader, "/sheets/"+strconv.FormatUint(s.ID, 10)).Body.String()
if strings.Contains(body, "estilo-bar-title") || !strings.Contains(body, `<span estilo-bar-context dir="auto" title="Budget">Budget</span>`) {
t.Error("a reader is offered the rename, or does not see the title")
diff --git a/internal/sheets/grants_http_test.go b/internal/sheets/grants_http_test.go
index 1d862e2..eb29f63 100644
--- a/internal/sheets/grants_http_test.go
+++ b/internal/sheets/grants_http_test.go
@@ -128,11 +128,7 @@ func signInAs(t *testing.T, app *App, address string) []*http.Cookie {
if len(mail.addresses()) == before {
t.Fatalf("no link was emailed to %s; is that address admitted?", address)
}
- u, err := url.Parse(mail.lastLink(t))
- if err != nil {
- t.Fatalf("parse link: %v", err)
- }
- rec := do(t, app, httptest.NewRequest(http.MethodGet, u.RequestURI(), nil))
+ rec := followLink(t, app.Router(), mail.lastLink(t))
cookies := rec.Result().Cookies()
if len(cookies) == 0 {
t.Fatalf("verify minted no session for %s (status %d, location %q)",
diff --git a/internal/sheets/helpers_test.go b/internal/sheets/helpers_test.go
index 9821d68..7c6a31c 100644
--- a/internal/sheets/helpers_test.go
+++ b/internal/sheets/helpers_test.go
@@ -4,7 +4,6 @@ import (
"context"
"net/http"
"net/http/httptest"
- "net/url"
"path/filepath"
"sync"
"testing"
@@ -97,7 +96,8 @@ func testMember(t *testing.T, app *App) Member {
}
// signIn drives the real magic-link flow — ask for a link, take the one
-// that was actually emailed, follow it — and returns the app plus the
+// that was actually emailed, open it and press the confirm door's Sign
+// in — and returns the app plus the
// session cookies it minted.
//
// Driven end to end rather than faked, because a hand-minted session
@@ -121,12 +121,7 @@ func sessionFor(t *testing.T, app *App, mail *recorder) []*http.Cookie {
t.Helper()
postSignin(t, app, "paul@example.com")
- u, err := url.Parse(mail.lastLink(t))
- if err != nil {
- t.Fatalf("parse emailed link: %v", err)
- }
- rec := httptest.NewRecorder()
- app.Router().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, u.RequestURI(), nil))
+ rec := followLink(t, app.Router(), mail.lastLink(t))
cookies := rec.Result().Cookies()
if len(cookies) == 0 {
t.Fatalf("verify set no cookie (status %d, location %q)",
diff --git a/internal/sheets/home_navigation_test.go b/internal/sheets/home_navigation_test.go
index f78233c..fae8d42 100644
--- a/internal/sheets/home_navigation_test.go
+++ b/internal/sheets/home_navigation_test.go
@@ -4,6 +4,7 @@ import (
"amadan.net/oficina/home/connect"
"amadan.net/rastrillo/rastrillo"
"context"
+ "html"
"net/http"
"net/http/httptest"
"net/url"
@@ -23,7 +24,15 @@ func TestHomeLocalPluginRoundTripAndSignout(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- verify := f.request(t, "GET", u.RequestURI(), nil, false)
+ // 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.request(t, "GET", u.RequestURI(), nil, false)
+ m := confirmForm.FindStringSubmatch(open.Body.String())
+ if open.Code != 200 || m == nil || !strings.Contains(open.Body.String(), "estilo-door") {
+ t.Fatalf("Home-local link: %d, want the confirm door\n%s", open.Code, open.Body.String())
+ }
+ verify := f.request(t, "POST", html.UnescapeString(m[1]), url.Values{"token": {html.UnescapeString(m[2])}}, false)
if verify.Code != 303 || verify.Header().Get("Location") != connect.LocalCompletePath {
t.Fatal("local callback", verify.Code, verify.Header().Get("Location"))
}
diff --git a/internal/sheets/home_routes.go b/internal/sheets/home_routes.go
index 057cd83..ba99562 100644
--- a/internal/sheets/home_routes.go
+++ b/internal/sheets/home_routes.go
@@ -23,20 +23,26 @@ func (a *App) mountAuth(r chi.Router) {
r.Get("/signed-out", a.handleSignedOut)
}
if a.auth != nil {
- // The four handlers rastrillo/auth expects an app to mount, at
- // the paths its own docs use. Verify reads ?token=; the path is
- // baked into every emailed link, so moving it later invalidates
- // links already in people's inboxes.
+ // The handlers rastrillo/auth expects an app to mount, at the
+ // paths its own docs use. Verify answers GET with the confirm
+ // door (renderConfirm), which spends nothing, and POST with the
+ // redemption, so it is mounted for both methods in both modes:
+ // 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 that is not admitted is answered by auth's
+ // Refused hook (App.refused), inside Callback and Verify.
if a.homeRoutes != nil {
r.Post("/signin", a.homeRoutes.LocalGuard(a.handleBegin))
- r.Get("/auth/callback", a.homeRoutes.LocalGuard(doorOnRefusal(a.auth.Callback, a.handleSignin)))
- r.Get("/auth/verify", a.homeRoutes.LocalGuard(doorOnRefusal(a.auth.Verify, a.handleSignin)))
- r.Post("/auth/verify", a.homeRoutes.LocalGuard(doorOnRefusal(a.auth.Verify, a.handleSignin)))
+ r.Get("/auth/callback", a.homeRoutes.LocalGuard(a.auth.Callback))
+ r.Get("/auth/verify", a.homeRoutes.LocalGuard(a.auth.Verify))
+ r.Post("/auth/verify", a.homeRoutes.LocalGuard(a.auth.Verify))
r.Post("/signout", a.homeRoutes.Signout)
} else {
r.Post("/signin", a.handleBegin)
- r.Get("/auth/callback", doorOnRefusal(a.auth.Callback, a.handleSignin))
- r.Get("/auth/verify", doorOnRefusal(a.auth.Verify, a.handleSignin))
+ r.Get("/auth/callback", a.auth.Callback)
+ r.Get("/auth/verify", a.auth.Verify)
+ r.Post("/auth/verify", a.auth.Verify)
r.Post("/signout", a.signOutToPage)
}
}
diff --git a/internal/sheets/home_test.go b/internal/sheets/home_test.go
index 16254ce..4803564 100644
--- a/internal/sheets/home_test.go
+++ b/internal/sheets/home_test.go
@@ -45,7 +45,7 @@ func newHomeFixtureWith(t *testing.T, directory string) *homeFixture {
if err != nil {
t.Fatal(err)
}
- localAuth, err := NewAuth(d, AuthEnv{Origin: testOrigin, InstanceKey: "test-instance-key", Mailer: app.testMailer(t), Home: &cfg}, quietLogger(), app.Admits)
+ localAuth, err := NewAuth(d, AuthEnv{Origin: testOrigin, InstanceKey: "test-instance-key", Mailer: app.testMailer(t), Home: &cfg}, quietLogger(), app)
if err != nil {
t.Fatal(err)
}
diff --git a/internal/sheets/keymail_signin_test.go b/internal/sheets/keymail_signin_test.go
index edeb63e..570b336 100644
--- a/internal/sheets/keymail_signin_test.go
+++ b/internal/sheets/keymail_signin_test.go
@@ -189,9 +189,10 @@ func TestKeymailFailureFallsBackToAnEmailLink(t *testing.T) {
// TestKeymailRefusalIsTheDoor: a non-admitted address Keymail verified
// is refused after verification with the sign-in door, rendered in the
-// refusal's own 403 — not rastrillo's bare text, and not a redirect to a
-// query anybody could type. No session is minted. An admitted one is
-// signed in.
+// refusal's own 403 by auth's Refused hook (App.refused) — not
+// rastrillo's bare text, and not a redirect to a query anybody could
+// type. No session is minted. An admitted one is signed in, through the
+// continuation page, and its session opens the list.
func TestKeymailRefusalIsTheDoor(t *testing.T) {
app, _, km := keymailApp(t)
h := app.Router()
@@ -200,10 +201,14 @@ func TestKeymailRefusalIsTheDoor(t *testing.T) {
u := continuation(t, address, begin)
return keymailGet(t, h, "/auth/callback?"+url.Values{"state": {u.Query().Get("state")}, "code": {km.Approve(address)}}.Encode(), begin.Result().Cookies()...)
}
- if cb := roundTrip(keymailMember); cb.Code != http.StatusSeeOther || cb.Header().Get("Location") != "/sheets" {
+ cb := roundTrip(keymailMember)
+ if cb.Code != http.StatusSeeOther || cb.Header().Get("Location") != "/sheets" {
t.Fatalf("admitted callback: %d %q, want 303 /sheets", cb.Code, cb.Header().Get("Location"))
}
- cb := roundTrip(keymailStranger)
+ if list := page(t, app, cb.Result().Cookies(), "/sheets"); list.Code != http.StatusOK || !strings.Contains(list.Body.String(), keymailMember) {
+ t.Fatalf("GET /sheets with the Keymail session = %d, or it does not name %s", list.Code, keymailMember)
+ }
+ cb = roundTrip(keymailStranger)
if cb.Code != http.StatusForbidden || cb.Header().Get("Location") != "" {
t.Fatalf("non-admitted 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/sheets/page_test.go b/internal/sheets/page_test.go
index 6c3be17..14543bb 100644
--- a/internal/sheets/page_test.go
+++ b/internal/sheets/page_test.go
@@ -98,7 +98,7 @@ func TestIndexLeaksNothing(t *testing.T) {
// TestSignInEndToEnd drives the whole flow the way a person does:
// request a link, take the link out of the email that was actually sent,
-// follow it, and check the root page then says who you are.
+// open it, press Sign in, and check the root page then says who you are.
//
// It exists because every earlier test here proved a piece — the gate,
// the routes, the page — and the first real click still landed on a page
@@ -125,9 +125,9 @@ func TestSignInEndToEnd(t *testing.T) {
t.Fatalf("emailed link carries no token: %s", link)
}
- // 3. Follow it.
- verify := httptest.NewRecorder()
- app.Router().ServeHTTP(verify, httptest.NewRequest(http.MethodGet, u.RequestURI(), nil))
+ // 3. Open it — the confirm door, which spends nothing — and press
+ // its Sign in button, which does.
+ verify := followLink(t, app.Router(), link)
cookies := verify.Result().Cookies()
if len(cookies) == 0 {
t.Fatalf("verify set no cookie (status %d, location %q)",
@@ -215,16 +215,15 @@ func TestAnonymousRootStillOffersAWayIn(t *testing.T) {
func TestSignInIsSingleUse(t *testing.T) {
app, mail := appWithRecorder(t)
postSignin(t, app, "paul@example.com")
- u, _ := url.Parse(mail.lastLink(t))
+ h := app.Router()
+ action, token := openLink(t, h, mail.lastLink(t))
- first := httptest.NewRecorder()
- app.Router().ServeHTTP(first, httptest.NewRequest(http.MethodGet, u.RequestURI(), nil))
+ first := postLink(t, h, action, token)
if len(first.Result().Cookies()) == 0 {
t.Fatal("first use did not sign in")
}
- second := httptest.NewRecorder()
- app.Router().ServeHTTP(second, httptest.NewRequest(http.MethodGet, u.RequestURI(), nil))
+ second := postLink(t, h, action, token)
if len(second.Result().Cookies()) != 0 {
t.Error("second use of the same link minted a session")
}
diff --git a/internal/sheets/serving.go b/internal/sheets/serving.go
new file mode 100644
index 0000000..6b5eb35
--- /dev/null
+++ b/internal/sheets/serving.go
@@ -0,0 +1,54 @@
+package sheets
+
+import (
+ "net/http"
+
+ "amadan.net/rastrillo/rastrillo"
+)
+
+// ContentSecurityPolicy is the policy Sheets serves under 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 Sheets' documents are drawn
+// with them:
+//
+// - the scriptless table and public-link views paint each cell's fill
+// and text colour as custom properties on the cell (table.html,
+// tablepaint.go). The colours are the document's own, unbounded, so
+// they cannot be moved into sheets.css, and a per-document
+// stylesheet would be a new route and cache for every view;
+// - the Format menu's fill and text palettes draw each swatch's
+// colour the same way (sheet.html);
+// - the vendored Jspreadsheet and jsuites write style attributes of
+// their own (setAttribute("style", …) and style="…" in markup),
+// which this app does not patch: it is vendored as shipped.
+//
+// Hashing them is not possible (the values are per document), and the
+// other directives are unchanged, so scripts stay same-origin only and
+// framing, base and form targets stay locked. Home mode serves
+// connect.FormPolicy, which carries the same style-src.
+// TestProductionCSPIsTheDefaultBarStyles keeps every other directive in
+// step with rastrillo's default.
+const ContentSecurityPolicy = "default-src 'self'; style-src 'self' 'unsafe-inline'; " +
+ "img-src 'self' data:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
+
+// ServeOptions completes opts with what Sheets 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 {
+ // rastrillo.Options.Mux wants a *http.ServeMux; the app's own
+ // router is a chi.Router (an http.Handler), so it mounts under one.
+ mux := http.NewServeMux()
+ mux.Handle("/", a.Router())
+ opts.Mux = mux
+ // A panic outside the router's signed-in group (where the app's own
+ // recoverer renders the viewer's chrome) is the same error page, not
+ // rastrillo's plain text (spec §3.3).
+ opts.ErrorPage = a.ErrorPage
+ opts.CSP = ContentSecurityPolicy
+ return opts
+}
diff --git a/internal/sheets/serving_browser_test.go b/internal/sheets/serving_browser_test.go
new file mode 100644
index 0000000..43ab172
--- /dev/null
+++ b/internal/sheets/serving_browser_test.go
@@ -0,0 +1,91 @@
+//go:build browser
+
+package sheets
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "testing"
+ "time"
+
+ "amadan.net/rastrillo/rastrillo/harness"
+ "github.com/chromedp/cdproto/network"
+ "github.com/chromedp/chromedp"
+)
+
+// TestDocumentStylesSurviveTheProductionCSP: Sheets in Chromium behind
+// the production serving stack (productionHandler: rastrillo's security
+// headers and Sheets' CSP), standalone. A filled cell and an inked cell
+// in the scriptless table view paint differently from a plain one, the
+// table scrolls sideways in its wrapper, and the grid's Format palette
+// swatches carry their colours.
+//
+// Every one of those is a style attribute. Under rastrillo fb806a2's
+// default style-src the browser drops them all — the cell turns
+// transparent on black, the swatch loses --sheet-h, the wrapper stops
+// scrolling — while the other browser checks, which serve app.Router()
+// with no CSP at all, stay green (Astra on #46).
+func TestDocumentStylesSurviveTheProductionCSP(t *testing.T) {
+ app, cookies := signIn(t)
+ s, cells := newSheet(t, app, cookies, "Painted")
+ postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"filled"}`)
+ postCell(t, app, cookies, cells, `{"r":0,"c":1,"raw":"inked"}`)
+ postCell(t, app, cookies, cells, `{"r":0,"c":2,"raw":"plain"}`)
+ postFormatForm(t, app, cookies, s, map[string]string{"r1": "0", "c1": "0", "r2": "0", "c2": "0", "fill": "red", "page": "1"})
+ postFormatForm(t, app, cookies, s, map[string]string{"r1": "0", "c1": "1", "r2": "0", "c2": "1", "ink": "blue", "page": "1"})
+
+ rig := harness.New(t, func(string) http.Handler { return productionHandler(t, app) })
+ run := func(what string, actions ...chromedp.Action) {
+ t.Helper()
+ ctx, cancel := context.WithTimeout(rig.Context(), 20*time.Second)
+ defer cancel()
+ if err := chromedp.Run(ctx, actions...); err != nil {
+ t.Fatalf("%s: %v", what, err)
+ }
+ }
+ var set []chromedp.Action
+ for _, c := range cookies {
+ set = append(set, network.SetCookie(c.Name, c.Value).WithURL(rig.Origin+"/").
+ WithHTTPOnly(c.HttpOnly).WithSecure(c.Secure).WithSameSite(network.CookieSameSiteLax))
+ }
+ run("sign in", set...)
+
+ var table struct {
+ FilledBg, PlainBg, InkedInk, PlainInk, Overflow string
+ }
+ run("table view",
+ chromedp.Navigate(fmt.Sprintf("%s/sheets/%d/table", rig.Origin, s.ID)),
+ chromedp.WaitVisible(`table[data-cell-table]`),
+ chromedp.Evaluate(`(() => {
+ const cell = (x) => [...document.querySelectorAll('table[data-cell-table] tbody td')].find(td => td.textContent.trim() === x);
+ const cs = (x) => getComputedStyle(cell(x));
+ return {
+ FilledBg: cs('filled').backgroundColor,
+ PlainBg: cs('plain').backgroundColor,
+ InkedInk: cs('inked').color,
+ PlainInk: cs('plain').color,
+ Overflow: getComputedStyle(document.querySelector('table[data-cell-table]').parentElement).overflowX,
+ };
+ })()`, &table),
+ )
+ if table.FilledBg == table.PlainBg {
+ t.Errorf("a red-filled cell paints %s, the same as a plain one: its style attribute was refused", table.FilledBg)
+ }
+ if table.InkedInk == table.PlainInk {
+ t.Errorf("a blue-inked cell paints %s text, the same as a plain one: its style attribute was refused", table.InkedInk)
+ }
+ if table.Overflow != "auto" {
+ t.Errorf("the table's wrapper has overflow-x %q, want auto: wide sheets cannot scroll", table.Overflow)
+ }
+
+ var swatch string
+ run("grid palette",
+ chromedp.Navigate(fmt.Sprintf("%s/sheets/%d", rig.Origin, s.ID)),
+ chromedp.WaitVisible(`td[data-x="0"][data-y="0"]`),
+ chromedp.Evaluate(`getComputedStyle(document.querySelector('[data-swatch][data-value="red"]')).getPropertyValue('--sheet-h').trim()`, &swatch),
+ )
+ if swatch == "" {
+ t.Error("the Fill palette's red swatch has no --sheet-h: its style attribute was refused")
+ }
+}
diff --git a/internal/sheets/serving_test.go b/internal/sheets/serving_test.go
new file mode 100644
index 0000000..946a7df
--- /dev/null
+++ b/internal/sheets/serving_test.go
@@ -0,0 +1,145 @@
+package sheets
+
+import (
+ "fmt"
+ "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.Router() 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: quietLogger()}))
+ if err != nil {
+ t.Fatalf("rastrillo.Handler: %v", err)
+ }
+ t.Cleanup(func() { _ = closeFn() })
+ return h
+}
+
+// directives splits a policy into directive name → source list.
+func directives(policy string) map[string]string {
+ out := map[string]string{}
+ for _, d := range strings.Split(policy, ";") {
+ name, value, _ := strings.Cut(strings.TrimSpace(d), " ")
+ if name != "" {
+ out[name] = strings.TrimSpace(value)
+ }
+ }
+ return out
+}
+
+// TestProductionCSPIsTheDefaultBarStyles: Sheets' policy is rastrillo's
+// default with style-src alone widened to 'unsafe-inline'
+// (ContentSecurityPolicy says why). Every other directive must match
+// the framework's, read off a handler built with no CSP of its own, so
+// a rastrillo bump that tightens anything else fails here rather than
+// being silently undone by our constant.
+func TestProductionCSPIsTheDefaultBarStyles(t *testing.T) {
+ bare, closeFn, err := rastrillo.Handler(rastrillo.Options{Mux: http.NewServeMux(), Logger: quietLogger()})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer closeFn()
+ rec := httptest.NewRecorder()
+ bare.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
+ def := directives(rec.Header().Get("Content-Security-Policy"))
+ ours := directives(ContentSecurityPolicy)
+ if len(def) == 0 {
+ t.Fatal("rastrillo set no default CSP")
+ }
+ for name, value := range def {
+ if name == "style-src" {
+ continue
+ }
+ if ours[name] != value {
+ t.Errorf("%s: ours %q, rastrillo's default %q", name, ours[name], value)
+ }
+ }
+ for name := range ours {
+ if _, ok := def[name]; !ok {
+ t.Errorf("ours adds %s, which rastrillo's default does not have", name)
+ }
+ }
+ // A hash or nonce beside 'unsafe-inline' makes the browser ignore
+ // 'unsafe-inline' (CSP3), which is exactly how the styles broke.
+ if got := ours["style-src"]; got != "'self' 'unsafe-inline'" {
+ t.Errorf("style-src = %q, want 'self' 'unsafe-inline' and nothing else", got)
+ }
+}
+
+// TestStandaloneServesInlineStyles: through the production stack, a
+// formatted table page — whose cell colours are custom properties on a
+// style attribute — is served under a policy that lets that attribute
+// apply. On 5e7e873 the header was rastrillo's new default and the cell
+// lost its fill (Astra on #46).
+func TestStandaloneServesInlineStyles(t *testing.T) {
+ app, cookies := signIn(t)
+ s, cells := newSheet(t, app, cookies, "Painted")
+ postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"heading"}`)
+ postFormatForm(t, app, cookies, s, map[string]string{
+ "r1": "0", "c1": "0", "r2": "0", "c2": "0", "fill": "green", "page": "1",
+ })
+ h := productionHandler(t, app)
+ req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/sheets/%d/table", s.ID), nil)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, as(req, cookies))
+ if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `style="--sheet-h:`) {
+ t.Fatalf("table page = %d, or it paints no cell", rec.Code)
+ }
+ policies := rec.Header().Values("Content-Security-Policy")
+ if len(policies) != 1 || policies[0] != ContentSecurityPolicy {
+ t.Fatalf("CSP = %q, want exactly Sheets' policy", policies)
+ }
+}
+
+// TestTheConfirmDoorKeepsEveryPolicy: the confirm door through the
+// production stack carries Sheets' policy and auth's added
+// frame-ancestors 'none'. Browsers enforce every CSP header, so the
+// second one only narrows.
+func TestTheConfirmDoorKeepsEveryPolicy(t *testing.T) {
+ app, mail := appWithRecorder(t)
+ postSignin(t, app, signedInAddress)
+ link := mail.lastLink(t)
+ h := productionHandler(t, app)
+ action, _ := openLink(t, h, link)
+ if action != "/auth/verify" {
+ t.Fatalf("confirm action = %q", action)
+ }
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, strings.TrimPrefix(link, testOrigin), nil))
+ got := rec.Header().Values("Content-Security-Policy")
+ if len(got) != 2 || got[0] != ContentSecurityPolicy || got[1] != "frame-ancestors 'none'" {
+ t.Fatalf("confirm door CSP = %q, want Sheets' 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)
+ }
+ }
+}
+
+// TestHomeModeServesInlineStyles: with Home configured, connect's
+// middleware replaces the policy with connect.FormPolicy. Through the
+// production stack its style-src must still admit the cells' style
+// attributes.
+func TestHomeModeServesInlineStyles(t *testing.T) {
+ f := newHomeFixture(t)
+ h := productionHandler(t, f.app)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, testOrigin+"/signin/local", nil))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("GET /signin/local = %d", rec.Code)
+ }
+ got := rec.Header().Values("Content-Security-Policy")
+ if len(got) != 1 || directives(got[0])["style-src"] != "'self' 'unsafe-inline'" {
+ t.Fatalf("Home-mode CSP = %q, want one policy whose style-src is 'self' 'unsafe-inline'", got)
+ }
+}
diff --git a/internal/sheets/signin.go b/internal/sheets/signin.go
index df02aad..54e50f8 100644
--- a/internal/sheets/signin.go
+++ b/internal/sheets/signin.go
@@ -7,6 +7,7 @@ import (
"amadan.net/oficina/estilo.git/brand"
"amadan.net/oficina/estilo.git/chrome"
"amadan.net/oficina/estilo.git/sky"
+ "amadan.net/rastrillo/rastrillo/auth"
"amadan.net/rastrillo/rastrillo/sessions"
)
@@ -54,11 +55,12 @@ func signinError(code string) (text string, field bool) {
}
}
-// signinView is the sign-in page's own payload: the form, or the sent
-// state that replaces it.
+// signinView is the sign-in page's own payload: the form, the sent
+// state that replaces it, or the confirm step an emailed link opens on.
type signinView struct {
- Form chrome.SignIn
- Sent *chrome.SignInNotice
+ Form chrome.SignIn
+ Sent *chrome.SignInNotice
+ Confirm *signinConfirm
}
// signinFormPath is where the local sign-in form lives. With Home
@@ -81,7 +83,7 @@ func (a *App) signinFormPath() string {
// the skip in that mode.
func (a *App) handleSignin(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
- if a.home == nil && !refusedHere(r) && q.Get("sent") == "" && q.Get("err") == "" && q.Get("force") == "" {
+ if a.home == nil && q.Get("sent") == "" && q.Get("err") == "" && q.Get("force") == "" {
if _, ok := a.identity(r); ok {
http.Redirect(w, r, signedInLanding(r), http.StatusSeeOther)
return
@@ -96,19 +98,15 @@ func (a *App) handleSignin(w http.ResponseWriter, r *http.Request) {
return
}
text, field := signinError(q.Get("err"))
- if refusedHere(r) {
- // Keymail or a link verified the address and Admits said no
- // (doorOnRefusal), in this response. Never from the query: told
- // only to someone who just proved they hold the address; the
- // form below is the way on.
- text, field = "That address can’t sign in to Sheets. Use a different address, or ask someone to share a sheet with it.", false
- }
- form := chrome.SignIn{
- Icon: brand.Path("sheets"),
- App: "Sheets",
- Sky: sky.For(r.Host),
- Action: "/signin",
- }
+ a.renderDoor(w, r, http.StatusOK, text, field)
+}
+
+// renderDoor draws the sign-in form with text as its error, in the
+// address field's own slot when field is set. handleSignin passes the
+// query's error; App.refused passes the refusal with a 403.
+func (a *App) renderDoor(w http.ResponseWriter, r *http.Request, status int, text string, field bool) {
+ form := a.door(r)
+ form.Action = "/signin"
if field {
form.AddressError = text
} else {
@@ -120,10 +118,46 @@ func (a *App) handleSignin(w http.ResponseWriter, r *http.Request) {
// "force" and sends a magic link instead of bouncing back to keymail.
// Without carrying it, a keymail user whose OAuth fails is redirected
// to keymail again on every retry, with no way out.
- if q.Get("force") != "" {
+ if r.URL.Query().Get("force") != "" {
form.Hidden = []chrome.Hidden{{Name: "force", Value: "1"}}
}
- a.render(w, r, "signin", frame{parts: []string{form.Page()}, signinPage: true}, http.StatusOK, signinView{Form: form})
+ a.render(w, r, "signin", frame{parts: []string{form.Page()}, signinPage: true}, status, signinView{Form: form})
+}
+
+// door is the sign-in door's chrome for this instance: Sheets' mark and
+// name on its sky.
+func (a *App) door(r *http.Request) chrome.SignIn {
+ return chrome.SignIn{
+ Icon: brand.Path("sheets"),
+ App: "Sheets",
+ 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 Sheets, 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 (a *App) renderConfirm(w http.ResponseWriter, r *http.Request, d auth.ConfirmPageData) {
+ c := signinConfirm{Door: a.door(r), Action: d.Action, Token: d.Token}
+ c.Door.Lede = "Select Sign in to finish signing in."
+ a.render(w, r, "signin", frame{parts: []string{c.Door.Page()}, signinPage: true}, http.StatusOK, signinView{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
}
// signedInLanding is where an already-signed-in /signin goes: the
diff --git a/internal/sheets/signin_confirm_test.go b/internal/sheets/signin_confirm_test.go
new file mode 100644
index 0000000..6c07523
--- /dev/null
+++ b/internal/sheets/signin_confirm_test.go
@@ -0,0 +1,131 @@
+package sheets
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+)
+
+// TestEmailedLinkOpensTheConfirmDoor walks the magic-link sign-in end to
+// end: begin, the mail, GET of the emailed link (the confirm door, which
+// spends nothing), the door's POST, and a session that opens the list.
+//
+// GET /auth/verify used to sign in on sight, so a mail-security gateway
+// that fetches every link in an inbound message (Safe Links, Proofpoint)
+// spent it before the person clicked, and they were told it had expired.
+// Rastrillo now confirms on GET and redeems on POST; this pins that the
+// confirm step is Sheets' 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, mail := appWithRecorder(t)
+ h := app.Router()
+
+ if w := postSignin(t, app, signedInAddress); 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"))
+ }
+ link := mail.lastLink(t)
+ 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 := httptest.NewRecorder()
+ h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, u.RequestURI(), nil))
+ if rec.Code != http.StatusOK || len(rec.Result().Cookies()) != 0 {
+ t.Fatalf("%s's GET = %d with %d cookies, want 200 and nothing minted", who, rec.Code, len(rec.Result().Cookies()))
+ }
+ body := rec.Body.String()
+ for _, want := range []string{"estilo-door", "estilo-signin-form", `alt=""`, "<span>Sheets</span>", "<title>Sign in · Sheets</title>", `name="token" value="` + u.Query().Get("token") + `"`} {
+ if !strings.Contains(body, want) {
+ t.Errorf("%s: confirm door lacks %q:\n%s", who, want, body)
+ }
+ }
+ if n := strings.Count(body, "<button"); n != 1 || !strings.Contains(body, `type="submit" autofocus>Sign in</button>`) {
+ t.Errorf("%s: confirm door has %d buttons, want exactly one Sign in:\n%s", who, n, body)
+ }
+ if strings.Contains(body, `name="address"`) {
+ t.Errorf("%s: confirm door asks for an address again", who)
+ }
+ if strings.Contains(body, "<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 without a browser's same-origin evidence is refused and
+ // spends nothing.
+ action, token := openLink(t, h, link)
+ cross := httptest.NewRequest(http.MethodPost, action, strings.NewReader(url.Values{"token": {token}}.Encode()))
+ cross.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ cross.Header.Set("Sec-Fetch-Site", "cross-site")
+ if rec := do(t, app, 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") != "/sheets" {
+ t.Fatalf("POST %s = %d %q, want 303 /sheets", action, signed.Code, signed.Header().Get("Location"))
+ }
+ cookies := signed.Result().Cookies()
+ list := page(t, app, cookies, "/sheets")
+ if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), signedInAddress) {
+ t.Fatalf("GET /sheets with the new session = %d, or it does not name %s", list.Code, signedInAddress)
+ }
+
+ // 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"))
+ }
+}
+
+// TestALinkRefusedAtRedemptionIsTheDoor: an address admitted when its
+// link was mailed but not when it is redeemed — a guest whose only grant
+// was revoked in between — gets the sign-in door with the refusal on it,
+// in auth's 403 (auth.Config.Refused, App.refused), and no session.
+func TestALinkRefusedAtRedemptionIsTheDoor(t *testing.T) {
+ app, cookies := signIn(t)
+ s, _ := newSheet(t, app, cookies, "Budget")
+ guest, err := app.grant(s.ID, "ada@elsewhere.example", SheetViewer, signedInMember(t, app))
+ if err != nil {
+ t.Fatal(err)
+ }
+ mail := app.testMailer(t)
+ postSignin(t, app, "ada@elsewhere.example")
+ link := mail.lastLink(t)
+ if err := app.revoke(s.ID, guest.ID); err != nil {
+ t.Fatal(err)
+ }
+
+ rec := followLink(t, app.Router(), link)
+ if rec.Code != http.StatusForbidden || rec.Header().Get("Location") != "" {
+ t.Fatalf("refused redemption = %d %q, want 403 and the door itself", rec.Code, rec.Header().Get("Location"))
+ }
+ for _, c := range rec.Result().Cookies() {
+ if strings.Contains(c.Name, "session") && c.MaxAge >= 0 && c.Value != "" {
+ t.Fatalf("a refused sign-in set a session cookie %q", c.Name)
+ }
+ }
+ body := rec.Body.String()
+ for _, want := range []string{"estilo-door", `name="address"`, `action="/signin"`, "can’t sign in to Sheets"} {
+ if !strings.Contains(body, want) {
+ t.Errorf("refusal door lacks %q:\n%s", want, body)
+ }
+ }
+}
diff --git a/internal/sheets/signin_test.go b/internal/sheets/signin_test.go
index 3acb42e..af0b72a 100644
--- a/internal/sheets/signin_test.go
+++ b/internal/sheets/signin_test.go
@@ -81,20 +81,23 @@ func testAuth(t *testing.T) *App {
if _, err := migrate.Apply(context.Background(), d, BootSchema); err != nil {
t.Fatalf("migrate: %v", err)
}
+ app := New(Config{DB: d.G, Admit: Admits([]string{"paul@example.com"})})
a, err := NewAuth(d.Writer(), AuthEnv{
Origin: "https://sheets.example.com",
InstanceKey: "test-instance-key",
Owners: []string{"paul@example.com"},
Mailer: nil, // auth falls back to a logging sender; no mail is sent in this test
- }, quietLogger(), Admits([]string{"paul@example.com"}))
+ }, quietLogger(), app)
if err != nil {
t.Fatalf("NewAuth: %v", err)
}
t.Cleanup(func() { CloseSigninMail(a) })
- return New(Config{DB: d.G, Auth: a, Admit: Admits([]string{"paul@example.com"})})
+ app.UseAuth(a)
+ return app
}
-// TestAuthRoutesAreMounted proves the four handlers are reachable. It
+// TestAuthRoutesAreMounted proves auth's handlers are reachable — Verify
+// on POST as well as GET, or the confirm door's button gets a 405. It
// exists because Router mounts them behind `if a.auth != nil`, and every
// other test in this package passes nil — so without this, the whole
// sign-in surface could be unmounted and the suite would stay green.
@@ -104,6 +107,7 @@ func TestAuthRoutesAreMounted(t *testing.T) {
{http.MethodPost, "/signin"},
{http.MethodGet, "/auth/callback"},
{http.MethodGet, "/auth/verify"},
+ {http.MethodPost, "/auth/verify"},
{http.MethodPost, "/signout"},
} {
rec := httptest.NewRecorder()
diff --git a/internal/sheets/signin_timing_test.go b/internal/sheets/signin_timing_test.go
index 3e2a104..1d8d4d3 100644
--- a/internal/sheets/signin_timing_test.go
+++ b/internal/sheets/signin_timing_test.go
@@ -58,7 +58,7 @@ func TestSigninNeverWaitsForTheMailer(t *testing.T) {
}
app := New(Config{DB: d.G, Sealer: sealer, Origin: testOrigin, Admit: Admits([]string{signedInAddress})})
bm := &blockingMailer{release: make(chan struct{})}
- a, err := NewAuth(d.Writer(), AuthEnv{Origin: testOrigin, InstanceKey: "test-instance-key", Owners: []string{signedInAddress}, Mailer: bm}, quietLogger(), app.Admits)
+ a, err := NewAuth(d.Writer(), AuthEnv{Origin: testOrigin, InstanceKey: "test-instance-key", Owners: []string{signedInAddress}, Mailer: bm}, quietLogger(), app)
if err != nil {
t.Fatal(err)
}
diff --git a/internal/sheets/static/tokens.css b/internal/sheets/static/tokens.css
index 10f6da5..6886854 100644
--- a/internal/sheets/static/tokens.css
+++ b/internal/sheets/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/sheets/templates/signin.html b/internal/sheets/templates/signin.html
index 1ea067a..a6c27d4 100644
--- a/internal/sheets/templates/signin.html
+++ b/internal/sheets/templates/signin.html
@@ -5,5 +5,19 @@
button stranded at the end of a 704px form. Every message comes from
signinError's fixed set — there is no path from the URL into this
page. */}}
-{{with .Data.Sent}}{{template "estilo-signin-sent" .}}{{else}}{{template "estilo-signin" .Data.Form}}{{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"}}
+{{- else with .Data.Sent}}{{template "estilo-signin-sent" .}}{{else}}{{template "estilo-signin" .Data.Form}}{{end}}
{{end}}